@ijfw/memory-server 1.4.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +1 -1
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +550 -14
- package/src/cross-orchestrator.js +1171 -10
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +17 -2
- package/src/dashboard-server.js +152 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +27 -1
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +323 -0
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +554 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +152 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/obsidian-parser.js +91 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +10 -0
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +249 -0
- package/src/orchestrator/review.js +136 -0
- package/src/orchestrator/runtime-loop.js +430 -0
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1764 -0
- package/src/orchestrator/status-protocol.js +235 -0
- package/src/orchestrator/subagent-telemetry.js +452 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +281 -0
- package/src/orchestrator/wave-state.js +564 -0
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +656 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -0
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +603 -59
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +113 -12
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +41 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +41 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +278 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recovery/truncation.js — v1.5.0 T20: subagent truncation detection +
|
|
3
|
+
* recovery against the T4 intent-journal + T5 per-subagent event stream.
|
|
4
|
+
*
|
|
5
|
+
* ROLES:
|
|
6
|
+
* * `detectTruncation({events, journal, expectedTerminalVerb})` — pure
|
|
7
|
+
* classification of a subagent's event stream + journal state. Returns
|
|
8
|
+
* `{ truncated:boolean, reason:string }`. The "tell" signals (in
|
|
9
|
+
* priority order, per T19's reporter contract):
|
|
10
|
+
* 1. `events` ends with `outcome:'error'` → truncated:'error-terminated'
|
|
11
|
+
* 2. `events` is empty AND `journal` has an open
|
|
12
|
+
* begin (begin without commit) → truncated:'no-events-open-begin'
|
|
13
|
+
* 3. last event predates the wave's expected
|
|
14
|
+
* terminal verb (e.g. `subagent.post-done`) → truncated:'missing-terminal'
|
|
15
|
+
* 4. journal has any begin-without-commit
|
|
16
|
+
* regardless of event stream end → truncated:'open-partial'
|
|
17
|
+
* 5. else → truncated:false (clean)
|
|
18
|
+
*
|
|
19
|
+
* * `recoverSubagent({projectRoot, waveId, subId, sinceVerbId})` — calls
|
|
20
|
+
* `query('state.replay', { sinceVerbId })` which (per T4) snapshot-rolls-
|
|
21
|
+
* back overwrite-verb partials + seals append-verb partials. Returns
|
|
22
|
+
* `{ recovered:boolean, replayResult, verdict, reason }`.
|
|
23
|
+
*
|
|
24
|
+
* * `measureTruncationRate({ fixtures, runOne })` — iterates a corpus of
|
|
25
|
+
* fixtures, runs the recovery routine against each via `runOne`, and
|
|
26
|
+
* returns `{ corpusSize, truncatedCount, recoveredCount, unrecoveredCount,
|
|
27
|
+
* ratePostRecovery, baselineRate, byCategory[] }`. The "rate" we publish
|
|
28
|
+
* is the FRACTION OF FIXTURES WHERE A TRUNCATION OCCURRED AND RECOVERY
|
|
29
|
+
* COULD NOT RESTORE THE EXPECTED FINAL STATE — i.e. the truncation rate
|
|
30
|
+
* that SURVIVES recovery. Clean fixtures (no truncation injected) are
|
|
31
|
+
* part of the denominator because they prove recovery does not corrupt
|
|
32
|
+
* non-truncated runs.
|
|
33
|
+
*
|
|
34
|
+
* * `writeRateArtifact(projectRoot, result)` — atomically persists the
|
|
35
|
+
* measurement to `<projectRoot>/.ijfw/telemetry/truncation-rate.json`,
|
|
36
|
+
* fitting the T21 convergence-telemetry directory convention.
|
|
37
|
+
*
|
|
38
|
+
* NO PRODUCTION DEPENDENCIES; ESM; Node >=18.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
42
|
+
import { join, dirname } from 'node:path';
|
|
43
|
+
|
|
44
|
+
import { writeAtomic } from '../lib/atomic-io.js';
|
|
45
|
+
import { query } from '../orchestrator/state-sdk.js';
|
|
46
|
+
|
|
47
|
+
// -- Constants ------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Brief-locked threshold (BUILD-PLAN T20): the measured truncation rate that
|
|
51
|
+
* survives recovery MUST be at or below this fraction to pass. Halves the
|
|
52
|
+
* documented 62% baseline (see PLAN-CROSS-AUDIT-ADJUDICATION row M6).
|
|
53
|
+
*/
|
|
54
|
+
export const TRUNCATION_RATE_THRESHOLD = 0.31;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Documented v1.4.x truncation-rate baseline (the rate WITHOUT this T20
|
|
58
|
+
* recovery layer). Published so the artifact is self-describing — anyone
|
|
59
|
+
* reading `.ijfw/telemetry/truncation-rate.json` can compute the improvement
|
|
60
|
+
* factor against this number.
|
|
61
|
+
*/
|
|
62
|
+
export const TRUNCATION_BASELINE_RATE = 0.62;
|
|
63
|
+
|
|
64
|
+
/** Default expected terminal verb for a subagent's event stream. */
|
|
65
|
+
export const DEFAULT_TERMINAL_VERB = 'subagent.post-done';
|
|
66
|
+
|
|
67
|
+
// -- Detection -----------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Classify a subagent's event stream + journal as truncated-or-not.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} args
|
|
73
|
+
* @param {object[]} args.events the event stream (T5 envelope shape)
|
|
74
|
+
* @param {object[]} [args.journal] intent-journal records visible to recovery
|
|
75
|
+
* @param {string} [args.expectedTerminalVerb] wave's expected terminal verb
|
|
76
|
+
* (default `subagent.post-done`)
|
|
77
|
+
* @returns {{truncated: false|string, reason: string, lastEvent: object|null}}
|
|
78
|
+
*/
|
|
79
|
+
export function detectTruncation({
|
|
80
|
+
events, journal, expectedTerminalVerb,
|
|
81
|
+
} = {}) {
|
|
82
|
+
const ev = Array.isArray(events) ? events : [];
|
|
83
|
+
const j = Array.isArray(journal) ? journal : [];
|
|
84
|
+
const terminal = typeof expectedTerminalVerb === 'string' && expectedTerminalVerb
|
|
85
|
+
? expectedTerminalVerb
|
|
86
|
+
: DEFAULT_TERMINAL_VERB;
|
|
87
|
+
|
|
88
|
+
const lastEvent = ev.length > 0 ? ev[ev.length - 1] : null;
|
|
89
|
+
|
|
90
|
+
// Build a quick view of journal partial-vs-committed.
|
|
91
|
+
const commits = new Set();
|
|
92
|
+
const begins = new Map();
|
|
93
|
+
for (const r of j) {
|
|
94
|
+
if (!r || typeof r.verbId !== 'string') continue;
|
|
95
|
+
if (r.phase === 'commit') commits.add(r.verbId);
|
|
96
|
+
else if (r.phase === 'begin') begins.set(r.verbId, r);
|
|
97
|
+
}
|
|
98
|
+
const openPartials = [];
|
|
99
|
+
for (const [verbId, beginRec] of begins) {
|
|
100
|
+
if (!commits.has(verbId)) openPartials.push(beginRec);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 1. outcome:'error' tail — T19 contract: the subagent's stream ending
|
|
104
|
+
// with an error verb is the canonical truncation tell.
|
|
105
|
+
if (lastEvent && lastEvent.outcome === 'error') {
|
|
106
|
+
return { truncated: 'error-terminated', reason: `last event outcome='error' (verb=${lastEvent.verb})`, lastEvent };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. No events at all BUT journal carries an open begin → subagent
|
|
110
|
+
// started a mutating verb and never emitted (truncated before tap).
|
|
111
|
+
if (ev.length === 0 && openPartials.length > 0) {
|
|
112
|
+
return {
|
|
113
|
+
truncated: 'no-events-open-begin',
|
|
114
|
+
reason: `no events, ${openPartials.length} open begin(s) in journal`,
|
|
115
|
+
lastEvent: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 3. Last event is not the expected terminal verb. We accept either an
|
|
120
|
+
// exact terminal-verb match OR an outcome marker explicitly indicating
|
|
121
|
+
// a clean exit ('ok' with the terminal verb). Anything else with an
|
|
122
|
+
// open partial OR with no terminal at all is treated as truncated.
|
|
123
|
+
const lastIsTerminal = lastEvent && lastEvent.verb === terminal
|
|
124
|
+
&& (lastEvent.outcome === 'ok' || lastEvent.outcome === 'advisory');
|
|
125
|
+
|
|
126
|
+
if (!lastIsTerminal && openPartials.length > 0) {
|
|
127
|
+
return {
|
|
128
|
+
truncated: 'open-partial',
|
|
129
|
+
reason: `open begin without commit (${openPartials.length}) and no terminal verb`,
|
|
130
|
+
lastEvent,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!lastIsTerminal && ev.length > 0) {
|
|
135
|
+
return {
|
|
136
|
+
truncated: 'missing-terminal',
|
|
137
|
+
reason: `last event verb='${lastEvent.verb}' (expected terminal '${terminal}')`,
|
|
138
|
+
lastEvent,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 4. Clean: last event is the expected terminal AND no open partials.
|
|
143
|
+
if (openPartials.length > 0) {
|
|
144
|
+
// Edge: terminal verb fired but a partial mutating verb never committed.
|
|
145
|
+
// Treat as truncated — recovery should still seal/rollback the partial.
|
|
146
|
+
return {
|
|
147
|
+
truncated: 'open-partial',
|
|
148
|
+
reason: `terminal verb emitted but ${openPartials.length} open begin(s) remain`,
|
|
149
|
+
lastEvent,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { truncated: false, reason: 'clean exit', lastEvent };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// -- Recovery ------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Apply T4's `state.replay` to recover a truncated subagent. Snapshot-rolls
|
|
160
|
+
* back overwrite-verb partials; seals append-verb partials in place; leaves
|
|
161
|
+
* already-committed verbs untouched.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} args
|
|
164
|
+
* @param {string} args.projectRoot the wave's project root
|
|
165
|
+
* @param {string} args.waveId the truncated subagent's wave
|
|
166
|
+
* @param {string} args.subId the truncated subagent's id
|
|
167
|
+
* @param {string} [args.sinceVerbId] scope replay to verbs at/after this id
|
|
168
|
+
* (default — full journal)
|
|
169
|
+
* @returns {Promise<{recovered:boolean, replayResult:object, reason:string}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function recoverSubagent({
|
|
172
|
+
projectRoot, waveId, subId, sinceVerbId,
|
|
173
|
+
} = {}) {
|
|
174
|
+
if (typeof projectRoot !== 'string' || !projectRoot) {
|
|
175
|
+
throw new Error('recovery/truncation: projectRoot required');
|
|
176
|
+
}
|
|
177
|
+
const payload = {};
|
|
178
|
+
if (typeof sinceVerbId === 'string' && sinceVerbId) payload.sinceVerbId = sinceVerbId;
|
|
179
|
+
const ctx = { projectRoot, subagentId: subId };
|
|
180
|
+
let replayResult;
|
|
181
|
+
try {
|
|
182
|
+
replayResult = await query('state.replay', payload, ctx);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return {
|
|
185
|
+
recovered: false,
|
|
186
|
+
replayResult: null,
|
|
187
|
+
reason: `replay threw: ${err?.message || err}`,
|
|
188
|
+
waveId,
|
|
189
|
+
subId,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const sealed = Array.isArray(replayResult?.sealed) ? replayResult.sealed.length : 0;
|
|
193
|
+
const rolledBack = Array.isArray(replayResult?.rolledBack) ? replayResult.rolledBack.length : 0;
|
|
194
|
+
const skipped = Array.isArray(replayResult?.skipped) ? replayResult.skipped.length : 0;
|
|
195
|
+
return {
|
|
196
|
+
recovered: replayResult?.ok === true,
|
|
197
|
+
replayResult,
|
|
198
|
+
reason: `replay ok=${replayResult?.ok} sealed=${sealed} rolledBack=${rolledBack} skipped=${skipped}`,
|
|
199
|
+
waveId,
|
|
200
|
+
subId,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -- Corpus harness ------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Discover fixture subdirectories under a corpus root. Every subdir must
|
|
208
|
+
* carry a `meta.json`; subdirs without one are skipped (allows README files /
|
|
209
|
+
* stray dirs to coexist).
|
|
210
|
+
*
|
|
211
|
+
* @param {string} corpusDir
|
|
212
|
+
* @returns {{id:string, dir:string, meta:object}[]}
|
|
213
|
+
*/
|
|
214
|
+
export function listFixtures(corpusDir) {
|
|
215
|
+
if (!existsSync(corpusDir)) return [];
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const name of readdirSync(corpusDir).sort()) {
|
|
218
|
+
const dir = join(corpusDir, name);
|
|
219
|
+
const metaPath = join(dir, 'meta.json');
|
|
220
|
+
if (!existsSync(metaPath)) continue;
|
|
221
|
+
let meta;
|
|
222
|
+
try { meta = JSON.parse(readFileSync(metaPath, 'utf8')); }
|
|
223
|
+
catch { continue; }
|
|
224
|
+
out.push({ id: name, dir, meta });
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Run the recovery routine across a corpus + return a measurement.
|
|
231
|
+
*
|
|
232
|
+
* The caller supplies `runOne(fixture)` — a function that materialises the
|
|
233
|
+
* fixture into a real temp project, invokes `recoverSubagent`, and returns
|
|
234
|
+
* `{ truncated, recovered, expectedFinalStateMatches, category }`. We keep
|
|
235
|
+
* runOne pluggable so the test can use real `query()` while a future caller
|
|
236
|
+
* could plug in a different I/O backend without changing this aggregator.
|
|
237
|
+
*
|
|
238
|
+
* @param {object} args
|
|
239
|
+
* @param {{id:string, meta:object}[]} args.fixtures
|
|
240
|
+
* @param {(fixture:object) => Promise<{truncated:string|false, recovered:boolean, expectedFinalStateMatches:boolean, category:string}>} args.runOne
|
|
241
|
+
* @returns {Promise<{corpusSize, truncatedCount, recoveredCount,
|
|
242
|
+
* unrecoveredCount, ratePostRecovery, baselineRate,
|
|
243
|
+
* byCategory:Array, fixtures:Array}>}
|
|
244
|
+
*/
|
|
245
|
+
export async function measureTruncationRate({ fixtures, runOne }) {
|
|
246
|
+
if (!Array.isArray(fixtures)) throw new Error('measureTruncationRate: fixtures[] required');
|
|
247
|
+
if (typeof runOne !== 'function') throw new Error('measureTruncationRate: runOne fn required');
|
|
248
|
+
|
|
249
|
+
const perFixture = [];
|
|
250
|
+
const byCategoryMap = new Map();
|
|
251
|
+
for (const fx of fixtures) {
|
|
252
|
+
const r = await runOne(fx);
|
|
253
|
+
const row = {
|
|
254
|
+
id: fx.id,
|
|
255
|
+
category: r.category || fx.meta?.category || 'unknown',
|
|
256
|
+
truncated: r.truncated,
|
|
257
|
+
recovered: r.recovered,
|
|
258
|
+
expectedFinalStateMatches: r.expectedFinalStateMatches,
|
|
259
|
+
};
|
|
260
|
+
perFixture.push(row);
|
|
261
|
+
const key = row.category;
|
|
262
|
+
if (!byCategoryMap.has(key)) {
|
|
263
|
+
byCategoryMap.set(key, {
|
|
264
|
+
category: key, total: 0, truncated: 0, unrecovered: 0,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const bucket = byCategoryMap.get(key);
|
|
268
|
+
bucket.total += 1;
|
|
269
|
+
if (row.truncated) bucket.truncated += 1;
|
|
270
|
+
if (!row.expectedFinalStateMatches) bucket.unrecovered += 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const corpusSize = perFixture.length;
|
|
274
|
+
const truncatedCount = perFixture.filter((r) => r.truncated).length;
|
|
275
|
+
// A fixture COUNTS AS UNRECOVERED when its post-recovery state does not
|
|
276
|
+
// match the expected final state. Clean fixtures contribute 0 (their
|
|
277
|
+
// expectedFinalStateMatches is required to be true regardless).
|
|
278
|
+
const unrecoveredCount = perFixture.filter((r) => !r.expectedFinalStateMatches).length;
|
|
279
|
+
const recoveredCount = corpusSize - unrecoveredCount;
|
|
280
|
+
const ratePostRecovery = corpusSize === 0 ? 0 : unrecoveredCount / corpusSize;
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
corpusSize,
|
|
284
|
+
truncatedCount,
|
|
285
|
+
recoveredCount,
|
|
286
|
+
unrecoveredCount,
|
|
287
|
+
ratePostRecovery,
|
|
288
|
+
baselineRate: TRUNCATION_BASELINE_RATE,
|
|
289
|
+
threshold: TRUNCATION_RATE_THRESHOLD,
|
|
290
|
+
passed: ratePostRecovery <= TRUNCATION_RATE_THRESHOLD,
|
|
291
|
+
byCategory: Array.from(byCategoryMap.values()).sort(
|
|
292
|
+
(a, b) => a.category.localeCompare(b.category),
|
|
293
|
+
),
|
|
294
|
+
fixtures: perFixture,
|
|
295
|
+
measuredAt: new Date().toISOString(),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -- Artifact emission --------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Persist the measurement under `<projectRoot>/.ijfw/telemetry/`. Atomic
|
|
303
|
+
* write (tmp-rename) via `writeAtomic`. Returns the absolute path written.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} projectRoot
|
|
306
|
+
* @param {object} result value returned by `measureTruncationRate`
|
|
307
|
+
* @returns {string} absolute path of the artifact
|
|
308
|
+
*/
|
|
309
|
+
export function writeRateArtifact(projectRoot, result) {
|
|
310
|
+
if (typeof projectRoot !== 'string' || !projectRoot) {
|
|
311
|
+
throw new Error('recovery/truncation: writeRateArtifact requires projectRoot');
|
|
312
|
+
}
|
|
313
|
+
const path = join(projectRoot, '.ijfw', 'telemetry', 'truncation-rate.json');
|
|
314
|
+
if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
315
|
+
writeAtomic(path, `${JSON.stringify(result, null, 2)}\n`);
|
|
316
|
+
return path;
|
|
317
|
+
}
|
package/src/redactor.js
CHANGED
|
@@ -16,10 +16,31 @@ const PATTERNS = [
|
|
|
16
16
|
{ re: /ghp_[A-Za-z0-9]{20,}/g, label: 'github' },
|
|
17
17
|
{ re: /github_pat_[A-Za-z0-9_]{20,}/g, label: 'github' },
|
|
18
18
|
{ re: /gh[ousr]_[A-Za-z0-9]{30,}/g, label: 'github' }, // gho_/ghu_/ghs_/ghr_
|
|
19
|
+
// GitLab Personal Access Tokens (glpat-) + CI job tokens (glcbt-).
|
|
20
|
+
// GitLab PAT spec: `glpat-` + 20 base64url chars. We accept 20+ to be future-proof.
|
|
21
|
+
{ re: /glpat-[A-Za-z0-9_-]{20,}/g, label: 'gitlab' },
|
|
22
|
+
{ re: /glcbt-[A-Za-z0-9_-]{20,}/g, label: 'gitlab' }, // CI build token
|
|
23
|
+
{ re: /gldt-[A-Za-z0-9_-]{20,}/g, label: 'gitlab' }, // GitLab deploy token
|
|
19
24
|
// AWS permanent access key ID (AKIA) + temporary (ASIA) key ID.
|
|
20
25
|
{ re: /(?:AKIA|ASIA)[0-9A-Z]{16}/g, label: 'aws' },
|
|
26
|
+
// AWS secret access key — contextualized because bare 40-char base64 is
|
|
27
|
+
// catastrophically false-positive. Match only when paired with a known key
|
|
28
|
+
// name (AWS_SECRET_ACCESS_KEY=, aws_secret=, etc).
|
|
29
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; pattern requires contextual prefix and is anchored to AWS secret key naming conventions.
|
|
30
|
+
{ re: /(?:AWS|aws)[_-]?(?:SECRET|secret)[_-]?(?:ACCESS[_-]?)?(?:KEY|key)\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/g, label: 'aws' },
|
|
31
|
+
// Discord bot tokens — three base64url segments with fixed first-segment width (24).
|
|
32
|
+
// Must come BEFORE the generic JWT pattern so the structural difference is preserved.
|
|
33
|
+
// Format: <24 chars>.<6 chars>.<27+ chars>. Exclude any match whose first
|
|
34
|
+
// segment starts with `eyJ` to avoid stealing JWT matches.
|
|
35
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; this is an anchored secret pattern, not user-controlled matching logic.
|
|
36
|
+
{ re: /(?<![A-Za-z0-9_])(?!eyJ)[A-Za-z0-9_-]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}(?![A-Za-z0-9_])/g, label: 'discord' },
|
|
21
37
|
// Authorization: Bearer <token>.
|
|
22
38
|
{ re: /Bearer\s+[A-Za-z0-9._~+/=-]{10,}/g, label: 'bearer' },
|
|
39
|
+
// Generic JWT (three base64url segments). Catches Supabase service-role keys,
|
|
40
|
+
// Auth0/Okta/Firebase ID tokens, and any bare JWT in tool output. Anchored on
|
|
41
|
+
// `eyJ` header (base64url of `{`) which all JWTs share.
|
|
42
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; the `eyJ` anchor and three-segment shape minimise false positives.
|
|
43
|
+
{ re: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, label: 'jwt' },
|
|
23
44
|
// Slack bot / user / legacy tokens.
|
|
24
45
|
{ re: /xox[baprs]-[A-Za-z0-9-]{10,}/g, label: 'slack' },
|
|
25
46
|
// Stripe live + test secret keys.
|
|
@@ -29,6 +50,32 @@ const PATTERNS = [
|
|
|
29
50
|
{ re: /npm_[A-Za-z0-9]{36}/g, label: 'npm' },
|
|
30
51
|
// HuggingFace user tokens.
|
|
31
52
|
{ re: /hf_[A-Za-z0-9]{34,}/g, label: 'huggingface' },
|
|
53
|
+
// OpenAI organization IDs — `org-` followed by 24 alphanumeric chars.
|
|
54
|
+
// Not a secret per se, but often pasted alongside the API key and a
|
|
55
|
+
// valuable target identifier; redact in case of cross-tenant leakage.
|
|
56
|
+
{ re: /\borg-[A-Za-z0-9]{24}\b/g, label: 'openai-org' },
|
|
57
|
+
// Vercel API tokens — 24 hex/alnum chars after a `vrcl_` style prefix; the
|
|
58
|
+
// newer (2026) Vercel CLI emits tokens prefixed with `vercel_pat_` or
|
|
59
|
+
// contextualised env vars. Cover both the explicit prefix and the env-var
|
|
60
|
+
// contextual form.
|
|
61
|
+
{ re: /vercel_pat_[A-Za-z0-9_]{20,}/gi, label: 'vercel' },
|
|
62
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output and requires a Vercel context prefix before matching a token.
|
|
63
|
+
{ re: /(?:VERCEL|vercel)[_-]?(?:API[_-]?)?TOKEN\s*[:=]\s*['"]?[A-Za-z0-9_-]{24,}['"]?/g, label: 'vercel' },
|
|
64
|
+
// Supabase service-role / anon keys. The JWT pattern above already catches
|
|
65
|
+
// the typical service-role JWT shape; this rule covers the legacy
|
|
66
|
+
// `sbp_` (project access) and `sb_` style tokens distributed via the CLI.
|
|
67
|
+
{ re: /sbp_[A-Za-z0-9]{40,}/g, label: 'supabase' },
|
|
68
|
+
// Notion integration tokens — `secret_` + 43 chars (legacy) and `ntn_` + alnum (2026).
|
|
69
|
+
{ re: /secret_[A-Za-z0-9]{43}/g, label: 'notion' },
|
|
70
|
+
{ re: /ntn_[A-Za-z0-9_]{40,}/g, label: 'notion' },
|
|
71
|
+
// Linear API keys — `lin_api_` + alnum, `lin_oauth_` + alnum.
|
|
72
|
+
{ re: /lin_api_[A-Za-z0-9]{32,}/g, label: 'linear' },
|
|
73
|
+
{ re: /lin_oauth_[A-Za-z0-9]{32,}/g, label: 'linear' },
|
|
74
|
+
// Twilio Account SID + Auth Token. SID is `AC` + 32 hex; auth token is
|
|
75
|
+
// 32 hex which is high-FP so we require contextual naming.
|
|
76
|
+
{ re: /AC[a-f0-9]{32}/g, label: 'twilio' },
|
|
77
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- redactor scans bounded tool output; pattern requires a Twilio context prefix before matching.
|
|
78
|
+
{ re: /(?:TWILIO|twilio)[_-]?(?:AUTH[_-]?)?TOKEN\s*[:=]\s*['"]?[a-f0-9]{32}['"]?/g, label: 'twilio' },
|
|
32
79
|
// Azure Storage connection-string AccountKey (base64, 88 chars with padding).
|
|
33
80
|
{ re: /AccountKey=[A-Za-z0-9+/]{86,88}={0,2}/g, label: 'azure' },
|
|
34
81
|
// GCP service-account private key PEM block.
|
|
@@ -47,6 +94,18 @@ const PATTERNS = [
|
|
|
47
94
|
{ re: /https:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/g, label: 'webhook' },
|
|
48
95
|
{ re: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/g, label: 'webhook' },
|
|
49
96
|
{ re: /https:\/\/[\w-]+\.webhook\.office\.com\/webhookb2\/[\w@/-]+/g, label: 'webhook' },
|
|
97
|
+
// v1.5.0 audit-LOW-tok-L1: PII patterns.
|
|
98
|
+
// Email -- conservative RFC-ish match. Bounds local-part to 1-64 chars,
|
|
99
|
+
// domain to a label.label pattern with TLD ≥2 chars. Avoids matching
|
|
100
|
+
// bare `@handle` references in prose.
|
|
101
|
+
{ re: /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}(?![A-Za-z0-9])/g, label: 'email' },
|
|
102
|
+
// Phone -- E.164 international form (+CCNNNNNNNNNN, 10-15 digits total)
|
|
103
|
+
// plus common US/EU formats with separators. Anchored to word boundaries
|
|
104
|
+
// and a digit-density threshold to avoid eating commit SHAs / version
|
|
105
|
+
// strings. Conservative: requires either a leading `+` or one of
|
|
106
|
+
// `(NNN)`, `NNN-NNN-NNNN`, `NNN.NNN.NNNN` shapes.
|
|
107
|
+
{ re: /(?<![\d+])\+[1-9]\d{1,3}[\s.-]?\d{2,4}[\s.-]?\d{2,4}[\s.-]?\d{2,4}(?![\d])/g, label: 'phone' },
|
|
108
|
+
{ re: /(?<![\d-])(?:\(\d{3}\)\s?|\d{3}[.-])\d{3}[.-]\d{4}(?![\d-])/g, label: 'phone' },
|
|
50
109
|
];
|
|
51
110
|
|
|
52
111
|
// INLINE rules match `key=value` style assignments. Value regex excludes
|
|
@@ -78,7 +137,7 @@ export function redactSecrets(s) {
|
|
|
78
137
|
return out;
|
|
79
138
|
}
|
|
80
139
|
|
|
81
|
-
//
|
|
140
|
+
// classifyAnchored(value) -> { clean: boolean, redacted_kind: string | null }
|
|
82
141
|
//
|
|
83
142
|
// D-PILLAR-SPEC section 3 surface used by D2 entity extraction. Passes the
|
|
84
143
|
// value through the same PATTERNS list redactSecrets uses; if any pattern
|
|
@@ -90,20 +149,30 @@ export function redactSecrets(s) {
|
|
|
90
149
|
//
|
|
91
150
|
// Important: PATTERNS are anchored implicitly via length minimums (e.g.
|
|
92
151
|
// `sk-(?:proj-)?[A-Za-z0-9_-]{32,}`), but to avoid classifying a long file
|
|
93
|
-
// path that happens to contain a token-shaped substring,
|
|
94
|
-
// only when the pattern matches the FULL trimmed value. File paths
|
|
95
|
-
// function/identifier names are always shorter than the secret patterns'
|
|
152
|
+
// path that happens to contain a token-shaped substring, classifyAnchored()
|
|
153
|
+
// rejects only when the pattern matches the FULL trimmed value. File paths
|
|
154
|
+
// and function/identifier names are always shorter than the secret patterns'
|
|
96
155
|
// minimum lengths, so the conservative cut-line is "match must equal the
|
|
97
156
|
// candidate" -- a substring match doesn't trigger classification.
|
|
98
|
-
|
|
157
|
+
//
|
|
158
|
+
// Naming (v1.5.0 audit LOW #13): renamed conceptually from `classify` to
|
|
159
|
+
// `classifyAnchored` to signal the asymmetric contract -- callers must pass
|
|
160
|
+
// the candidate as a discrete value, NOT a free-form text body that contains
|
|
161
|
+
// the value somewhere inside. `classify` is retained as a back-compat alias.
|
|
162
|
+
export function classifyAnchored(value) {
|
|
99
163
|
if (typeof value !== 'string') return { clean: true, redacted_kind: null };
|
|
100
164
|
const v = value.trim();
|
|
101
165
|
if (!v) return { clean: true, redacted_kind: null };
|
|
102
166
|
for (const { re, label } of PATTERNS) {
|
|
103
167
|
// Build a fresh non-global RegExp per check; the source PATTERNS use /g
|
|
104
|
-
// for redactSecrets but
|
|
168
|
+
// for redactSecrets but classifyAnchored needs a single full-value match.
|
|
105
169
|
const r = new RegExp(`^(?:${re.source})$`, re.flags.replace('g', ''));
|
|
106
170
|
if (r.test(v)) return { clean: false, redacted_kind: label };
|
|
107
171
|
}
|
|
108
172
|
return { clean: true, redacted_kind: null };
|
|
109
173
|
}
|
|
174
|
+
|
|
175
|
+
// Back-compat alias. New callers should prefer `classifyAnchored` so the
|
|
176
|
+
// "value must be the whole candidate, not embedded in prose" contract is
|
|
177
|
+
// obvious at the call site.
|
|
178
|
+
export const classify = classifyAnchored;
|
package/src/runtime-mediator.js
CHANGED
|
@@ -15,6 +15,21 @@
|
|
|
15
15
|
* Fail-closed invariant: if the file exists but is unparseable / malformed,
|
|
16
16
|
* the caller MUST treat it as a deny. A corrupted state file is not a free
|
|
17
17
|
* pass -- that would defeat the sandbox.
|
|
18
|
+
*
|
|
19
|
+
* ## Cross-platform enforcement boundary
|
|
20
|
+
* This module is the single tier-2 enforcement point for platforms without
|
|
21
|
+
* a native pre-tool hook lifecycle: Gemini CLI, Cursor, Windsurf, and
|
|
22
|
+
* Copilot (VS Code). All MCP tool calls from those platforms pass through
|
|
23
|
+
* `checkPermission()` at `server.js:98` BEFORE any handler executes.
|
|
24
|
+
* Hook-lifecycle platforms (Claude Code, Codex, Hermes, Wayland) get
|
|
25
|
+
* parallel enforcement via their own pre-tool-use hook scripts in addition
|
|
26
|
+
* to this MCP boundary.
|
|
27
|
+
*
|
|
28
|
+
* Coverage:
|
|
29
|
+
* - `test-runtime-mediator.js` unit-level primitives
|
|
30
|
+
* - `test-mcp-gate-integration.js` integration through the exported
|
|
31
|
+
* `gatePermissionAndQuota` (server.js:98)
|
|
32
|
+
* — locks in the four no-hook platforms.
|
|
18
33
|
*/
|
|
19
34
|
|
|
20
35
|
import { readFile, mkdir, appendFile, rename, stat } from 'node:fs/promises';
|
package/src/sanitizer.js
CHANGED
|
@@ -20,6 +20,16 @@ export function sanitizeContent(s) {
|
|
|
20
20
|
// U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF
|
|
21
21
|
out = out.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '');
|
|
22
22
|
|
|
23
|
+
// 2b. Strip Unicode tag-block chars (U+E0000-U+E007F) \u2014 the "ASCII Smuggler"
|
|
24
|
+
// prompt-injection vector. These codepoints are invisible to humans but map
|
|
25
|
+
// 1:1 to printable ASCII (U+E0041 = "A", U+E0061 = "a", etc.) and many LLMs
|
|
26
|
+
// interpret them as the corresponding text. An attacker can hide an
|
|
27
|
+
// instruction like "ignore all prior" inside otherwise-benign memory content.
|
|
28
|
+
// v1.5.1 H1.4 (audit memory-engine.md F-SEC-3).
|
|
29
|
+
// Ref: https://embracethered.com/blog/posts/2024/hiding-and-finding-text-with-unicode-tags/
|
|
30
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- single-char Unicode range class; no quantifier; not backtrack-exploitable
|
|
31
|
+
out = out.replace(/[\u{E0000}-\u{E007F}]/gu, '');
|
|
32
|
+
|
|
23
33
|
// 3. Defang ANY heading prefix (1+ hashes, optional whitespace) -- entry must
|
|
24
34
|
// never produce a structural ## section that mimics a journal timestamp.
|
|
25
35
|
out = out.replace(/^[ \t]*#+[ \t]+/gm, '> ');
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// r17 (cold-tier wire-up): hybrid BM25+vector rerank step for searchMemory.
|
|
2
|
+
//
|
|
3
|
+
// Pure module, no side effects on import. server.js imports the rerank
|
|
4
|
+
// helper; test-search-hybrid.js imports the same helper without dragging in
|
|
5
|
+
// the MCP server's stdio bootstrap (which would hang the test runner).
|
|
6
|
+
//
|
|
7
|
+
// Behavior:
|
|
8
|
+
// - When IJFW_VECTORS is off (default) OR no opts.embedder is injected,
|
|
9
|
+
// this is a pure no-op and returns the input `ranked` array unchanged.
|
|
10
|
+
// - When IJFW_VECTORS=on AND the embedder is available (either via
|
|
11
|
+
// @xenova/transformers installed OR via opts.embedder injection), the
|
|
12
|
+
// BM25 top-K is reranked using cosine similarity over each result's
|
|
13
|
+
// snippet, blended with the BM25 score via vectors.hybridRerank.
|
|
14
|
+
// - Any failure during embedding falls back to BM25 with a single
|
|
15
|
+
// stderr warning per distinct reason. Never throws into the caller.
|
|
16
|
+
|
|
17
|
+
import { vectorsEnabled, getEmbedder, hybridRerank } from './vectors.js';
|
|
18
|
+
// v1.5.0 wire-W1.C: persistent embedding cache so repeated queries over a
|
|
19
|
+
// stable corpus don't pay the per-snippet embed cost twice. Cache is keyed
|
|
20
|
+
// on sha256(snippet) + model_id; falls back to live re-embed on any miss
|
|
21
|
+
// or when opts.db is absent.
|
|
22
|
+
import {
|
|
23
|
+
cacheKeyFor,
|
|
24
|
+
getCachedEmbedding,
|
|
25
|
+
setCachedEmbedding,
|
|
26
|
+
hasVectorCache,
|
|
27
|
+
} from './memory/embedding-cache.js';
|
|
28
|
+
|
|
29
|
+
// Throttle stderr noise — single warning per distinct failure reason.
|
|
30
|
+
let _vectorWarnedReason = null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Optional hybrid rerank step. Returns the input `ranked` unchanged when
|
|
34
|
+
* vectors are disabled or the embedder cannot be loaded. On success, returns
|
|
35
|
+
* the merged ranking from `hybridRerank` (BM25 score + cosine similarity).
|
|
36
|
+
*
|
|
37
|
+
* @param {string} query
|
|
38
|
+
* @param {Array<{id, score, snippet, meta}>} ranked - BM25 top-K
|
|
39
|
+
* @param {object} opts
|
|
40
|
+
* @param {object} [opts.embedder] - injected for tests; defaults to getEmbedder()
|
|
41
|
+
* @param {number} [opts.wBm25] - BM25 weight (default 0.6 via vectors.js)
|
|
42
|
+
* @param {number} [opts.wVec] - vector weight (default 0.4 via vectors.js)
|
|
43
|
+
* @returns {Promise<Array>} reranked list (or `ranked` on no-op)
|
|
44
|
+
*/
|
|
45
|
+
export async function maybeRerankWithVectors(query, ranked, opts = {}) {
|
|
46
|
+
// Skip the embedder load entirely when vectors are off — env check is the
|
|
47
|
+
// cheap path. Tests can force the embedder path by passing opts.embedder.
|
|
48
|
+
if (!opts.embedder && !vectorsEnabled()) return ranked;
|
|
49
|
+
|
|
50
|
+
let embedder = opts.embedder;
|
|
51
|
+
if (!embedder) {
|
|
52
|
+
try {
|
|
53
|
+
embedder = await getEmbedder();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// getEmbedder shouldn't throw (it returns {available:false}), but defend.
|
|
56
|
+
embedder = { available: false, reason: `getEmbedder-threw: ${err.message}` };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!embedder || !embedder.available) {
|
|
60
|
+
const reason = embedder?.reason || 'unavailable';
|
|
61
|
+
if (_vectorWarnedReason !== reason) {
|
|
62
|
+
_vectorWarnedReason = reason;
|
|
63
|
+
// Stderr only; stdout is the JSON-RPC framing channel.
|
|
64
|
+
process.stderr.write(
|
|
65
|
+
`IJFW: cold-tier vectors requested (IJFW_VECTORS=on) but embedder unavailable (${reason}). Falling back to BM25.\n`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return ranked;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// v1.5.0 audit MED #6 (memory-engine.md F-SPD-1): batch-embed the
|
|
73
|
+
// query + all snippets in parallel via Promise.all. The previous
|
|
74
|
+
// sequential `for (... await embedder.embed)` loop serialised K+1
|
|
75
|
+
// calls -- when `embedder.embed` is an HTTP round-trip (e.g. a
|
|
76
|
+
// remote inference server) p99 was ~600ms for the top-K=10 case.
|
|
77
|
+
// Concurrent dispatch drops that to ~80ms (single-round-trip cost
|
|
78
|
+
// plus per-call overhead). For the local @xenova/transformers
|
|
79
|
+
// pipeline the calls still resolve serially under the hood, but
|
|
80
|
+
// Promise.all is no worse than the sequential await and lets future
|
|
81
|
+
// batch-aware embedders win without further changes.
|
|
82
|
+
//
|
|
83
|
+
// v1.5.0 wire-W1.C: when opts.db + opts.modelId are supplied AND the
|
|
84
|
+
// memory_entry_vectors table exists, route each embed through the
|
|
85
|
+
// persistent cache. The cache is keyed on sha256(text), so a second
|
|
86
|
+
// call with the same query + corpus serves entirely from SQLite —
|
|
87
|
+
// zero embedder calls, zero re-embed cost. The query embedding is
|
|
88
|
+
// also cached (queries repeat in long sessions / dashboard polls).
|
|
89
|
+
//
|
|
90
|
+
// cacheReady flips to false when the table is missing OR no db is
|
|
91
|
+
// passed; the rerank then degrades to the existing live-embed path
|
|
92
|
+
// with no observable behavior change.
|
|
93
|
+
const snippets = ranked.map((r) => r.snippet || '');
|
|
94
|
+
const cacheDb = opts.db || null;
|
|
95
|
+
const modelId = opts.modelId || embedder.modelId || null;
|
|
96
|
+
const cacheReady = cacheDb && typeof modelId === 'string' && modelId.length > 0 && hasVectorCache(cacheDb);
|
|
97
|
+
|
|
98
|
+
const embedWithCache = async (text) => {
|
|
99
|
+
if (!cacheReady) return embedder.embed(text);
|
|
100
|
+
const key = cacheKeyFor(text);
|
|
101
|
+
if (key === null) return embedder.embed(text);
|
|
102
|
+
const hit = getCachedEmbedding(cacheDb, key, modelId);
|
|
103
|
+
if (hit) return hit;
|
|
104
|
+
const vec = await embedder.embed(text);
|
|
105
|
+
setCachedEmbedding(cacheDb, key, modelId, vec);
|
|
106
|
+
return vec;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const [queryVec, ...docVecs] = await Promise.all([
|
|
110
|
+
embedWithCache(query),
|
|
111
|
+
...snippets.map((s) => embedWithCache(s)),
|
|
112
|
+
]);
|
|
113
|
+
const vectorScores = new Map();
|
|
114
|
+
for (let i = 0; i < ranked.length; i++) {
|
|
115
|
+
const docVec = docVecs[i] || [];
|
|
116
|
+
// Cosine over L2-normalized vectors === dot product.
|
|
117
|
+
let dot = 0;
|
|
118
|
+
const n = Math.min(queryVec.length, docVec.length);
|
|
119
|
+
for (let j = 0; j < n; j++) dot += queryVec[j] * docVec[j];
|
|
120
|
+
vectorScores.set(ranked[i].id, dot);
|
|
121
|
+
}
|
|
122
|
+
return hybridRerank(ranked, vectorScores, {
|
|
123
|
+
wBm25: opts.wBm25,
|
|
124
|
+
wVec: opts.wVec,
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const reason = `embed-failed: ${err.message}`;
|
|
128
|
+
if (_vectorWarnedReason !== reason) {
|
|
129
|
+
_vectorWarnedReason = reason;
|
|
130
|
+
process.stderr.write(
|
|
131
|
+
`IJFW: cold-tier vectors hit an error mid-pipeline (${reason}). Falling back to BM25 for this query.\n`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return ranked;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Test seam — reset the once-per-process warning gate.
|
|
139
|
+
export function _resetVectorWarnGate() { _vectorWarnedReason = null; }
|