mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
feat(session-start): make instinct injection count and confidence threshold configurable (#2413)
* feat(session-start): make instinct injection count and confidence threshold configurable Expose ECC_MAX_INJECTED_INSTINCTS and ECC_INSTINCT_CONFIDENCE_THRESHOLD so operators can tune SessionStart instinct injection without editing source. Defaults are unchanged (6 instincts, 0.7 confidence floor). The two previously hardcoded constants become DEFAULT_-prefixed fallbacks, resolved through getMaxInjectedInstincts() and getInstinctConfidenceThreshold(), mirroring the existing getSessionRetentionDays() / getSessionStartMaxContextChars() env-override pattern already in this file. Invalid or out-of-range values fall back to the defaults. Adds subprocess coverage in tests/hooks/hooks.test.js and documents both variables in the README Hook Runtime Controls section. Implements part (a) of #2371. * fix(session-start): reject partial env values for instinct injection knobs Parse ECC_MAX_INJECTED_INSTINCTS and ECC_INSTINCT_CONFIDENCE_THRESHOLD with Number() (after trim) instead of parseInt/parseFloat, so malformed values like "3.9", "6abc", or "0.7x" fall back to the default rather than silently accepting the numeric prefix (parseInt("3.9")=3, parseFloat("0.7x")=0.7). Adds a regression assertion that a non-integer count falls back to 6. * fix(session-start): validate decimal grammar for instinct injection env vars Number() still accepts non-decimal numeric syntax, so ECC_INSTINCT_CONFIDENCE_THRESHOLD=0x1 resolved to 1 and ECC_MAX_INJECTED_INSTINCTS=1e2 to 100. Gate each value on a strict format (/^\d+(\.\d+)?$/ for the 0-1 threshold, /^\d+$/ for the positive-integer count) before converting, so hex/exponent/partial values fall back to the default. Adds regression assertions for 1e2 and 0x1.
This commit is contained in:
parent
41599069c3
commit
ff4a06dd91
3 changed files with 148 additions and 4 deletions
|
|
@ -528,6 +528,12 @@ export ECC_SESSION_START_CONTEXT=off
|
||||||
# Set to 0, off, false, disabled, never, or none to keep all sessions (disable pruning).
|
# Set to 0, off, false, disabled, never, or none to keep all sessions (disable pruning).
|
||||||
export ECC_SESSION_RETENTION_DAYS=14
|
export ECC_SESSION_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# Cap how many learned instincts SessionStart injects into context (default: 6)
|
||||||
|
export ECC_MAX_INJECTED_INSTINCTS=6
|
||||||
|
|
||||||
|
# Minimum confidence an instinct needs to be injected, 0-1 (default: 0.7)
|
||||||
|
export ECC_INSTINCT_CONFIDENCE_THRESHOLD=0.7
|
||||||
|
|
||||||
# Keep context/scope/loop warnings but suppress API-rate cost estimates
|
# Keep context/scope/loop warnings but suppress API-rate cost estimates
|
||||||
export ECC_CONTEXT_MONITOR_COST_WARNINGS=off
|
export ECC_CONTEXT_MONITOR_COST_WARNINGS=off
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ const { detectProjectType } = require('../lib/project-detect');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
const INSTINCT_CONFIDENCE_THRESHOLD = 0.7;
|
const DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD = 0.7;
|
||||||
const MAX_INJECTED_INSTINCTS = 6;
|
const DEFAULT_MAX_INJECTED_INSTINCTS = 6;
|
||||||
const MAX_INJECTED_LEARNED_SKILLS = 6;
|
const MAX_INJECTED_LEARNED_SKILLS = 6;
|
||||||
const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220;
|
const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220;
|
||||||
const DEFAULT_SESSION_START_CONTEXT_MAX_CHARS = 8000;
|
const DEFAULT_SESSION_START_CONTEXT_MAX_CHARS = 8000;
|
||||||
|
|
@ -116,6 +116,52 @@ function getSessionStartMaxContextChars() {
|
||||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_SESSION_START_CONTEXT_MAX_CHARS;
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_SESSION_START_CONTEXT_MAX_CHARS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the minimum confidence an instinct needs to be injected at
|
||||||
|
* SessionStart. Overridable via `ECC_INSTINCT_CONFIDENCE_THRESHOLD`
|
||||||
|
* (a number in [0, 1]); falsy or out-of-range values fall back to
|
||||||
|
* {@link DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD}.
|
||||||
|
*
|
||||||
|
* @returns {number} The confidence floor for injected instincts.
|
||||||
|
*/
|
||||||
|
function getInstinctConfidenceThreshold() {
|
||||||
|
const raw = process.env.ECC_INSTINCT_CONFIDENCE_THRESHOLD;
|
||||||
|
if (!raw) return DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD;
|
||||||
|
|
||||||
|
// Require a plain decimal (e.g. "0.7", "1", "0.95") so trailing junk
|
||||||
|
// ("0.7x") and non-decimal numeric syntax like "0x1" (hex) or "1e2"
|
||||||
|
// (exponent) are rejected whole rather than silently accepted by Number().
|
||||||
|
const normalized = raw.trim();
|
||||||
|
if (!/^\d+(\.\d+)?$/.test(normalized)) return DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD;
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
|
||||||
|
? parsed
|
||||||
|
: DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the maximum number of instincts injected at SessionStart.
|
||||||
|
* Overridable via `ECC_MAX_INJECTED_INSTINCTS` (a positive integer);
|
||||||
|
* falsy or invalid values fall back to
|
||||||
|
* {@link DEFAULT_MAX_INJECTED_INSTINCTS}.
|
||||||
|
*
|
||||||
|
* @returns {number} The cap on injected instincts.
|
||||||
|
*/
|
||||||
|
function getMaxInjectedInstincts() {
|
||||||
|
const raw = process.env.ECC_MAX_INJECTED_INSTINCTS;
|
||||||
|
if (!raw) return DEFAULT_MAX_INJECTED_INSTINCTS;
|
||||||
|
|
||||||
|
// Require a plain non-negative integer so "3.9", "6abc", "0x1" (hex),
|
||||||
|
// and "1e2" (exponent) are rejected whole and fall back to the default,
|
||||||
|
// rather than parseInt truncating or Number() accepting alternate syntax.
|
||||||
|
const normalized = raw.trim();
|
||||||
|
if (!/^\d+$/.test(normalized)) return DEFAULT_MAX_INJECTED_INSTINCTS;
|
||||||
|
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_INJECTED_INSTINCTS;
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionStartMode(rawInput) {
|
function getSessionStartMode(rawInput) {
|
||||||
const input = String(rawInput || '');
|
const input = String(rawInput || '');
|
||||||
if (!input.trim()) return null;
|
if (!input.trim()) return null;
|
||||||
|
|
@ -373,9 +419,12 @@ function summarizeActiveInstincts(observerContext) {
|
||||||
...globalDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)),
|
...globalDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const confidenceThreshold = getInstinctConfidenceThreshold();
|
||||||
|
const maxInjected = getMaxInjectedInstincts();
|
||||||
|
|
||||||
const deduped = new Map();
|
const deduped = new Map();
|
||||||
for (const instinct of scopedInstincts) {
|
for (const instinct of scopedInstincts) {
|
||||||
if (!instinct.id || instinct.confidence < INSTINCT_CONFIDENCE_THRESHOLD) continue;
|
if (!instinct.id || instinct.confidence < confidenceThreshold) continue;
|
||||||
const existing = deduped.get(instinct.id);
|
const existing = deduped.get(instinct.id);
|
||||||
if (!existing || (existing._scopeLabel !== 'project' && instinct._scopeLabel === 'project')) {
|
if (!existing || (existing._scopeLabel !== 'project' && instinct._scopeLabel === 'project')) {
|
||||||
deduped.set(instinct.id, instinct);
|
deduped.set(instinct.id, instinct);
|
||||||
|
|
@ -393,7 +442,7 @@ function summarizeActiveInstincts(observerContext) {
|
||||||
if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1;
|
if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1;
|
||||||
return String(left.id).localeCompare(String(right.id));
|
return String(left.id).localeCompare(String(right.id));
|
||||||
})
|
})
|
||||||
.slice(0, MAX_INJECTED_INSTINCTS);
|
.slice(0, maxInjected);
|
||||||
|
|
||||||
if (ranked.length === 0) {
|
if (ranked.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
|
|
|
||||||
|
|
@ -511,6 +511,95 @@ async function runTests() {
|
||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('honors ECC_MAX_INJECTED_INSTINCTS for injected instincts', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-max-instincts-${Date.now()}`);
|
||||||
|
const homunculusDir = path.join(isoHome, 'homunculus');
|
||||||
|
const instinctsDir = path.join(homunculusDir, 'instincts', 'personal');
|
||||||
|
fs.mkdirSync(instinctsDir, { recursive: true });
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(instinctsDir, `instinct-${i}.md`),
|
||||||
|
`---\nid: max-instinct-${i}\nconfidence: 0.9\n---\n## Action\nDo configurable thing number ${i}.\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const baseEnv = { HOME: isoHome, USERPROFILE: isoHome, CLV2_HOMUNCULUS_DIR: homunculusDir };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const def = await runScript(path.join(scriptsDir, 'session-start.js'), '', baseEnv);
|
||||||
|
assert.strictEqual(def.code, 0);
|
||||||
|
assert.ok(def.stderr.includes('Injecting 6 instinct(s)'), `default cap should inject 6, stderr: ${def.stderr}`);
|
||||||
|
|
||||||
|
const capped = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_MAX_INJECTED_INSTINCTS: '3' });
|
||||||
|
assert.strictEqual(capped.code, 0);
|
||||||
|
assert.ok(capped.stderr.includes('Injecting 3 instinct(s)'), `override should inject 3, stderr: ${capped.stderr}`);
|
||||||
|
|
||||||
|
const garbage = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_MAX_INJECTED_INSTINCTS: 'not-a-number' });
|
||||||
|
assert.strictEqual(garbage.code, 0);
|
||||||
|
assert.ok(garbage.stderr.includes('Injecting 6 instinct(s)'), `garbage override should fall back to default 6, stderr: ${garbage.stderr}`);
|
||||||
|
|
||||||
|
// A partial/non-integer value must be rejected whole, not truncated.
|
||||||
|
const partial = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_MAX_INJECTED_INSTINCTS: '3.9' });
|
||||||
|
assert.strictEqual(partial.code, 0);
|
||||||
|
assert.ok(partial.stderr.includes('Injecting 6 instinct(s)'), `non-integer override (3.9) should fall back to default 6, not truncate to 3, stderr: ${partial.stderr}`);
|
||||||
|
|
||||||
|
// Non-decimal numeric syntax (exponent, hex) must be rejected too.
|
||||||
|
// If "1e2" were accepted as 100, all 8 fixtures would inject; the
|
||||||
|
// default cap of 6 proves it fell back.
|
||||||
|
const exponent = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_MAX_INJECTED_INSTINCTS: '1e2' });
|
||||||
|
assert.strictEqual(exponent.code, 0);
|
||||||
|
assert.ok(exponent.stderr.includes('Injecting 6 instinct(s)'), `exponent override (1e2) should fall back to default 6, stderr: ${exponent.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('honors ECC_INSTINCT_CONFIDENCE_THRESHOLD for injected instincts', async () => {
|
||||||
|
const isoHome = path.join(os.tmpdir(), `ecc-instinct-threshold-${Date.now()}`);
|
||||||
|
const homunculusDir = path.join(isoHome, 'homunculus');
|
||||||
|
const instinctsDir = path.join(homunculusDir, 'instincts', 'personal');
|
||||||
|
fs.mkdirSync(instinctsDir, { recursive: true });
|
||||||
|
// 4 high-confidence (0.9) + 4 low-confidence (0.6) instincts.
|
||||||
|
for (let i = 1; i <= 8; i++) {
|
||||||
|
const confidence = i <= 4 ? '0.9' : '0.6';
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(instinctsDir, `instinct-${i}.md`),
|
||||||
|
`---\nid: thr-instinct-${i}\nconfidence: ${confidence}\n---\n## Action\nDo threshold thing number ${i}.\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const baseEnv = { HOME: isoHome, USERPROFILE: isoHome, CLV2_HOMUNCULUS_DIR: homunculusDir };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const def = await runScript(path.join(scriptsDir, 'session-start.js'), '', baseEnv);
|
||||||
|
assert.strictEqual(def.code, 0);
|
||||||
|
assert.ok(def.stderr.includes('Injecting 4 instinct(s)'), `default 0.7 threshold should inject only the four 0.9 instincts, stderr: ${def.stderr}`);
|
||||||
|
|
||||||
|
const raised = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_INSTINCT_CONFIDENCE_THRESHOLD: '0.95' });
|
||||||
|
assert.strictEqual(raised.code, 0);
|
||||||
|
assert.ok(!raised.stderr.includes('instinct(s) into session context'), `0.95 threshold should filter out all instincts, stderr: ${raised.stderr}`);
|
||||||
|
|
||||||
|
const lowered = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_INSTINCT_CONFIDENCE_THRESHOLD: '0.5' });
|
||||||
|
assert.strictEqual(lowered.code, 0);
|
||||||
|
assert.ok(lowered.stderr.includes('Injecting 6 instinct(s)'), `0.5 threshold should pass all eight but cap at the default 6, stderr: ${lowered.stderr}`);
|
||||||
|
|
||||||
|
// Non-decimal syntax must fall back to the default 0.7, not be read
|
||||||
|
// as hex. If "0x1" were accepted as 1.0, zero instincts would inject
|
||||||
|
// (none are at full confidence); the default 0.7 injects the four 0.9s.
|
||||||
|
const hex = await runScript(path.join(scriptsDir, 'session-start.js'), '', { ...baseEnv, ECC_INSTINCT_CONFIDENCE_THRESHOLD: '0x1' });
|
||||||
|
assert.strictEqual(hex.code, 0);
|
||||||
|
assert.ok(hex.stderr.includes('Injecting 4 instinct(s)'), `hex threshold (0x1) should fall back to the 0.7 default and inject the four 0.9 instincts, stderr: ${hex.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
await asyncTest('disables session-start additional context when requested', async () => {
|
await asyncTest('disables session-start additional context when requested', async () => {
|
||||||
const isoHome = path.join(os.tmpdir(), `ecc-disabled-start-${Date.now()}`);
|
const isoHome = path.join(os.tmpdir(), `ecc-disabled-start-${Date.now()}`);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue