diff --git a/README.md b/README.md index 71152c5dc..bdc9298bf 100644 --- a/README.md +++ b/README.md @@ -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). 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 export ECC_CONTEXT_MONITOR_COST_WARNINGS=off ``` diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 17bbc1a1d..4cfc443ec 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -27,8 +27,8 @@ const { detectProjectType } = require('../lib/project-detect'); const path = require('path'); const fs = require('fs'); -const INSTINCT_CONFIDENCE_THRESHOLD = 0.7; -const MAX_INJECTED_INSTINCTS = 6; +const DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD = 0.7; +const DEFAULT_MAX_INJECTED_INSTINCTS = 6; const MAX_INJECTED_LEARNED_SKILLS = 6; const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220; 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; } +/** + * 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) { const input = String(rawInput || ''); if (!input.trim()) return null; @@ -373,9 +419,12 @@ function summarizeActiveInstincts(observerContext) { ...globalDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)), ]; + const confidenceThreshold = getInstinctConfidenceThreshold(); + const maxInjected = getMaxInjectedInstincts(); + const deduped = new Map(); 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); if (!existing || (existing._scopeLabel !== 'project' && instinct._scopeLabel === 'project')) { deduped.set(instinct.id, instinct); @@ -393,7 +442,7 @@ function summarizeActiveInstincts(observerContext) { if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1; return String(left.id).localeCompare(String(right.id)); }) - .slice(0, MAX_INJECTED_INSTINCTS); + .slice(0, maxInjected); if (ranked.length === 0) { return ''; diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 8ccde106b..c7a61ef78 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -511,6 +511,95 @@ async function runTests() { passed++; 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 ( await asyncTest('disables session-start additional context when requested', async () => { const isoHome = path.join(os.tmpdir(), `ecc-disabled-start-${Date.now()}`);