agent-recall-core 3.4.30 → 3.4.35
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/README.md +121 -441
- package/dist/digest/store.d.ts.map +1 -1
- package/dist/digest/store.js +4 -3
- package/dist/digest/store.js.map +1 -1
- package/dist/display/board-render.d.ts +61 -0
- package/dist/display/board-render.d.ts.map +1 -0
- package/dist/display/board-render.js +223 -0
- package/dist/display/board-render.js.map +1 -0
- package/dist/helpers/alignment-patterns.d.ts +10 -0
- package/dist/helpers/alignment-patterns.d.ts.map +1 -1
- package/dist/helpers/alignment-patterns.js +5 -1
- package/dist/helpers/alignment-patterns.js.map +1 -1
- package/dist/helpers/blind-spots.d.ts +86 -0
- package/dist/helpers/blind-spots.d.ts.map +1 -0
- package/dist/helpers/blind-spots.js +205 -0
- package/dist/helpers/blind-spots.js.map +1 -0
- package/dist/helpers/conflict-scan.d.ts +10 -0
- package/dist/helpers/conflict-scan.d.ts.map +1 -1
- package/dist/helpers/conflict-scan.js +3 -3
- package/dist/helpers/conflict-scan.js.map +1 -1
- package/dist/helpers/semantic-match.d.ts +82 -0
- package/dist/helpers/semantic-match.d.ts.map +1 -0
- package/dist/helpers/semantic-match.js +229 -0
- package/dist/helpers/semantic-match.js.map +1 -0
- package/dist/index.d.ts +52 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +48 -9
- package/dist/index.js.map +1 -1
- package/dist/palace/awareness.d.ts +30 -0
- package/dist/palace/awareness.d.ts.map +1 -1
- package/dist/palace/awareness.js +65 -2
- package/dist/palace/awareness.js.map +1 -1
- package/dist/palace/consolidate.d.ts.map +1 -1
- package/dist/palace/consolidate.js +17 -0
- package/dist/palace/consolidate.js.map +1 -1
- package/dist/palace/decay-pass.d.ts +46 -0
- package/dist/palace/decay-pass.d.ts.map +1 -0
- package/dist/palace/decay-pass.js +87 -0
- package/dist/palace/decay-pass.js.map +1 -0
- package/dist/palace/insights-index.d.ts.map +1 -1
- package/dist/palace/insights-index.js +4 -2
- package/dist/palace/insights-index.js.map +1 -1
- package/dist/palace/skills.d.ts +41 -2
- package/dist/palace/skills.d.ts.map +1 -1
- package/dist/palace/skills.js +106 -4
- package/dist/palace/skills.js.map +1 -1
- package/dist/prompts/consolidation-prompt.d.ts +25 -0
- package/dist/prompts/consolidation-prompt.d.ts.map +1 -0
- package/dist/prompts/consolidation-prompt.js +91 -0
- package/dist/prompts/consolidation-prompt.js.map +1 -0
- package/dist/storage/archive-prune.d.ts +56 -0
- package/dist/storage/archive-prune.d.ts.map +1 -0
- package/dist/storage/archive-prune.js +118 -0
- package/dist/storage/archive-prune.js.map +1 -0
- package/dist/storage/archive-write.d.ts +38 -0
- package/dist/storage/archive-write.d.ts.map +1 -0
- package/dist/storage/archive-write.js +98 -0
- package/dist/storage/archive-write.js.map +1 -0
- package/dist/storage/blind-spots-store.d.ts +30 -0
- package/dist/storage/blind-spots-store.d.ts.map +1 -0
- package/dist/storage/blind-spots-store.js +91 -0
- package/dist/storage/blind-spots-store.js.map +1 -0
- package/dist/storage/capture-router.d.ts +60 -0
- package/dist/storage/capture-router.d.ts.map +1 -0
- package/dist/storage/capture-router.js +133 -0
- package/dist/storage/capture-router.js.map +1 -0
- package/dist/storage/classification.d.ts +32 -0
- package/dist/storage/classification.d.ts.map +1 -0
- package/dist/storage/classification.js +74 -0
- package/dist/storage/classification.js.map +1 -0
- package/dist/storage/consolidation-queue.d.ts +39 -0
- package/dist/storage/consolidation-queue.d.ts.map +1 -0
- package/dist/storage/consolidation-queue.js +120 -0
- package/dist/storage/consolidation-queue.js.map +1 -0
- package/dist/storage/content-guard.d.ts +74 -0
- package/dist/storage/content-guard.d.ts.map +1 -0
- package/dist/storage/content-guard.js +179 -0
- package/dist/storage/content-guard.js.map +1 -0
- package/dist/storage/corrections.d.ts +246 -21
- package/dist/storage/corrections.d.ts.map +1 -1
- package/dist/storage/corrections.js +605 -56
- package/dist/storage/corrections.js.map +1 -1
- package/dist/storage/durable-intent.d.ts +49 -0
- package/dist/storage/durable-intent.d.ts.map +1 -0
- package/dist/storage/durable-intent.js +124 -0
- package/dist/storage/durable-intent.js.map +1 -0
- package/dist/storage/filelock.d.ts +6 -0
- package/dist/storage/filelock.d.ts.map +1 -1
- package/dist/storage/filelock.js +6 -1
- package/dist/storage/filelock.js.map +1 -1
- package/dist/storage/memory-protocol.d.ts +19 -0
- package/dist/storage/memory-protocol.d.ts.map +1 -0
- package/dist/storage/memory-protocol.js +101 -0
- package/dist/storage/memory-protocol.js.map +1 -0
- package/dist/storage/paths.d.ts +23 -0
- package/dist/storage/paths.d.ts.map +1 -1
- package/dist/storage/paths.js +39 -0
- package/dist/storage/paths.js.map +1 -1
- package/dist/storage/retention.d.ts +23 -0
- package/dist/storage/retention.d.ts.map +1 -0
- package/dist/storage/retention.js +48 -0
- package/dist/storage/retention.js.map +1 -0
- package/dist/storage/session.d.ts +2 -2
- package/dist/storage/session.d.ts.map +1 -1
- package/dist/storage/session.js +11 -1
- package/dist/storage/session.js.map +1 -1
- package/dist/supabase/config.d.ts +8 -0
- package/dist/supabase/config.d.ts.map +1 -1
- package/dist/supabase/config.js +5 -0
- package/dist/supabase/config.js.map +1 -1
- package/dist/supabase/recall-backend.d.ts +1 -0
- package/dist/supabase/recall-backend.d.ts.map +1 -1
- package/dist/supabase/recall-backend.js +13 -13
- package/dist/supabase/recall-backend.js.map +1 -1
- package/dist/supabase/sync.d.ts.map +1 -1
- package/dist/supabase/sync.js +30 -3
- package/dist/supabase/sync.js.map +1 -1
- package/dist/tools-logic/bootstrap.d.ts +15 -0
- package/dist/tools-logic/bootstrap.d.ts.map +1 -1
- package/dist/tools-logic/bootstrap.js +238 -47
- package/dist/tools-logic/bootstrap.js.map +1 -1
- package/dist/tools-logic/brief.d.ts +84 -0
- package/dist/tools-logic/brief.d.ts.map +1 -0
- package/dist/tools-logic/brief.js +193 -0
- package/dist/tools-logic/brief.js.map +1 -0
- package/dist/tools-logic/check-action.d.ts +9 -0
- package/dist/tools-logic/check-action.d.ts.map +1 -1
- package/dist/tools-logic/check-action.js +31 -2
- package/dist/tools-logic/check-action.js.map +1 -1
- package/dist/tools-logic/check.d.ts +7 -0
- package/dist/tools-logic/check.d.ts.map +1 -1
- package/dist/tools-logic/check.js +24 -2
- package/dist/tools-logic/check.js.map +1 -1
- package/dist/tools-logic/confidence.d.ts +50 -0
- package/dist/tools-logic/confidence.d.ts.map +1 -0
- package/dist/tools-logic/confidence.js +70 -0
- package/dist/tools-logic/confidence.js.map +1 -0
- package/dist/tools-logic/drill-down.d.ts +37 -0
- package/dist/tools-logic/drill-down.d.ts.map +1 -0
- package/dist/tools-logic/drill-down.js +63 -0
- package/dist/tools-logic/drill-down.js.map +1 -0
- package/dist/tools-logic/export-corrections.d.ts +63 -0
- package/dist/tools-logic/export-corrections.d.ts.map +1 -0
- package/dist/tools-logic/export-corrections.js +124 -0
- package/dist/tools-logic/export-corrections.js.map +1 -0
- package/dist/tools-logic/journal-capture.d.ts.map +1 -1
- package/dist/tools-logic/journal-capture.js +4 -1
- package/dist/tools-logic/journal-capture.js.map +1 -1
- package/dist/tools-logic/journal-write.d.ts.map +1 -1
- package/dist/tools-logic/journal-write.js +3 -2
- package/dist/tools-logic/journal-write.js.map +1 -1
- package/dist/tools-logic/memory-query.d.ts +10 -3
- package/dist/tools-logic/memory-query.d.ts.map +1 -1
- package/dist/tools-logic/memory-query.js +31 -7
- package/dist/tools-logic/memory-query.js.map +1 -1
- package/dist/tools-logic/mirror-builder.d.ts +115 -0
- package/dist/tools-logic/mirror-builder.d.ts.map +1 -0
- package/dist/tools-logic/mirror-builder.js +348 -0
- package/dist/tools-logic/mirror-builder.js.map +1 -0
- package/dist/tools-logic/palace-write.d.ts.map +1 -1
- package/dist/tools-logic/palace-write.js +3 -2
- package/dist/tools-logic/palace-write.js.map +1 -1
- package/dist/tools-logic/pipeline-close.d.ts.map +1 -1
- package/dist/tools-logic/pipeline-close.js +2 -1
- package/dist/tools-logic/pipeline-close.js.map +1 -1
- package/dist/tools-logic/pipeline-open.d.ts.map +1 -1
- package/dist/tools-logic/pipeline-open.js +2 -1
- package/dist/tools-logic/pipeline-open.js.map +1 -1
- package/dist/tools-logic/predict-correction.d.ts +47 -0
- package/dist/tools-logic/predict-correction.d.ts.map +1 -0
- package/dist/tools-logic/predict-correction.js +137 -0
- package/dist/tools-logic/predict-correction.js.map +1 -0
- package/dist/tools-logic/prior-builder.d.ts +27 -0
- package/dist/tools-logic/prior-builder.d.ts.map +1 -0
- package/dist/tools-logic/prior-builder.js +57 -0
- package/dist/tools-logic/prior-builder.js.map +1 -0
- package/dist/tools-logic/recognition-builder.d.ts +116 -0
- package/dist/tools-logic/recognition-builder.d.ts.map +1 -0
- package/dist/tools-logic/recognition-builder.js +289 -0
- package/dist/tools-logic/recognition-builder.js.map +1 -0
- package/dist/tools-logic/safety-consolidation.d.ts +104 -0
- package/dist/tools-logic/safety-consolidation.d.ts.map +1 -0
- package/dist/tools-logic/safety-consolidation.js +256 -0
- package/dist/tools-logic/safety-consolidation.js.map +1 -0
- package/dist/tools-logic/session-end-reflect.d.ts +18 -0
- package/dist/tools-logic/session-end-reflect.d.ts.map +1 -1
- package/dist/tools-logic/session-end-reflect.js +68 -29
- package/dist/tools-logic/session-end-reflect.js.map +1 -1
- package/dist/tools-logic/session-end.d.ts +9 -0
- package/dist/tools-logic/session-end.d.ts.map +1 -1
- package/dist/tools-logic/session-end.js +111 -13
- package/dist/tools-logic/session-end.js.map +1 -1
- package/dist/tools-logic/session-start-lite.d.ts +2 -0
- package/dist/tools-logic/session-start-lite.d.ts.map +1 -1
- package/dist/tools-logic/session-start-lite.js +10 -0
- package/dist/tools-logic/session-start-lite.js.map +1 -1
- package/dist/tools-logic/session-start.d.ts +46 -0
- package/dist/tools-logic/session-start.d.ts.map +1 -1
- package/dist/tools-logic/session-start.js +96 -3
- package/dist/tools-logic/session-start.js.map +1 -1
- package/dist/tools-logic/skill-propose.d.ts +27 -0
- package/dist/tools-logic/skill-propose.d.ts.map +1 -0
- package/dist/tools-logic/skill-propose.js +66 -0
- package/dist/tools-logic/skill-propose.js.map +1 -0
- package/dist/tools-logic/skill-recall.d.ts +5 -0
- package/dist/tools-logic/skill-recall.d.ts.map +1 -1
- package/dist/tools-logic/skill-recall.js +14 -1
- package/dist/tools-logic/skill-recall.js.map +1 -1
- package/dist/tools-logic/smart-recall.d.ts +17 -0
- package/dist/tools-logic/smart-recall.d.ts.map +1 -1
- package/dist/tools-logic/smart-recall.js +51 -16
- package/dist/tools-logic/smart-recall.js.map +1 -1
- package/dist/tools-logic/store-doctor.d.ts +71 -0
- package/dist/tools-logic/store-doctor.d.ts.map +1 -0
- package/dist/tools-logic/store-doctor.js +387 -0
- package/dist/tools-logic/store-doctor.js.map +1 -0
- package/dist/tools-logic/store-repair.d.ts +64 -0
- package/dist/tools-logic/store-repair.d.ts.map +1 -0
- package/dist/tools-logic/store-repair.js +243 -0
- package/dist/tools-logic/store-repair.js.map +1 -0
- package/dist/tools-logic/supersession.d.ts +34 -0
- package/dist/tools-logic/supersession.d.ts.map +1 -0
- package/dist/tools-logic/supersession.js +103 -0
- package/dist/tools-logic/supersession.js.map +1 -0
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/vector/local-vector-backend.d.ts.map +1 -1
- package/dist/vector/local-vector-backend.js +14 -17
- package/dist/vector/local-vector-backend.js.map +1 -1
- package/package.json +9 -1
- package/dist/relevance/injector.d.ts +0 -51
- package/dist/relevance/injector.d.ts.map +0 -1
- package/dist/relevance/injector.js +0 -121
- package/dist/relevance/injector.js.map +0 -1
- package/dist/relevance/precision.d.ts +0 -109
- package/dist/relevance/precision.d.ts.map +0 -1
- package/dist/relevance/precision.js +0 -279
- package/dist/relevance/precision.js.map +0 -1
- package/dist/relevance/promoter.d.ts +0 -60
- package/dist/relevance/promoter.d.ts.map +0 -1
- package/dist/relevance/promoter.js +0 -336
- package/dist/relevance/promoter.js.map +0 -1
- package/dist/relevance/stager.d.ts +0 -61
- package/dist/relevance/stager.d.ts.map +0 -1
- package/dist/relevance/stager.js +0 -251
- package/dist/relevance/stager.js.map +0 -1
- package/dist/relevance/surfacer.d.ts +0 -34
- package/dist/relevance/surfacer.d.ts.map +0 -1
- package/dist/relevance/surfacer.js +0 -333
- package/dist/relevance/surfacer.js.map +0 -1
|
@@ -28,6 +28,33 @@ function correctionsDir(project) {
|
|
|
28
28
|
function outcomesPath(project) {
|
|
29
29
|
return path.join(correctionsDir(project), "_outcomes.jsonl");
|
|
30
30
|
}
|
|
31
|
+
function rejectedPath(project) {
|
|
32
|
+
return path.join(correctionsDir(project), "_rejected.jsonl");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Gate version stamp — bump whenever isLikelyRealCorrection's accept criteria
|
|
36
|
+
* change so a rejected-log analysis can attribute discard rates to a specific
|
|
37
|
+
* gate revision. Kept in lock-step with the classifier below.
|
|
38
|
+
*
|
|
39
|
+
* v3 (2026-06-21, Loop 8): the gate now scans the FULL correction text (and its
|
|
40
|
+
* decimal-safe sentence fragments) for an actionable marker instead of only the
|
|
41
|
+
* truncated first sentence. Loop 7 proved the first-sentence-slice discarded
|
|
42
|
+
* genuine soft corrections whose directive lived in sentence 2. The NOISE
|
|
43
|
+
* filters (system-fragment / too-short / pure-acknowledgment / doc-header) are
|
|
44
|
+
* unchanged and still run FIRST so the precision floor holds.
|
|
45
|
+
*
|
|
46
|
+
* v4 (2026-06-22, Loop 14): split directive markers into STRONG (accept anywhere)
|
|
47
|
+
* vs WEAK (accept only outside a hedged/reporting frame), closing the round-table's
|
|
48
|
+
* MEDIUM false-accept where tentative filler ("I think we should use it") passed on
|
|
49
|
+
* a bare weak verb. Recall-safe — no fixture correction relies on a hedged weak verb.
|
|
50
|
+
*/
|
|
51
|
+
export const GATE_VERSION = "v4-2026-06-22";
|
|
52
|
+
/**
|
|
53
|
+
* Cap for _rejected.jsonl — keep the most-recent N rows so a survivorship-bias
|
|
54
|
+
* probe can never grow the file unbounded on the hot capture path. Rotation is
|
|
55
|
+
* best-effort: a failure to rotate must never throw into writeCorrection.
|
|
56
|
+
*/
|
|
57
|
+
const REJECTED_LOG_CAP = 2000;
|
|
31
58
|
/** Auto-detect severity: p0 if uses strong negation/mandate language, else p1. */
|
|
32
59
|
function detectSeverity(text) {
|
|
33
60
|
const p0Patterns = /\bnever\b|\balways\b|\bdon'?t\b|\bdo not\b|\bmust not\b|\bforbid\b|\bprohibit\b/i;
|
|
@@ -47,59 +74,212 @@ function defaultWeight(severity) {
|
|
|
47
74
|
function todayDate() {
|
|
48
75
|
return new Date().toISOString().slice(0, 10);
|
|
49
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Atomic JSON write — tmp + rename, mode 0600. Prevents truncation on SIGTERM.
|
|
79
|
+
* Extracted from the three identical inlined copies (writeCorrection /
|
|
80
|
+
* retractCorrection / recordOutcome) so every correction writer shares one
|
|
81
|
+
* durable path. Pure side-effect helper; no behavior change vs the originals.
|
|
82
|
+
*/
|
|
83
|
+
function writeRecordAtomic(filepath, record) {
|
|
84
|
+
const tmp = `${filepath}.tmp.${process.pid}.${Date.now()}`;
|
|
85
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
86
|
+
fs.renameSync(tmp, filepath);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Beta posterior mean E[Beta(α,β)] with α=heeded+1, β=recurrence+1 (Laplace).
|
|
90
|
+
* Mirrors the canonical `betaUtility` in tools-logic/smart-recall.ts — kept INLINE
|
|
91
|
+
* so the low-level storage layer never imports the recall stack. Returns (0,1):
|
|
92
|
+
* neutral (no evidence) = 0.5; more heeded → higher; more recurrence → lower.
|
|
93
|
+
*/
|
|
94
|
+
function betaPosterior(heeded, recurrence) {
|
|
95
|
+
return (heeded + 1) / (heeded + recurrence + 2);
|
|
96
|
+
}
|
|
97
|
+
/** Days after which an untouched correction is considered stale (P4). */
|
|
98
|
+
const STALE_DAYS = 30;
|
|
99
|
+
/**
|
|
100
|
+
* P4: a correction is stale when its most recent touch (last_retrieved, else
|
|
101
|
+
* last_outcome, else its date) is older than STALE_DAYS. Pure — `nowMs` is
|
|
102
|
+
* injectable for tests. INFORMATIONAL ONLY: the corrections room is decay-
|
|
103
|
+
* protected, so this never archives on its own; it surfaces a review candidate.
|
|
104
|
+
*/
|
|
105
|
+
export function isStaleCorrection(rec, nowMs = Date.now()) {
|
|
106
|
+
const touch = rec.last_retrieved ?? rec.last_outcome ?? rec.date;
|
|
107
|
+
const t = new Date(touch).getTime();
|
|
108
|
+
if (Number.isNaN(t))
|
|
109
|
+
return false;
|
|
110
|
+
return nowMs - t > STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
111
|
+
}
|
|
50
112
|
function applyCorrectionDefaults(record, holderDefault) {
|
|
113
|
+
const kind = record.kind ?? "correction";
|
|
114
|
+
const weight = record.weight ?? defaultWeight(record.severity);
|
|
51
115
|
return {
|
|
52
116
|
...record,
|
|
53
117
|
holder: record.holder ?? holderDefault,
|
|
54
|
-
kind
|
|
55
|
-
weight
|
|
118
|
+
kind,
|
|
119
|
+
weight,
|
|
56
120
|
active: record.active ?? true,
|
|
121
|
+
// Wave 5: a human correction is authoritative ground truth by default.
|
|
122
|
+
// Non-correction kinds (insight/hunch/fact) are advisory unless explicitly
|
|
123
|
+
// marked authoritative. Honor an explicit value when present.
|
|
124
|
+
authoritative: record.authoritative ?? (kind === "correction"),
|
|
125
|
+
// Consolidation/lifecycle defaults (2026-06-29). Old records lack these;
|
|
126
|
+
// they normalize on read with no migration. proof_confidence seeds from the
|
|
127
|
+
// authority weight so it is meaningful before any outcome has accrued.
|
|
128
|
+
proof_count: record.proof_count ?? 1,
|
|
129
|
+
proof_confidence: record.proof_confidence ?? weight,
|
|
130
|
+
stale: record.stale ?? false,
|
|
57
131
|
};
|
|
58
132
|
}
|
|
59
133
|
// ---------------------------------------------------------------------------
|
|
60
134
|
// Public API
|
|
61
135
|
// ---------------------------------------------------------------------------
|
|
136
|
+
/**
|
|
137
|
+
* Decimal-safe sentence splitter. Splits on sentence-ending punctuation
|
|
138
|
+
* (`.`, `!`, `?`, or a newline) ONLY when followed by whitespace or end-of-text
|
|
139
|
+
* — NOT on a bare `.` that sits between digits/word chars. This protects
|
|
140
|
+
* version/model tokens like "Opus 4.7", "v3.4.32", "novada-search" and URLs
|
|
141
|
+
* from being chopped mid-fragment (the exact Loop-7 mis-split that hid the
|
|
142
|
+
* imperative in "Show BOTH Opus 4.7 and 4.8" behind the slice "Show BOTH Opus 4").
|
|
143
|
+
*
|
|
144
|
+
* Returns the FULL text as a single fragment when no sentence boundary is found.
|
|
145
|
+
* Empty fragments are dropped. This is a classifier helper, not a linguistic
|
|
146
|
+
* tokenizer — it deliberately errs toward NOT splitting.
|
|
147
|
+
*/
|
|
148
|
+
export function splitSentences(text) {
|
|
149
|
+
// Boundary = one of . ! ? OR a newline, that is followed by whitespace or EOT.
|
|
150
|
+
// A `.` wedged between two non-space chars (4.7, file.md, e.g.) is NOT a boundary.
|
|
151
|
+
const out = [];
|
|
152
|
+
let buf = "";
|
|
153
|
+
for (let i = 0; i < text.length; i++) {
|
|
154
|
+
const ch = text[i];
|
|
155
|
+
buf += ch;
|
|
156
|
+
const next = text[i + 1];
|
|
157
|
+
const isPunct = ch === "." || ch === "!" || ch === "?";
|
|
158
|
+
const isNewline = ch === "\n" || ch === "\r";
|
|
159
|
+
// Sentence boundary: terminal punctuation at end OR followed by whitespace;
|
|
160
|
+
// newline is always a boundary. A `.` between non-whitespace chars is NOT
|
|
161
|
+
// a boundary (next is defined and not whitespace) — keeps decimals intact.
|
|
162
|
+
const atBoundary = isNewline ||
|
|
163
|
+
(isPunct && (next === undefined || /\s/.test(next)));
|
|
164
|
+
if (atBoundary) {
|
|
165
|
+
const frag = buf.trim();
|
|
166
|
+
if (frag)
|
|
167
|
+
out.push(frag);
|
|
168
|
+
buf = "";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const tail = buf.trim();
|
|
172
|
+
if (tail)
|
|
173
|
+
out.push(tail);
|
|
174
|
+
return out.length > 0 ? out : [text.trim()].filter(Boolean);
|
|
175
|
+
}
|
|
176
|
+
// Directive markers, split by STRENGTH (Loop 14 precision fix). Scanned
|
|
177
|
+
// per-fragment (Loop 8) so a directive in sentence 2+ is seen.
|
|
178
|
+
//
|
|
179
|
+
// STRONG markers signal a behavioral rule even inside prose, so they accept
|
|
180
|
+
// unconditionally. WEAK markers (a bare modal/verb) are genuine in a direct
|
|
181
|
+
// correction ("stop making it full width, it should be inline") but ALSO appear
|
|
182
|
+
// in tentative first-person filler ("I think we should use it") — the MEDIUM
|
|
183
|
+
// false-accept the Loop 14 round-table found. WEAK markers therefore accept only
|
|
184
|
+
// when the fragment is NOT a hedged/reporting frame. This is recall-safe: every
|
|
185
|
+
// genuine fixture correction carries a STRONG marker, a preference shape, or a
|
|
186
|
+
// non-hedged weak verb (verified by scripts/eval/capture-gate-confusion.mjs).
|
|
187
|
+
const STRONG_IMPERATIVE = /\b(never|always|don'?t|do not|must\s+not|must|should\s+not|needs?\s+(to|those|the|a|an|more|all)\b|instead|make\s+sure|remember\s+to|remove\s+all|replace\s+with|default\s+to|keep\s+the|keep\s+\w|show\s+(both|all|the|only))\b/i;
|
|
188
|
+
const WEAK_IMPERATIVE = /\b(should|use|using|stop|avoid|prefer)\b/i;
|
|
189
|
+
// Tentative / reporting frame at the START of a fragment — the speaker is musing
|
|
190
|
+
// or reporting, not issuing a rule. A WEAK marker inside such a frame is NOT a
|
|
191
|
+
// directive. Anchored at ^ so it only catches the OPENER, never a directive
|
|
192
|
+
// sentence that merely follows a hedge.
|
|
193
|
+
const HEDGE_FRAME = /^\s*(i\s+(think|guess|suppose|believe|reckon|feel|will)\b|i'?ll\b|i'?m\s+going\s+to\b|maybe\b|perhaps\b|sounds?\s+good\b|the\s+team\s+(wants?|thinks?|prefers?)\b|we\s+(could|might|may)\b)/i;
|
|
194
|
+
// Preference / corrective-fact statement. Includes user-preference verbs (CJK
|
|
195
|
+
// equivalents) AND the "X not Y" / "wrong … not" corrective-fact shape that
|
|
196
|
+
// carries real intent without an imperative verb (e.g. "Product names are
|
|
197
|
+
// novada-search (not novada-mcp)"). Scanned per-fragment.
|
|
198
|
+
const PREFERENCE_PATTERN = /(\buser\s+(wants?|prefers?|likes?|needs?|agreed|tested|chose|wanted)\b|\bthe\s+user\s+is\b|偏好|喜欢|要求|\bwrong\b[\s\S]{0,60}\bnot\b|\(not\s+[^)]+\)|\bnot\s+\w[\w-]*[,.]?\s+(it'?s|its|use|the\s+\w))/i;
|
|
62
199
|
/**
|
|
63
200
|
* Capture-quality gate — rejects context-free fragments, pure acknowledgments,
|
|
64
201
|
* and text that carries no actionable signal.
|
|
65
202
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
203
|
+
* v3 (2026-06-21, Loop 8): the ACTIONABLE-signal scan now runs over the FULL
|
|
204
|
+
* text AND each decimal-safe sentence fragment — accepting if ANY fragment
|
|
205
|
+
* carries an imperative/modal/preference marker. This fixes the Loop-7 root
|
|
206
|
+
* cause where the gate only ever saw the truncated first sentence
|
|
207
|
+
* (`text.split(/[.\n]/)[0].slice(0,100)`), discarding ~60% of genuine soft
|
|
208
|
+
* corrections whose directive lived in sentence 2 (e.g. "No, that's wrong.
|
|
209
|
+
* Don't use dark backgrounds.") or whose first sentence was chopped by a
|
|
210
|
+
* decimal ("Show BOTH Opus 4.7 and 4.8" → "Show BOTH Opus 4").
|
|
211
|
+
*
|
|
212
|
+
* PRECISION FLOOR: the HARD noise gates run FIRST, on the WHOLE text — these can
|
|
213
|
+
* never be rescued by the actionable scan:
|
|
214
|
+
* 1. too-short (< 12 chars).
|
|
215
|
+
* 2. system/tool fragment: starts with '<', pure number, bare file path.
|
|
216
|
+
* 3. doc/report/transcript header (starts with '#', a report/mission title,
|
|
217
|
+
* or a file:// URL) — pasted artifacts, never a behavioral rule.
|
|
218
|
+
*
|
|
219
|
+
* Then the ACTIONABLE-signal scan runs over the FULL text + each fragment. A
|
|
220
|
+
* text that OPENS like an acknowledgment ("No, that's wrong …") is RESCUED only
|
|
221
|
+
* if a fragment carries a genuine directive (the Loop-7 leak: "No, that's wrong.
|
|
222
|
+
* Don't use dark backgrounds." → fragment 2 "Don't use …" is a real rule).
|
|
223
|
+
*
|
|
224
|
+
* Finally the SOFT acknowledgment gate rejects pure acks that the actionable
|
|
225
|
+
* scan did NOT rescue (bare "ok sure", "no that's not what I meant"). Because it
|
|
226
|
+
* runs AFTER the actionable scan, it can no longer eat a genuine correction that
|
|
227
|
+
* merely opens with "no" — but a content-free ack still has no directive to
|
|
228
|
+
* rescue it, so it is still dropped. The marker set is TIGHT (dropped the v2
|
|
229
|
+
* loose "verb-ish anywhere" path) so a long prose blob with no real directive
|
|
230
|
+
* is NOT re-admitted just because it contains a generic verb.
|
|
231
|
+
*
|
|
232
|
+
* Returns { ok: true } when the text passes, or { ok: false, reason } explaining
|
|
233
|
+
* which gate fired. Callers may surface the reason in a warning.
|
|
234
|
+
*/
|
|
235
|
+
/**
|
|
236
|
+
* dropHardNoise — the four hard-noise precision-floor gates extracted so they
|
|
237
|
+
* can be called independently by the two-lane router (both lanes apply the same
|
|
238
|
+
* pre-filter before routing).
|
|
239
|
+
*
|
|
240
|
+
* Returns true = text passes (KEEP — not obviously noise)
|
|
241
|
+
* Returns false = text fails a hard gate (DROP — un-rescuable by actionable scan)
|
|
69
242
|
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* 2. Must NOT be a pure acknowledgment/fragment — applied unconditionally (no
|
|
73
|
-
* length cap — v1 60-char cap allowed long-context bypass).
|
|
74
|
-
* 3. Reject system/tool fragments: starts with '<', is a pure number, or is a
|
|
75
|
-
* bare file path (no spaces, contains path separator, no verb-ish content).
|
|
76
|
-
* 4. PASS if any of:
|
|
77
|
-
* a) imperative/modal marker present
|
|
78
|
-
* b) preference statement ("user wants/prefers/likes/needs", CJK equiv.)
|
|
79
|
-
* c) substantive rule: len >= 40 AND >= 2 words of >= 5 chars AND verb-ish
|
|
80
|
-
* 5. else reject: "no actionable signal"
|
|
243
|
+
* Identical semantics to the inline gates in isLikelyRealCorrection; this
|
|
244
|
+
* extraction must NOT change gate v4 behaviour (Loops 7/8/14 must stay intact).
|
|
81
245
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
246
|
+
* Gate 1 — minimum length (< 12 chars)
|
|
247
|
+
* Gate 2a — starts with '<' (system/tool fragment)
|
|
248
|
+
* Gate 2b — pure digits (bare number, no rule content)
|
|
249
|
+
* Gate 2c — bare file path (no spaces, has / or \, no 4+ letter word)
|
|
250
|
+
* Gate 3 — doc/report/transcript header (markdown '#', file://, ⏺, report title)
|
|
84
251
|
*/
|
|
252
|
+
export function dropHardNoise(text) {
|
|
253
|
+
const r = (typeof text === "string" ? text : "").trim();
|
|
254
|
+
// Gate 1 — minimum length
|
|
255
|
+
if (r.length < 12)
|
|
256
|
+
return false;
|
|
257
|
+
// Gate 2a — system/tool fragment
|
|
258
|
+
if (r.startsWith("<"))
|
|
259
|
+
return false;
|
|
260
|
+
// Gate 2b — bare number
|
|
261
|
+
if (/^\d+$/.test(r))
|
|
262
|
+
return false;
|
|
263
|
+
// Gate 2c — bare file path: no spaces, contains / or \, no 4+ letter words
|
|
264
|
+
if (!/\s/.test(r) && /[/\\]/.test(r) && !/\b[a-zA-Z]{4,}\b/.test(r))
|
|
265
|
+
return false;
|
|
266
|
+
// Gate 3 — doc/report/transcript header
|
|
267
|
+
const firstLine = r.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
268
|
+
const docHeaderPattern = /^(#{1,6}\s|file:\/\/|⏺|.*\b(test\s+report|status\s+report|local\s+test|mission|protocol|语言风格指南)\b\s*[—\-:])/i;
|
|
269
|
+
if (docHeaderPattern.test(firstLine))
|
|
270
|
+
return false;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
85
273
|
export function isLikelyRealCorrection(rule, _context) {
|
|
86
274
|
// NOTE: _context is accepted for forward-compat but NEVER classified on.
|
|
87
275
|
const r = rule.trim();
|
|
276
|
+
// ── HARD NOISE GATES (precision floor) — un-rescuable, run on WHOLE text ───
|
|
277
|
+
// Mirrors dropHardNoise's gates (kept inline for the per-gate reason strings).
|
|
88
278
|
// Gate 1 — minimum length
|
|
89
279
|
if (r.length < 12) {
|
|
90
280
|
return { ok: false, reason: "too short" };
|
|
91
281
|
}
|
|
92
|
-
// Gate 2 —
|
|
93
|
-
// Matches strings that start with an acknowledgment word and trail with only filler content
|
|
94
|
-
// up to 80 extra chars. The 80-char trailing budget covers e.g.
|
|
95
|
-
// "Ok good, then we don't change anything. let's focus on novada-mcp" (67 total)
|
|
96
|
-
// without catching real rules that happen to open with "ok" or "yes" (those tend to be
|
|
97
|
-
// much longer and/or contain definitive nouns + verbs checked in gate 4).
|
|
98
|
-
const acknowledgmentPattern = /^(no[,.]?\s*(that'?s\s+wrong[.!]?)?|ok(ay)?\b|good\b|great\b|nice\b|yes\b|yeah\b|right\b|wait\b|hmm+\b|sure\b|thanks?\b)[\s\S]{0,80}$/i;
|
|
99
|
-
if (acknowledgmentPattern.test(r)) {
|
|
100
|
-
return { ok: false, reason: "pure acknowledgment or fragment — no rule content" };
|
|
101
|
-
}
|
|
102
|
-
// Gate 3 — system/tool fragments
|
|
282
|
+
// Gate 2 — system/tool fragments
|
|
103
283
|
if (r.startsWith("<")) {
|
|
104
284
|
return { ok: false, reason: "system/tool fragment (starts with '<')" };
|
|
105
285
|
}
|
|
@@ -110,27 +290,67 @@ export function isLikelyRealCorrection(rule, _context) {
|
|
|
110
290
|
if (!/\s/.test(r) && /[/\\]/.test(r) && !/\b[a-zA-Z]{4,}\b/.test(r)) {
|
|
111
291
|
return { ok: false, reason: "looks like a bare file path — no rule content" };
|
|
112
292
|
}
|
|
113
|
-
// Gate
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
293
|
+
// Gate 3 — doc / report / transcript header (pasted artifact, not a rule).
|
|
294
|
+
// Loop-7 true-noise "doc/report headers": markdown headers, report/mission
|
|
295
|
+
// titles, file:// URL pastes, and the agent's own "⏺ …" transcript echo.
|
|
296
|
+
// Anchored at the START so a real rule that merely mentions "report"
|
|
297
|
+
// mid-sentence is unaffected. This is what stops the full-text scan from
|
|
298
|
+
// re-admitting a long pasted doc just because its body contains a verb.
|
|
299
|
+
const firstLine = r.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
300
|
+
const docHeaderPattern = /^(#{1,6}\s|file:\/\/|⏺|.*\b(test\s+report|status\s+report|local\s+test|mission|protocol|语言风格指南)\b\s*[—\-:])/i;
|
|
301
|
+
if (docHeaderPattern.test(firstLine)) {
|
|
302
|
+
return { ok: false, reason: "doc/report/transcript header — pasted artifact, no rule content" };
|
|
303
|
+
}
|
|
304
|
+
// ── ACTIONABLE-SIGNAL SCAN (v3) — FULL text + each sentence fragment ───────
|
|
305
|
+
// Loop 8 root-cause fix: accept if the FULL text OR ANY decimal-safe fragment
|
|
306
|
+
// carries a directive marker. Fragments come from the WHOLE text (never a
|
|
307
|
+
// truncated slice), so a directive in sentence 2+ is now seen and can RESCUE
|
|
308
|
+
// a text that opens with an acknowledgment.
|
|
309
|
+
const fragments = [r, ...splitSentences(r)];
|
|
310
|
+
// (a) STRONG directive marker in any fragment → accept unconditionally.
|
|
311
|
+
if (fragments.some((f) => STRONG_IMPERATIVE.test(f))) {
|
|
117
312
|
return { ok: true };
|
|
118
313
|
}
|
|
119
|
-
// (
|
|
120
|
-
|
|
121
|
-
|
|
314
|
+
// (a2) WEAK directive marker → accept only in a fragment that is NOT a hedged/
|
|
315
|
+
// reporting frame. Closes the Loop-14 filler-prose false-accept ("I think we
|
|
316
|
+
// should use it") while still accepting a direct weak-verb correction ("stop
|
|
317
|
+
// making it full width") and a directive sentence that merely FOLLOWS a hedge.
|
|
318
|
+
if (fragments.some((f) => WEAK_IMPERATIVE.test(f) && !HEDGE_FRAME.test(f))) {
|
|
122
319
|
return { ok: true };
|
|
123
320
|
}
|
|
124
|
-
// (
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
321
|
+
// (b) preference / corrective-fact statement in any fragment
|
|
322
|
+
if (fragments.some((f) => PREFERENCE_PATTERN.test(f))) {
|
|
323
|
+
return { ok: true };
|
|
324
|
+
}
|
|
325
|
+
// ── SOFT ACKNOWLEDGMENT GATE — runs AFTER the actionable scan ──────────────
|
|
326
|
+
// Pure acknowledgment / fragment: opens with an ack word and trails with only
|
|
327
|
+
// filler (<=80 extra chars). By this point the actionable scan has already
|
|
328
|
+
// found NO directive, so anything matching here is a genuine content-free ack
|
|
329
|
+
// ("ok sure", "no that's not what I meant", "confirmed"). NO length cap on the
|
|
330
|
+
// anchor — only the trailing budget — matching the v2 behavior for true acks.
|
|
331
|
+
const acknowledgmentPattern = /^(no[,.]?\s*(that'?s\s+wrong[.!]?)?|ok(ay)?\b|good\b|great\b|nice\b|yes\b|yeah\b|right\b|wait\b|hmm+\b|sure\b|thanks?\b|confirmed\b|fair\s+point\b)[\s\S]{0,80}$/i;
|
|
332
|
+
if (acknowledgmentPattern.test(r)) {
|
|
333
|
+
return { ok: false, reason: "pure acknowledgment or fragment — no rule content" };
|
|
131
334
|
}
|
|
132
335
|
return { ok: false, reason: "no actionable signal — rule lacks imperative/modal marker, preference statement, or substantive content" };
|
|
133
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* P1 consolidation match key. A new correction folds into an existing ACTIVE one
|
|
339
|
+
* only when their rule titles are IDENTICAL after normalization (lowercase, all
|
|
340
|
+
* runs of non-alphanumerics collapsed to a single space, trimmed).
|
|
341
|
+
*
|
|
342
|
+
* Deliberately VERBATIM-only. The dominant duplicate source is the SAME correction
|
|
343
|
+
* captured again across sessions, and exact-match is the ONE gate with ZERO risk
|
|
344
|
+
* of folding two DISTINCT rules into one. Fuzzy/semantic matching is unsafe on the
|
|
345
|
+
* zero-LLM storage path because it cannot tell a duplicate from a contradiction:
|
|
346
|
+
* "use proxy.ts" vs "use middleware.ts" and a "P0" vs "P1" variant differ by a
|
|
347
|
+
* short/numeric token that any local matcher either inflates (char-trigram) or
|
|
348
|
+
* drops (sub-3-char token filter) — so it would wrongly merge them. Paraphrase-
|
|
349
|
+
* level consolidation is left to the optional semantic/LLM path, never here.
|
|
350
|
+
*/
|
|
351
|
+
function normalizeRule(rule) {
|
|
352
|
+
return (rule ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
353
|
+
}
|
|
134
354
|
/**
|
|
135
355
|
* Write a correction to persistent storage.
|
|
136
356
|
* Auto-detects severity from the rule/context text.
|
|
@@ -142,9 +362,28 @@ export function isLikelyRealCorrection(rule, _context) {
|
|
|
142
362
|
*/
|
|
143
363
|
export function writeCorrection(project, correction) {
|
|
144
364
|
// Capture-quality gate — reject noise before touching disk.
|
|
145
|
-
//
|
|
146
|
-
|
|
365
|
+
// v3 (Loop 8): classify on the FULL correction text, not the truncated rule.
|
|
366
|
+
// `rule` is a first-sentence title slice (set by check.ts) that hid the
|
|
367
|
+
// directive when it lived in sentence 2 or after a decimal. The full text
|
|
368
|
+
// lives in `context`. ACCEPT if EITHER the rule OR the context carries a
|
|
369
|
+
// directive — a directive anywhere in the correction is genuine signal. The
|
|
370
|
+
// gate's own HARD noise gates (system-fragment / doc-header / too-short) run
|
|
371
|
+
// on each candidate, so this can't re-admit a long noise blob: a blob that
|
|
372
|
+
// matched a hard gate is rejected regardless of which field it came from.
|
|
373
|
+
const ruleText = (correction.rule ?? "").trim();
|
|
374
|
+
const contextText = (correction.context ?? "").trim();
|
|
375
|
+
const ruleGate = ruleText ? isLikelyRealCorrection(ruleText) : { ok: false, reason: "empty rule" };
|
|
376
|
+
// Only consult context when it adds NEW text (production: context ⊇ rule).
|
|
377
|
+
const contextGate = contextText && contextText !== ruleText
|
|
378
|
+
? isLikelyRealCorrection(contextText)
|
|
379
|
+
: { ok: false };
|
|
380
|
+
const gate = ruleGate.ok || contextGate.ok ? { ok: true } : ruleGate;
|
|
147
381
|
if (!gate.ok) {
|
|
382
|
+
// Survivorship-bias probe — record the discarded candidate (FULL rejected
|
|
383
|
+
// text + reason) so soft corrections the palace silently drops become
|
|
384
|
+
// measurable. Best-effort: logRejectedCorrection can NEVER throw here.
|
|
385
|
+
const rejectedText = contextText.length > ruleText.length ? contextText : ruleText;
|
|
386
|
+
logRejectedCorrection(project, rejectedText, gate.reason ?? "rejected");
|
|
148
387
|
return { written: false, reason: gate.reason };
|
|
149
388
|
}
|
|
150
389
|
const dir = correctionsDir(project);
|
|
@@ -152,13 +391,41 @@ export function writeCorrection(project, correction) {
|
|
|
152
391
|
// Auto-detect severity if not already set
|
|
153
392
|
const severity = correction.severity ?? detectSeverity(`${correction.rule} ${correction.context}`);
|
|
154
393
|
const record = applyCorrectionDefaults({ ...correction, severity }, todayDate());
|
|
394
|
+
// ── P1: on-write consolidation (refine-not-overwrite) ─────────────────────
|
|
395
|
+
// Borrow Hindsight's consolidation idea, AR-native: instead of accumulating a
|
|
396
|
+
// new dated file for a re-stated rule, fold it into the most similar ACTIVE
|
|
397
|
+
// correction of the SAME kind and bump that record's proof_count. The matched
|
|
398
|
+
// record keeps its id/date (stable document_id) and absorbs the new tags +
|
|
399
|
+
// higher severity/authority/weight. High-precision LOCAL gate — no key, no
|
|
400
|
+
// network — so this never runs an LLM on the storage hot path.
|
|
401
|
+
const normNew = normalizeRule(record.rule);
|
|
402
|
+
for (const existing of readActiveCorrections(project)) {
|
|
403
|
+
if (existing.id === record.id)
|
|
404
|
+
continue; // never merge into self (same-day re-slug)
|
|
405
|
+
if ((existing.kind ?? "correction") !== (record.kind ?? "correction"))
|
|
406
|
+
continue;
|
|
407
|
+
if (normalizeRule(existing.rule) !== normNew)
|
|
408
|
+
continue;
|
|
409
|
+
const merged = {
|
|
410
|
+
...existing,
|
|
411
|
+
proof_count: (existing.proof_count ?? 1) + 1,
|
|
412
|
+
merged_from: [...(existing.merged_from ?? []), record.id],
|
|
413
|
+
tags: Array.from(new Set([...(existing.tags ?? []), ...(record.tags ?? [])])),
|
|
414
|
+
// keep the STRONGER signal on every axis
|
|
415
|
+
severity: existing.severity === "p0" || record.severity === "p0" ? "p0" : "p1",
|
|
416
|
+
weight: Math.max(existing.weight ?? 0, record.weight ?? 0),
|
|
417
|
+
authoritative: Boolean(existing.authoritative || record.authoritative),
|
|
418
|
+
last_outcome: new Date().toISOString(),
|
|
419
|
+
};
|
|
420
|
+
const mfile = `${merged.date}-${slugify(merged.rule || merged.id)}.json`;
|
|
421
|
+
writeRecordAtomic(path.join(dir, mfile), merged);
|
|
422
|
+
return { written: true, merged: true, id: merged.id };
|
|
423
|
+
}
|
|
155
424
|
const filename = `${record.date}-${slugify(record.rule || record.id)}.json`;
|
|
156
425
|
const filepath = path.join(dir, filename);
|
|
157
426
|
// Atomic write — tmp + rename, mode 0600
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
fs.renameSync(tmp, filepath);
|
|
161
|
-
return { written: true };
|
|
427
|
+
writeRecordAtomic(filepath, record);
|
|
428
|
+
return { written: true, merged: false, id: record.id };
|
|
162
429
|
}
|
|
163
430
|
/**
|
|
164
431
|
* Read all corrections for a project, sorted newest first.
|
|
@@ -202,7 +469,7 @@ export function readP0Corrections(project) {
|
|
|
202
469
|
* The file is rewritten atomically — never deleted. The record remains in
|
|
203
470
|
* _outcomes.jsonl history and can be manually reactivated by editing the JSON.
|
|
204
471
|
*/
|
|
205
|
-
export function retractCorrection(project, id, reason) {
|
|
472
|
+
export function retractCorrection(project, id, reason, supersededBy) {
|
|
206
473
|
const dir = correctionsDir(project);
|
|
207
474
|
// Find the correction record by id
|
|
208
475
|
const all = readCorrections(project);
|
|
@@ -215,13 +482,13 @@ export function retractCorrection(project, id, reason) {
|
|
|
215
482
|
active: false,
|
|
216
483
|
retracted_at: new Date().toISOString(),
|
|
217
484
|
...(reason !== undefined ? { retract_reason: reason } : {}),
|
|
485
|
+
// P2: forward pointer to the correction that replaced this one (audit trail).
|
|
486
|
+
...(supersededBy !== undefined ? { superseded_by: supersededBy } : {}),
|
|
218
487
|
};
|
|
219
488
|
const filename = `${updated.date}-${slugify(updated.rule || updated.id)}.json`;
|
|
220
489
|
const filepath = path.join(dir, filename);
|
|
221
490
|
// Atomic rewrite — tmp + rename, mode 0600
|
|
222
|
-
|
|
223
|
-
fs.writeFileSync(tmp, JSON.stringify(updated, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
224
|
-
fs.renameSync(tmp, filepath);
|
|
491
|
+
writeRecordAtomic(filepath, updated);
|
|
225
492
|
return { success: true, id };
|
|
226
493
|
}
|
|
227
494
|
/**
|
|
@@ -257,18 +524,247 @@ export function recordOutcome(outcome) {
|
|
|
257
524
|
updated.recurrence_count = (updated.recurrence_count ?? 0) + 1;
|
|
258
525
|
updated.last_outcome = outcome.at;
|
|
259
526
|
}
|
|
527
|
+
else if (outcome.kind === "predicted") {
|
|
528
|
+
// Wave 5: prediction fired — instrument the predict-the-correction loop.
|
|
529
|
+
updated.predicted_count = (updated.predicted_count ?? 0) + 1;
|
|
530
|
+
updated.last_predicted = outcome.at;
|
|
531
|
+
}
|
|
532
|
+
else if (outcome.kind === "predict_hit") {
|
|
533
|
+
updated.predict_hits = (updated.predict_hits ?? 0) + 1;
|
|
534
|
+
}
|
|
260
535
|
const r = updated.retrieved_count ?? 0;
|
|
261
536
|
// Clamp to [0,1]: `retrieved` is guarded 1/day but `heeded` can fire on every
|
|
262
537
|
// session_end, so raw heeded/retrieved can exceed 1.0 ("150% heeded" is
|
|
263
538
|
// nonsense). min(1, …) keeps the metric honest. (Root-cause follow-up: apply
|
|
264
539
|
// the same 1/day guard to heeded as retrieved has, for finer resolution.)
|
|
540
|
+
// NB (Wave 5): `precision` is heeded/retrieved ONLY — predict_* never touch it.
|
|
265
541
|
updated.precision = r > 0 ? Math.min(1, Number(((updated.heeded_count ?? 0) / r).toFixed(3))) : undefined;
|
|
542
|
+
// Wave 5: predict_precision = predict_hits / predicted_count, kept SEPARATE
|
|
543
|
+
// from `precision`. Undefined until at least one prediction has fired.
|
|
544
|
+
// A predict_hit implies a prior prediction. If data is inconsistent (hits
|
|
545
|
+
// recorded without a matching predicted_count — e.g. migrated/corrupt records),
|
|
546
|
+
// floor the denominator at predict_hits so the metric stays VISIBLE and bounded
|
|
547
|
+
// rather than silently undefined while hits exist.
|
|
548
|
+
const pc = updated.predicted_count ?? 0;
|
|
549
|
+
const ph = updated.predict_hits ?? 0;
|
|
550
|
+
const predictDenom = Math.max(pc, ph);
|
|
551
|
+
updated.predict_precision = predictDenom > 0
|
|
552
|
+
? Math.min(1, Number((ph / predictDenom).toFixed(3)))
|
|
553
|
+
: undefined;
|
|
554
|
+
// P3: evidence-grounded proof_confidence. With NO outcome evidence yet, keep the
|
|
555
|
+
// authority prior (weight); once heeded/recurrence accrue, move to the Beta
|
|
556
|
+
// posterior so a rule that keeps being honored strengthens and one whose bug
|
|
557
|
+
// keeps recurring weakens. Kept SEPARATE from `precision` (heeded/retrieved) and
|
|
558
|
+
// from `weight` (static authority) — this is the evidence axis.
|
|
559
|
+
const heededC = updated.heeded_count ?? 0;
|
|
560
|
+
const recurC = updated.recurrence_count ?? 0;
|
|
561
|
+
updated.proof_confidence = (heededC + recurC) > 0
|
|
562
|
+
? Number(betaPosterior(heededC, recurC).toFixed(3))
|
|
563
|
+
: (updated.weight ?? defaultWeight(updated.severity));
|
|
266
564
|
// Re-write the JSON file atomically (tmp + rename — prevents truncation on SIGTERM).
|
|
267
565
|
const filename = `${updated.date}-${slugify(updated.rule || updated.id)}.json`;
|
|
268
566
|
const filepath = path.join(dir, filename);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
567
|
+
writeRecordAtomic(filepath, updated);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Best-effort: append one row to corrections/_rejected.jsonl recording a
|
|
571
|
+
* gate-rejected correction candidate. INVARIANT: never throws — every fs op is
|
|
572
|
+
* wrapped so a rejection log can never escalate into the capture path. Reads
|
|
573
|
+
* nothing on the hot path except the (already-small) file it rotates.
|
|
574
|
+
*
|
|
575
|
+
* Rotation: when the file exceeds REJECTED_LOG_CAP rows, it is rewritten with
|
|
576
|
+
* only the most-recent rows (append-only semantics, bounded size). Rotation is
|
|
577
|
+
* itself best-effort — a rotation failure still leaves the append intact.
|
|
578
|
+
*/
|
|
579
|
+
export function logRejectedCorrection(project, rule, reason) {
|
|
580
|
+
try {
|
|
581
|
+
const dir = correctionsDir(project);
|
|
582
|
+
ensureDir(dir);
|
|
583
|
+
const row = {
|
|
584
|
+
ts: new Date().toISOString(),
|
|
585
|
+
project,
|
|
586
|
+
rule,
|
|
587
|
+
reason,
|
|
588
|
+
gate_version: GATE_VERSION,
|
|
589
|
+
};
|
|
590
|
+
const p = rejectedPath(project);
|
|
591
|
+
fs.appendFileSync(p, JSON.stringify(row) + "\n", "utf-8");
|
|
592
|
+
// Bounded rotation — keep only the most-recent rows. Best-effort: if any
|
|
593
|
+
// step throws, the append above already succeeded and we simply skip trim.
|
|
594
|
+
try {
|
|
595
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
596
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
597
|
+
if (lines.length > REJECTED_LOG_CAP) {
|
|
598
|
+
const kept = lines.slice(-REJECTED_LOG_CAP).join("\n") + "\n";
|
|
599
|
+
const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
|
|
600
|
+
fs.writeFileSync(tmp, kept, { encoding: "utf-8", mode: 0o600 });
|
|
601
|
+
fs.renameSync(tmp, p);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
/* rotation is best-effort — append already landed */
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
/* a rejection log can NEVER throw into the capture path */
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Read all rejected correction candidates for a project, oldest-first (file
|
|
614
|
+
* order). Returns [] when no log exists — never throws. Skips malformed lines.
|
|
615
|
+
*/
|
|
616
|
+
export function readRejectedCorrections(project) {
|
|
617
|
+
const p = rejectedPath(project);
|
|
618
|
+
if (!fs.existsSync(p))
|
|
619
|
+
return [];
|
|
620
|
+
let raw;
|
|
621
|
+
try {
|
|
622
|
+
raw = fs.readFileSync(p, "utf-8");
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return [];
|
|
626
|
+
}
|
|
627
|
+
const out = [];
|
|
628
|
+
for (const line of raw.split("\n")) {
|
|
629
|
+
const trimmed = line.trim();
|
|
630
|
+
if (!trimmed)
|
|
631
|
+
continue;
|
|
632
|
+
try {
|
|
633
|
+
const rec = JSON.parse(trimmed);
|
|
634
|
+
if (rec && typeof rec.rule === "string" && typeof rec.reason === "string") {
|
|
635
|
+
out.push(rec);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
/* skip malformed line */
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Aggregate the rejected log into discard count + per-reason breakdown. When
|
|
646
|
+
* `acceptedCount` is supplied the discard RATE is computed too. Read-only.
|
|
647
|
+
*/
|
|
648
|
+
export function getRejectedStats(project, acceptedCount) {
|
|
649
|
+
const rows = readRejectedCorrections(project);
|
|
650
|
+
const byReason = new Map();
|
|
651
|
+
for (const r of rows) {
|
|
652
|
+
byReason.set(r.reason, (byReason.get(r.reason) ?? 0) + 1);
|
|
653
|
+
}
|
|
654
|
+
const top_reasons = [...byReason.entries()]
|
|
655
|
+
.map(([reason, count]) => ({ reason, count }))
|
|
656
|
+
.sort((a, b) => b.count - a.count);
|
|
657
|
+
const discarded = rows.length;
|
|
658
|
+
const denom = acceptedCount !== undefined ? discarded + acceptedCount : undefined;
|
|
659
|
+
return {
|
|
660
|
+
project,
|
|
661
|
+
discarded,
|
|
662
|
+
accepted: acceptedCount,
|
|
663
|
+
rate: denom && denom > 0 ? Number((discarded / denom).toFixed(4)) : undefined,
|
|
664
|
+
top_reasons,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Internal: bucket _outcomes.jsonl events per correction id, keeping only the
|
|
669
|
+
* lines for which `keep(localDay)` returns true. `localDay` is the event's
|
|
670
|
+
* local-TZ date (`sv` locale → YYYY-MM-DD), matching the 1/day guards elsewhere.
|
|
671
|
+
* Returns an empty Map when no log exists — never throws.
|
|
672
|
+
*
|
|
673
|
+
* This is the single parsing core shared by readOutcomesForToday /
|
|
674
|
+
* readOutcomesBefore / readOutcomesOnDate so all three agree on date handling.
|
|
675
|
+
*/
|
|
676
|
+
function bucketOutcomesBy(project, keep) {
|
|
677
|
+
const map = new Map();
|
|
678
|
+
const p = outcomesPath(project);
|
|
679
|
+
if (!fs.existsSync(p))
|
|
680
|
+
return map;
|
|
681
|
+
let raw;
|
|
682
|
+
try {
|
|
683
|
+
raw = fs.readFileSync(p, "utf-8");
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
return map;
|
|
687
|
+
}
|
|
688
|
+
for (const line of raw.split("\n")) {
|
|
689
|
+
const trimmed = line.trim();
|
|
690
|
+
if (!trimmed)
|
|
691
|
+
continue;
|
|
692
|
+
let evt;
|
|
693
|
+
try {
|
|
694
|
+
evt = JSON.parse(trimmed);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
continue; // skip malformed lines
|
|
698
|
+
}
|
|
699
|
+
if (!evt || !evt.correction_id || !evt.at)
|
|
700
|
+
continue;
|
|
701
|
+
let day;
|
|
702
|
+
try {
|
|
703
|
+
day = new Date(evt.at).toLocaleDateString("sv");
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (!keep(day))
|
|
709
|
+
continue;
|
|
710
|
+
let set = map.get(evt.correction_id);
|
|
711
|
+
if (!set) {
|
|
712
|
+
set = new Set();
|
|
713
|
+
map.set(evt.correction_id, set);
|
|
714
|
+
}
|
|
715
|
+
set.add(evt.kind);
|
|
716
|
+
}
|
|
717
|
+
return map;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Wave 5 — single source for "what outcomes already fired today" across the
|
|
721
|
+
* predict / check-action / session-start / session-end call sites. Reads the
|
|
722
|
+
* _outcomes.jsonl audit trail and buckets today's events (local-TZ) per
|
|
723
|
+
* correction id. Returns an empty Map when no log exists — never throws.
|
|
724
|
+
*
|
|
725
|
+
* Local-TZ date (`sv` locale → YYYY-MM-DD) matches the 1/day guards elsewhere
|
|
726
|
+
* (session-start/session-end) so "today" agrees across all four readers.
|
|
727
|
+
*/
|
|
728
|
+
export function readOutcomesForToday(project) {
|
|
729
|
+
const todayStr = new Date().toLocaleDateString("sv");
|
|
730
|
+
return bucketOutcomesBy(project, (day) => day === todayStr);
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Loop 3 — bucket outcome events recorded STRICTLY BEFORE a given ISO/date
|
|
734
|
+
* cutoff (local-TZ day comparison). Mirrors readOutcomesForToday but with an
|
|
735
|
+
* explicit date arg, so the cross-day predict_hit path can ask "was this risk
|
|
736
|
+
* already PREDICTED on an earlier day?" without depending on today's bucket.
|
|
737
|
+
*
|
|
738
|
+
* `isoCutoff` may be a full ISO timestamp or a YYYY-MM-DD date; only its
|
|
739
|
+
* local-TZ day is used. An event on the SAME day as the cutoff is EXCLUDED
|
|
740
|
+
* (strictly-before) — this is what keeps a same-session/same-day prediction
|
|
741
|
+
* from ever counting as a cross-day hit.
|
|
742
|
+
*/
|
|
743
|
+
export function readOutcomesBefore(project, isoCutoff) {
|
|
744
|
+
let cutoffDay;
|
|
745
|
+
try {
|
|
746
|
+
cutoffDay = new Date(isoCutoff).toLocaleDateString("sv");
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
return new Map();
|
|
750
|
+
}
|
|
751
|
+
return bucketOutcomesBy(project, (day) => day < cutoffDay);
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Loop 3 — bucket outcome events recorded ON a specific local-TZ day. Mirrors
|
|
755
|
+
* readOutcomesForToday but with an explicit date arg (for replaying a past day
|
|
756
|
+
* in tests / offline analysis). `isoDate` may be a full ISO timestamp or a
|
|
757
|
+
* YYYY-MM-DD date; only its local-TZ day is used.
|
|
758
|
+
*/
|
|
759
|
+
export function readOutcomesOnDate(project, isoDate) {
|
|
760
|
+
let onDay;
|
|
761
|
+
try {
|
|
762
|
+
onDay = new Date(isoDate).toLocaleDateString("sv");
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return new Map();
|
|
766
|
+
}
|
|
767
|
+
return bucketOutcomesBy(project, (day) => day === onDay);
|
|
272
768
|
}
|
|
273
769
|
/**
|
|
274
770
|
* Aggregate KPIs over all corrections for a project — the "is this learning loop working?" view.
|
|
@@ -294,6 +790,13 @@ export function getCorrectionKPIs(project) {
|
|
|
294
790
|
hot.push({ id: r.id, rule: r.rule, precision: p, retrieved: ret });
|
|
295
791
|
}
|
|
296
792
|
}
|
|
793
|
+
const nowMs = Date.now();
|
|
794
|
+
const stale = [];
|
|
795
|
+
for (const r of active) {
|
|
796
|
+
if (isStaleCorrection(r, nowMs)) {
|
|
797
|
+
stale.push({ id: r.id, rule: r.rule, last_seen: r.last_retrieved ?? r.last_outcome ?? r.date });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
297
800
|
return {
|
|
298
801
|
project,
|
|
299
802
|
total: all.length,
|
|
@@ -304,6 +807,52 @@ export function getCorrectionKPIs(project) {
|
|
|
304
807
|
precision: retrieved > 0 ? Math.min(1, Number((heeded / retrieved).toFixed(3))) : NaN,
|
|
305
808
|
noise_candidates: noise,
|
|
306
809
|
high_signal: hot,
|
|
810
|
+
stale_candidates: stale,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* P4: review low-signal corrections for archiving. SUGGEST-ONLY by default —
|
|
815
|
+
* returns candidates and mutates NOTHING. Set AR_CONSOLIDATE_AUTO=1 (or pass
|
|
816
|
+
* { auto: true }) to actually retract them. This mirrors AR's conservative
|
|
817
|
+
* posture: deleting belief is a deliberate act, so the default never mutates;
|
|
818
|
+
* an explicit human (or opt-in flag) triggers the retraction.
|
|
819
|
+
*/
|
|
820
|
+
export function reviewNoiseCorrections(project, opts) {
|
|
821
|
+
const auto = opts?.auto ?? (process.env.AR_CONSOLIDATE_AUTO === "1");
|
|
822
|
+
const suggestions = getCorrectionKPIs(project).noise_candidates;
|
|
823
|
+
const pruned = [];
|
|
824
|
+
if (auto) {
|
|
825
|
+
for (const c of suggestions) {
|
|
826
|
+
const res = retractCorrection(project, c.id, "auto-pruned: low signal (precision<0.3, retrieved≥3)");
|
|
827
|
+
if (res.success)
|
|
828
|
+
pruned.push(c.id);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return { suggestions, pruned, auto };
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* P5: order corrections for surfacing when a cap applies. Today P0s are surfaced
|
|
835
|
+
* `slice(0, 10)` in newest-first FILENAME order — so when a project has >10 P0s
|
|
836
|
+
* the ones that survive are arbitrary (just the most-recently-dated). This ranks
|
|
837
|
+
* by a composite LOCAL score (NO key, NO network) so the most authoritative +
|
|
838
|
+
* evidence-backed + recently-relevant rules win the cap:
|
|
839
|
+
* severity (p0 always above p1) ≫ proof_confidence ≫ recency ≫ proof_count.
|
|
840
|
+
* Deterministic and stable; pure (Date.now only for recency decay).
|
|
841
|
+
*/
|
|
842
|
+
export function rankCorrections(records, limit) {
|
|
843
|
+
const nowMs = Date.now();
|
|
844
|
+
const scoreOf = (r) => {
|
|
845
|
+
const sev = r.severity === "p0" ? 1 : 0;
|
|
846
|
+
const conf = r.proof_confidence ?? r.weight ?? 0;
|
|
847
|
+
const touch = r.last_retrieved ?? r.last_outcome ?? r.date;
|
|
848
|
+
const t = new Date(touch).getTime();
|
|
849
|
+
const days = Number.isNaN(t) ? 9999 : Math.max(0, (nowMs - t) / (24 * 60 * 60 * 1000));
|
|
850
|
+
const recency = Math.exp(-days / 180); // slow decay, matches knowledge half-life
|
|
851
|
+
const proof = Math.min(1, (r.proof_count ?? 1) / 5);
|
|
852
|
+
// severity dominates the ordering; the rest breaks ties within a severity tier.
|
|
853
|
+
return sev * 100 + conf * 10 + recency * 3 + proof;
|
|
307
854
|
};
|
|
855
|
+
const sorted = [...records].sort((a, b) => scoreOf(b) - scoreOf(a));
|
|
856
|
+
return limit !== undefined ? sorted.slice(0, limit) : sorted;
|
|
308
857
|
}
|
|
309
858
|
//# sourceMappingURL=corrections.js.map
|