recallnest 2.6.1 → 3.0.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/CHANGELOG.md +191 -0
- package/README.md +131 -14
- package/README_CN.md +121 -17
- package/config.json.example +8 -1
- package/docs/api-reference.md +1 -1
- package/integrations/README.md +1 -1
- package/integrations/agy/README.md +73 -0
- package/integrations/agy/setup.sh +75 -0
- package/integrations/setup-all.sh +1 -1
- package/package.json +5 -4
- package/src/__tests__/access-tracker-novelty.test.ts +22 -2
- package/src/__tests__/activity-counter.test.ts +38 -38
- package/src/__tests__/age-label.test.ts +33 -0
- package/src/__tests__/cluster-consolidation.test.ts +313 -22
- package/src/__tests__/consolidation-engine.test.ts +90 -0
- package/src/__tests__/contracts.test.ts +2 -2
- package/src/__tests__/data-checkup.test.ts +31 -3
- package/src/__tests__/dream-pipeline.test.ts +41 -1
- package/src/__tests__/dream-scope-isolation.test.ts +386 -0
- package/src/__tests__/dream-write-batching.test.ts +211 -0
- package/src/__tests__/env-config-invariant.test.ts +80 -0
- package/src/__tests__/env-config.test.ts +60 -0
- package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
- package/src/__tests__/maintenance-locks.test.ts +8 -0
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
- package/src/__tests__/memory-output.test.ts +9 -0
- package/src/__tests__/memory-utility.test.ts +256 -0
- package/src/__tests__/openai-contract.test.ts +513 -0
- package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
- package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
- package/src/__tests__/source-heartbeat.test.ts +6 -2
- package/src/__tests__/store-patch-metadata.test.ts +30 -0
- package/src/__tests__/synthesis-contract.test.ts +252 -0
- package/src/__tests__/synthesis-promotion.test.ts +369 -0
- package/src/__tests__/trusted-memory.test.ts +28 -0
- package/src/__tests__/utility-weight.test.ts +57 -0
- package/src/__tests__/verbatim-self-recall.test.ts +279 -0
- package/src/__tests__/version-manager.test.ts +35 -0
- package/src/access-tracker.ts +10 -1
- package/src/activity-counter.ts +69 -11
- package/src/age-label.ts +29 -0
- package/src/audit-log.ts +35 -0
- package/src/auto-gc.ts +97 -30
- package/src/cli.ts +224 -10
- package/src/consolidation-engine.ts +317 -123
- package/src/data-checkup.ts +17 -3
- package/src/dream-pipeline.ts +90 -12
- package/src/embedder.ts +20 -5
- package/src/env-config.ts +53 -0
- package/src/forget-engine.ts +8 -2
- package/src/gateway-server.ts +272 -0
- package/src/ingest.ts +23 -5
- package/src/llm-client.ts +86 -25
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +7 -2
- package/src/mcp-tools-core.ts +11 -1
- package/src/mcp-tools-governance.ts +45 -3
- package/src/memory-evolution.ts +16 -0
- package/src/memory-output.ts +12 -8
- package/src/memory-promotion.ts +280 -0
- package/src/memory-store-port.ts +32 -3
- package/src/memory-utility.ts +314 -0
- package/src/recall-ledger.ts +76 -0
- package/src/retriever.ts +82 -9
- package/src/scope-policy.ts +22 -1
- package/src/session-output.ts +4 -3
- package/src/skill-promotion.ts +152 -1
- package/src/store.ts +231 -71
- package/src/synthesis-contract.ts +286 -0
- package/src/version-manager.ts +83 -0
- package/src/workflow-observation-schema.ts +26 -0
- package/integrations/gemini-cli/README.md +0 -57
- package/integrations/gemini-cli/setup.sh +0 -64
- /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verbatim self-recall: can a memory be found using words copied out of its own body?
|
|
3
|
+
*
|
|
4
|
+
* ## Where this came from
|
|
5
|
+
*
|
|
6
|
+
* On 2026-08-13 a window tried to correct a stored case (`4cb4853e`) and could not reach
|
|
7
|
+
* it. `explain_memory` was called twice, eleven recall slots in total, with queries taken
|
|
8
|
+
* verbatim from that case's own text. The target came back zero times. Ranking first in
|
|
9
|
+
* the same result sets were two short, frequently-read `session_distill` entries.
|
|
10
|
+
*
|
|
11
|
+
* The correction was abandoned rather than guessed at — writing without the canonical key
|
|
12
|
+
* would have produced a second, parallel memory instead of a revision. So the failure was
|
|
13
|
+
* recorded as an anecdote and nothing more. This file is that anecdote turned into
|
|
14
|
+
* something that can be run.
|
|
15
|
+
*
|
|
16
|
+
* ## What the reproduction shows
|
|
17
|
+
*
|
|
18
|
+
* The fixture below is the shape that failed: one long, older, never-read `cases` entry
|
|
19
|
+
* competing against a dozen short, recent, frequently-read ones, with the long entry
|
|
20
|
+
* holding by far the best raw content score because the query is lifted from its text.
|
|
21
|
+
*
|
|
22
|
+
* Measured against the production `DEFAULT_RETRIEVAL_CONFIG`:
|
|
23
|
+
*
|
|
24
|
+
* raw vector score target 0.920 distractors 0.565–0.620
|
|
25
|
+
* after the chain target 0.469 distractors 0.47–0.71, target ranked 13 of 13
|
|
26
|
+
*
|
|
27
|
+
* The important detail — and it corrects the looser "the threshold cuts candidates down
|
|
28
|
+
* to single digits" reading this was first filed under — is that **nothing filters the
|
|
29
|
+
* target out**. Stage counts show `hard_min_score`, `layer_admission` and `noise_filter`
|
|
30
|
+
* each dropping zero rows; 0.469 clears `hardMinScore` 0.35 comfortably. The entry is
|
|
31
|
+
* admitted and then ranked last, so at any realistic `limit` it is simply never seen.
|
|
32
|
+
*
|
|
33
|
+
* The demotion is cumulative rather than attributable to one stage: importance weight,
|
|
34
|
+
* length normalisation, time decay and the hotness/frequency boosts each move a long,
|
|
35
|
+
* old, unread entry down and a short, fresh, popular one up. An ablation confirmed no
|
|
36
|
+
* single one of them is decisive — restoring any one factor on its own still leaves the
|
|
37
|
+
* target outside the top 5.
|
|
38
|
+
*
|
|
39
|
+
* ## Why these tests assert the failure instead of the fix
|
|
40
|
+
*
|
|
41
|
+
* Repairing the ranking chain is a design-level change that is deliberately out of scope
|
|
42
|
+
* here: it needs a shadow run and a per-row account of what drops out, and the candidate
|
|
43
|
+
* pool widening people reach for first was already measured and rejected
|
|
44
|
+
* (`rejected-recall-widen-candidate-pool`, 2026-08-22). So this is a characterisation
|
|
45
|
+
* test. It pins the current behaviour precisely enough that any change to the chain has
|
|
46
|
+
* to come past it, and it states the property that ought to hold.
|
|
47
|
+
*
|
|
48
|
+
* **If a test in the second block starts failing, that is very likely good news.** It
|
|
49
|
+
* means self-recall improved. Re-read the numbers, then move the assertion into the first
|
|
50
|
+
* block as a real invariant.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import { describe, expect, it } from "bun:test";
|
|
54
|
+
|
|
55
|
+
import { createRetriever, DEFAULT_RETRIEVAL_CONFIG } from "../retriever.js";
|
|
56
|
+
import { TraceCollector } from "../retrieval-trace.js";
|
|
57
|
+
|
|
58
|
+
const DAY_MS = 86_400_000;
|
|
59
|
+
|
|
60
|
+
/** One paragraph of the kind of long case entry that failed — repeated to realistic length. */
|
|
61
|
+
const CASE_PARAGRAPH =
|
|
62
|
+
"冷启动抑制已证伪:hotness 改成 bonus-only 之后,阈值风险队列从 52.25% 降到 33.12%。" +
|
|
63
|
+
"互审拦下三处过度断言,shadow 报告的口径与生产分类器不一致。";
|
|
64
|
+
|
|
65
|
+
const TARGET_TEXT = CASE_PARAGRAPH.repeat(10);
|
|
66
|
+
const TARGET_ID = "4cb4853e-0000-0000-0000-000000000000";
|
|
67
|
+
|
|
68
|
+
/** Lifted word-for-word out of TARGET_TEXT — this is what "its own phrasing" means. */
|
|
69
|
+
const VERBATIM_QUERY = "冷启动抑制已证伪 阈值风险队列 互审拦下三处过度断言 shadow 报告";
|
|
70
|
+
|
|
71
|
+
const DISTRACTOR_COUNT = 12;
|
|
72
|
+
|
|
73
|
+
function buildFixture() {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
|
|
76
|
+
// The target: long, two months old, never read. Best possible content match.
|
|
77
|
+
const target = {
|
|
78
|
+
id: TARGET_ID,
|
|
79
|
+
text: TARGET_TEXT,
|
|
80
|
+
vector: [1, 0, 0],
|
|
81
|
+
category: "cases",
|
|
82
|
+
scope: "memory",
|
|
83
|
+
importance: 0.75,
|
|
84
|
+
timestamp: now - 60 * DAY_MS,
|
|
85
|
+
metadata: JSON.stringify({
|
|
86
|
+
evolution: {
|
|
87
|
+
status: "active",
|
|
88
|
+
version: 1,
|
|
89
|
+
accessCount: 0,
|
|
90
|
+
lastAccessedAt: null,
|
|
91
|
+
validFrom: now - 60 * DAY_MS,
|
|
92
|
+
validUntil: null,
|
|
93
|
+
},
|
|
94
|
+
boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
|
|
95
|
+
}),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// The distractors: short, fresh, heavily read. Weak content match on this query.
|
|
99
|
+
const distractors = Array.from({ length: DISTRACTOR_COUNT }, (_, i) => ({
|
|
100
|
+
id: `dddddddd-${String(i).padStart(4, "0")}-0000-0000-000000000000`,
|
|
101
|
+
text: `会话提炼片段 ${i}:确认了一个与检索质量相关的方向。`,
|
|
102
|
+
vector: [1, 0, 0],
|
|
103
|
+
category: "events",
|
|
104
|
+
scope: "memory",
|
|
105
|
+
importance: 0.9,
|
|
106
|
+
timestamp: now - 2 * DAY_MS,
|
|
107
|
+
metadata: JSON.stringify({
|
|
108
|
+
evolution: {
|
|
109
|
+
status: "active",
|
|
110
|
+
version: 1,
|
|
111
|
+
accessCount: 40,
|
|
112
|
+
lastAccessedAt: now - DAY_MS,
|
|
113
|
+
validFrom: now - 2 * DAY_MS,
|
|
114
|
+
validUntil: null,
|
|
115
|
+
},
|
|
116
|
+
boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
|
|
117
|
+
}),
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
{ entry: target, score: 0.92 },
|
|
122
|
+
...distractors.map((entry, i) => ({ entry, score: 0.62 - i * 0.005 })),
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildRetriever(candidates: ReturnType<typeof buildFixture>) {
|
|
127
|
+
return createRetriever(
|
|
128
|
+
{
|
|
129
|
+
hasFtsSupport: false,
|
|
130
|
+
async vectorSearch() {
|
|
131
|
+
return candidates;
|
|
132
|
+
},
|
|
133
|
+
} as any,
|
|
134
|
+
{
|
|
135
|
+
async embedQuery() {
|
|
136
|
+
return [1, 0, 0];
|
|
137
|
+
},
|
|
138
|
+
async embedPassage() {
|
|
139
|
+
return [1, 0, 0];
|
|
140
|
+
},
|
|
141
|
+
} as any,
|
|
142
|
+
// Production defaults, minus the reranker (it needs a vendor key and a network call).
|
|
143
|
+
{ ...DEFAULT_RETRIEVAL_CONFIG, rerank: "none" as const },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function retrieveWithTrace(limit: number) {
|
|
148
|
+
const candidates = buildFixture();
|
|
149
|
+
const retriever = buildRetriever(candidates);
|
|
150
|
+
const trace = new TraceCollector();
|
|
151
|
+
const results = await retriever.retrieve({
|
|
152
|
+
query: VERBATIM_QUERY,
|
|
153
|
+
scopeFilter: ["memory"],
|
|
154
|
+
limit,
|
|
155
|
+
trace,
|
|
156
|
+
});
|
|
157
|
+
return { results, stages: trace.finalize(VERBATIM_QUERY, "vector").stages };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Invariants — these must keep holding.
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe("verbatim self-recall: what the fixture guarantees", () => {
|
|
165
|
+
it("gives the target the strictly best raw content score", () => {
|
|
166
|
+
// Guards the premise. If the fixture ever stops modelling "the query matches this one
|
|
167
|
+
// best", the tests below would be measuring something else entirely.
|
|
168
|
+
const candidates = buildFixture();
|
|
169
|
+
const target = candidates.find((c) => c.entry.id === TARGET_ID);
|
|
170
|
+
const best = Math.max(...candidates.filter((c) => c.entry.id !== TARGET_ID).map((c) => c.score));
|
|
171
|
+
|
|
172
|
+
expect(target?.score).toBe(0.92);
|
|
173
|
+
expect(target!.score).toBeGreaterThan(best);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("admits the target through every gate — it is not filtered out", async () => {
|
|
177
|
+
const { stages } = await retrieveWithTrace(DISTRACTOR_COUNT + 1);
|
|
178
|
+
|
|
179
|
+
// This is the part that must never regress into an actual filter. Losing a
|
|
180
|
+
// verbatim-matching memory to a ranking is bad; dropping it at a threshold would be
|
|
181
|
+
// worse, and would need a different fix.
|
|
182
|
+
for (const name of ["hard_min_score", "noise_filter"]) {
|
|
183
|
+
const stage = stages.find((s) => s.name === name);
|
|
184
|
+
expect(stage, `stage ${name} missing from trace`).toBeDefined();
|
|
185
|
+
expect(stage?.droppedCount, `stage ${name} dropped rows`).toBe(0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `layer_admission` is env-gated: applyLayerAdmission returns before touching the
|
|
189
|
+
// trace when RECALLNEST_LAYER_ADMISSION is off, which is the default. Asserting it
|
|
190
|
+
// unconditionally would only be asserting the developer's local .env — so check it
|
|
191
|
+
// when it ran, and rely on the whole-result assertion below when it did not.
|
|
192
|
+
const admission = stages.find((s) => s.name === "layer_admission");
|
|
193
|
+
if (admission) expect(admission.droppedCount, "layer admission dropped rows").toBe(0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("returns every candidate — nothing is removed on the way through", async () => {
|
|
197
|
+
// The environment-independent form of the claim above: whatever stages ran, the
|
|
198
|
+
// result set still holds all of them, so no gate removed the target.
|
|
199
|
+
const { results } = await retrieveWithTrace(DISTRACTOR_COUNT + 1);
|
|
200
|
+
|
|
201
|
+
expect(results).toHaveLength(DISTRACTOR_COUNT + 1);
|
|
202
|
+
expect(results.some((r) => r.entry.id === TARGET_ID)).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("keeps the target's final score above the hard minimum", async () => {
|
|
206
|
+
const { results } = await retrieveWithTrace(DISTRACTOR_COUNT + 1);
|
|
207
|
+
const target = results.find((r) => r.entry.id === TARGET_ID);
|
|
208
|
+
|
|
209
|
+
expect(target).toBeDefined();
|
|
210
|
+
expect(target!.score).toBeGreaterThan(DEFAULT_RETRIEVAL_CONFIG.hardMinScore);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// The defect — currently reproduced. A failure here probably means it got better.
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
describe("verbatim self-recall: the reported failure, reproduced", () => {
|
|
219
|
+
it("does not return the target in a default-sized result window", async () => {
|
|
220
|
+
// The original report in one line: eleven recall slots across two calls, queries taken
|
|
221
|
+
// from the entry's own text, target never returned.
|
|
222
|
+
const { results } = await retrieveWithTrace(5);
|
|
223
|
+
|
|
224
|
+
expect(results.some((r) => r.entry.id === TARGET_ID)).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("still does not return it when the window is doubled", async () => {
|
|
228
|
+
// Widening the window is the obvious first reflex, so it is worth recording that it
|
|
229
|
+
// does not work here either — the entry is last, not marginal.
|
|
230
|
+
const { results } = await retrieveWithTrace(10);
|
|
231
|
+
|
|
232
|
+
expect(results.some((r) => r.entry.id === TARGET_ID)).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("ranks the best content match last once every candidate is returned", async () => {
|
|
236
|
+
const { results } = await retrieveWithTrace(DISTRACTOR_COUNT + 1);
|
|
237
|
+
const rank = results.findIndex((r) => r.entry.id === TARGET_ID);
|
|
238
|
+
|
|
239
|
+
expect(results).toHaveLength(DISTRACTOR_COUNT + 1);
|
|
240
|
+
// Went in first by a wide margin, comes out last: a complete inversion of the
|
|
241
|
+
// content signal by the weighting stages that follow it.
|
|
242
|
+
expect(rank).toBe(DISTRACTOR_COUNT);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("shows the inversion is cumulative, not caused by one stage", async () => {
|
|
246
|
+
// Ablation summary: restoring any single disadvantage on its own — importance,
|
|
247
|
+
// recency, length, access count — still leaves the target out of the top 5. Recorded
|
|
248
|
+
// here so nobody re-runs the search for a single culprit.
|
|
249
|
+
const candidates = buildFixture();
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
|
|
252
|
+
const variants: Array<[string, () => typeof candidates]> = [
|
|
253
|
+
["importance raised to match", () =>
|
|
254
|
+
candidates.map((c) => c.entry.id === TARGET_ID
|
|
255
|
+
? { ...c, entry: { ...c.entry, importance: 0.9 } }
|
|
256
|
+
: c)],
|
|
257
|
+
["age reduced to match", () =>
|
|
258
|
+
candidates.map((c) => c.entry.id === TARGET_ID
|
|
259
|
+
? { ...c, entry: { ...c.entry, timestamp: now - 2 * DAY_MS } }
|
|
260
|
+
: c)],
|
|
261
|
+
["text shortened", () =>
|
|
262
|
+
candidates.map((c) => c.entry.id === TARGET_ID
|
|
263
|
+
? { ...c, entry: { ...c.entry, text: CASE_PARAGRAPH } }
|
|
264
|
+
: c)],
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
for (const [label, build] of variants) {
|
|
268
|
+
const results = await buildRetriever(build()).retrieve({
|
|
269
|
+
query: VERBATIM_QUERY,
|
|
270
|
+
scopeFilter: ["memory"],
|
|
271
|
+
limit: 5,
|
|
272
|
+
});
|
|
273
|
+
expect(
|
|
274
|
+
results.some((r) => r.entry.id === TARGET_ID),
|
|
275
|
+
`${label}: target unexpectedly recalled — the ablation result changed`,
|
|
276
|
+
).toBe(false);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
computeVersionRank,
|
|
15
15
|
deduplicateByVersionGroup,
|
|
16
16
|
createVersionGroup,
|
|
17
|
+
clearVersionGroupMetadata,
|
|
18
|
+
planSingletonVersionGroupRepairs,
|
|
17
19
|
} from "../version-manager.js";
|
|
18
20
|
import type { MemoryEntry } from "../store.js";
|
|
19
21
|
|
|
@@ -211,3 +213,36 @@ describe("deduplicateByVersionGroup", () => {
|
|
|
211
213
|
expect(deduped[0].entry.id).toBe("only");
|
|
212
214
|
});
|
|
213
215
|
});
|
|
216
|
+
|
|
217
|
+
describe("singleton version-group repair", () => {
|
|
218
|
+
it("plans only groups with one surviving member", () => {
|
|
219
|
+
const singleton = makeEntry("solo", { version_group: "vg-single", version_rank: 1 });
|
|
220
|
+
const pairA = makeEntry("pair-a", { version_group: "vg-pair", version_rank: 2 });
|
|
221
|
+
const pairB = makeEntry("pair-b", { version_group: "vg-pair", version_rank: 1 });
|
|
222
|
+
const malformed = makeEntry("bad");
|
|
223
|
+
malformed.metadata = "{not-json";
|
|
224
|
+
|
|
225
|
+
expect(planSingletonVersionGroupRepairs([
|
|
226
|
+
singleton,
|
|
227
|
+
pairA,
|
|
228
|
+
pairB,
|
|
229
|
+
makeEntry("ungrouped"),
|
|
230
|
+
malformed,
|
|
231
|
+
])).toEqual([{ id: "solo", groupId: "vg-single" }]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("clears only version-group keys and preserves unrelated metadata", () => {
|
|
235
|
+
const meta: Record<string, unknown> = {
|
|
236
|
+
version_group: "vg-single",
|
|
237
|
+
version_rank: 2.3,
|
|
238
|
+
version_created: "2026-07-07T00:00:00.000Z",
|
|
239
|
+
confidence: 0.9,
|
|
240
|
+
evolution: { status: "active" },
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
expect(clearVersionGroupMetadata(meta, "vg-single")).toEqual({
|
|
244
|
+
confidence: 0.9,
|
|
245
|
+
evolution: { status: "active" },
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
package/src/access-tracker.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { MemoryStore } from "./store.js";
|
|
13
13
|
import { evaluateTierChange, resolveTier, type MemoryTier } from "./decay-engine.js";
|
|
14
|
+
import { PROCESS_READER_ID, recordRecallHits } from "./recall-ledger.js";
|
|
14
15
|
import { logInfo, logWarn } from "./stderr-log.js";
|
|
15
16
|
|
|
16
17
|
// ============================================================================
|
|
@@ -70,7 +71,10 @@ export class AccessTracker {
|
|
|
70
71
|
private config: AccessTrackerConfig = DEFAULT_ACCESS_TRACKER_CONFIG,
|
|
71
72
|
readerId?: string,
|
|
72
73
|
) {
|
|
73
|
-
|
|
74
|
+
// 默认取进程级身份,不是每个实例各随机一个:components 按 profile 缓存,
|
|
75
|
+
// 一个会话开两个 profile 就会伪造出两个 reader,既污染 distinctReaderCount
|
|
76
|
+
// 也让 workflow observation 的 readerId join 对不上(见 recall-ledger.ts 抬头)。
|
|
77
|
+
this.readerId = readerId ?? PROCESS_READER_ID;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
@@ -137,6 +141,11 @@ export class AccessTracker {
|
|
|
137
141
|
|
|
138
142
|
const now = Date.now();
|
|
139
143
|
|
|
144
|
+
// P0 join key:先记账本再过闸。账本回答「agent 看到过哪些条目」,
|
|
145
|
+
// 下面的 novelty/cooldown 闸回答「哪些值得强化半衰期」——两个问题,
|
|
146
|
+
// 用后者过滤前者会把最常被读到的条目系统性排除在 join 之外。
|
|
147
|
+
recordRecallHits(ids, now);
|
|
148
|
+
|
|
140
149
|
for (let i = 0; i < ids.length; i++) {
|
|
141
150
|
const id = ids[i];
|
|
142
151
|
const score = scores?.[i];
|
package/src/activity-counter.ts
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
* HP-3: Activity-driven distill frequency.
|
|
3
3
|
* Tracks write operations since last distill, exposes tier-based thresholds.
|
|
4
4
|
* Complements CC-6 distill-lock: HP-3 decides "when to trigger", CC-6 decides "whether to allow".
|
|
5
|
+
*
|
|
6
|
+
* 2026-08-14 并发加固(dream 写爆库根治的一部分):
|
|
7
|
+
* - 写路径(increment/reset/resetBatch/prune)全部 async 化,进 `activity-stats` 跨进程锁 ——
|
|
8
|
+
* 此前 increment(11 个 mcp-server + ingest)与 reset(dream 04:00)裸读改写同一文件,
|
|
9
|
+
* 互踩窗口内的计数会静默丢失。
|
|
10
|
+
* - writeStats 改临时文件 + 原子 rename:裸 writeFileSync 期间被并发读到半截 JSON 时,
|
|
11
|
+
* readStats 的 catch 会返回空表 —— 一次损坏放大成"整个队列清零"。原子替换后读方
|
|
12
|
+
* 永远看到完整文件(旧版或新版)。
|
|
13
|
+
* - 读路径(getWriteCount/listScopesAboveThreshold/getDistillTier)保持同步无锁:原子
|
|
14
|
+
* rename 保证读到的文件完整;读到旧一拍的计数对 best-effort 语义无害。
|
|
5
15
|
*/
|
|
6
16
|
|
|
7
17
|
import {
|
|
@@ -9,9 +19,11 @@ import {
|
|
|
9
19
|
readFileSync,
|
|
10
20
|
writeFileSync,
|
|
11
21
|
mkdirSync,
|
|
22
|
+
renameSync,
|
|
12
23
|
} from "node:fs";
|
|
13
24
|
import { join, dirname } from "node:path";
|
|
14
25
|
import * as envConfig from "./env-config.js";
|
|
26
|
+
import { withLock } from "./distill-lock.js";
|
|
15
27
|
|
|
16
28
|
// ---------------------------------------------------------------------------
|
|
17
29
|
// Config
|
|
@@ -80,7 +92,22 @@ function readStats(statsPath: string): ActivityStats {
|
|
|
80
92
|
|
|
81
93
|
function writeStats(statsPath: string, stats: ActivityStats): void {
|
|
82
94
|
mkdirSync(dirname(statsPath), { recursive: true });
|
|
83
|
-
|
|
95
|
+
// 原子替换:先写同目录临时文件再 rename。并发读方要么看到旧版要么看到新版,
|
|
96
|
+
// 不会读到半截 JSON(那会被 readStats 的 catch 吞成空表,把整个队列清零)。
|
|
97
|
+
const tmpPath = `${statsPath}.tmp-${process.pid}`;
|
|
98
|
+
writeFileSync(tmpPath, JSON.stringify(stats, null, 2), "utf-8");
|
|
99
|
+
renameSync(tmpPath, statsPath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 写路径共用的锁参数:等待而非跳过(丢计数=丢 dream 触发信号),短超时防卡死写入方。 */
|
|
103
|
+
const ACTIVITY_LOCK_KEY = "activity-stats";
|
|
104
|
+
const ACTIVITY_LOCK_OPTS = { onBusy: "wait" as const, waitTimeoutMs: 2_000, expireMs: 30_000 };
|
|
105
|
+
|
|
106
|
+
async function withStatsLock<T>(fn: () => T): Promise<T> {
|
|
107
|
+
const outcome = await withLock(ACTIVITY_LOCK_KEY, async () => fn(), ACTIVITY_LOCK_OPTS);
|
|
108
|
+
// onBusy:"wait" resolves ran:true or throws on timeout; ran:false unreachable.
|
|
109
|
+
if (!outcome.ran) throw new Error("activity-stats lock unavailable");
|
|
110
|
+
return outcome.result;
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
// ---------------------------------------------------------------------------
|
|
@@ -88,16 +115,18 @@ function writeStats(statsPath: string, stats: ActivityStats): void {
|
|
|
88
115
|
// ---------------------------------------------------------------------------
|
|
89
116
|
|
|
90
117
|
/** Increment a scope's write counter by n (default 1). Returns the scope's new count. */
|
|
91
|
-
export function incrementWriteCount(
|
|
118
|
+
export async function incrementWriteCount(
|
|
92
119
|
scope: string,
|
|
93
120
|
n = 1,
|
|
94
121
|
cfg?: Partial<ActivityCounterConfig>,
|
|
95
|
-
): number {
|
|
122
|
+
): Promise<number> {
|
|
96
123
|
const { statsPath } = resolveConfig(cfg);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
124
|
+
return withStatsLock(() => {
|
|
125
|
+
const stats = readStats(statsPath);
|
|
126
|
+
stats.scopes[scope] = (stats.scopes[scope] ?? 0) + n;
|
|
127
|
+
writeStats(statsPath, stats);
|
|
128
|
+
return stats.scopes[scope];
|
|
129
|
+
});
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
/** Read a scope's current write count without modifying (0 if never written). */
|
|
@@ -107,11 +136,40 @@ export function getWriteCount(scope: string, cfg?: Partial<ActivityCounterConfig
|
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
/** Reset one scope's counter after its successful dream/distill (leaves other scopes untouched). */
|
|
110
|
-
export function resetWriteCount(scope: string, cfg?: Partial<ActivityCounterConfig>): void {
|
|
139
|
+
export async function resetWriteCount(scope: string, cfg?: Partial<ActivityCounterConfig>): Promise<void> {
|
|
111
140
|
const { statsPath } = resolveConfig(cfg);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
await withStatsLock(() => {
|
|
142
|
+
const stats = readStats(statsPath);
|
|
143
|
+
delete stats.scopes[scope];
|
|
144
|
+
writeStats(statsPath, stats);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remove every scope entry matching `predicate` in one read-modify-write (one lock
|
|
150
|
+
* acquisition, one atomic file replace). Returns the removed scope names.
|
|
151
|
+
*
|
|
152
|
+
* dream --auto 用它把 transcript scope(原文层,不参与巩固——shared-behaviors §5.3)
|
|
153
|
+
* 从计数文件里整体清扫,含未达标条目:listScopesAboveThreshold 只能看到达标的,
|
|
154
|
+
* 而未达标的 transcript 条目会让 stats 文件无限膨胀(历史会话只增不减)。
|
|
155
|
+
*/
|
|
156
|
+
export async function pruneWriteCounts(
|
|
157
|
+
predicate: (scope: string) => boolean,
|
|
158
|
+
cfg?: Partial<ActivityCounterConfig>,
|
|
159
|
+
): Promise<string[]> {
|
|
160
|
+
const { statsPath } = resolveConfig(cfg);
|
|
161
|
+
return withStatsLock(() => {
|
|
162
|
+
const stats = readStats(statsPath);
|
|
163
|
+
const removed: string[] = [];
|
|
164
|
+
for (const scope of Object.keys(stats.scopes)) {
|
|
165
|
+
if (predicate(scope)) {
|
|
166
|
+
delete stats.scopes[scope];
|
|
167
|
+
removed.push(scope);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (removed.length > 0) writeStats(statsPath, stats);
|
|
171
|
+
return removed;
|
|
172
|
+
});
|
|
115
173
|
}
|
|
116
174
|
|
|
117
175
|
/** Scopes whose write count is at or above `threshold` — the dream scheduler's work list. */
|
package/src/age-label.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relative age labels for rendered output.
|
|
3
|
+
*
|
|
4
|
+
* Why: a model asked to do date arithmetic on `2026-08-03` will sometimes get it
|
|
5
|
+
* wrong, and "how long ago" is exactly what decides whether a memory is still
|
|
6
|
+
* worth raising. So every rendered date carries a whole-day age next to it and
|
|
7
|
+
* nobody downstream has to subtract. (Borrowed from HKUDS/DeepTutor
|
|
8
|
+
* `memory/recall.py` — "days_ago rather than an ISO timestamp", 2026-08-17.)
|
|
9
|
+
*
|
|
10
|
+
* A future stamp (clock skew on an imported record) reads as today, not as a
|
|
11
|
+
* negative age. Unusable stamps render as "-" instead of a fabricated number.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DAY_MS = 86_400_000;
|
|
15
|
+
|
|
16
|
+
export const AGE_UNKNOWN = "-";
|
|
17
|
+
|
|
18
|
+
export function formatAgeLabel(timestampMs: number | null | undefined, now: number = Date.now()): string {
|
|
19
|
+
if (!timestampMs || !Number.isFinite(timestampMs)) return AGE_UNKNOWN;
|
|
20
|
+
const days = Math.floor((now - timestampMs) / DAY_MS);
|
|
21
|
+
return `${Math.max(0, days)}d`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatIsoAgeLabel(iso: string | null | undefined, now: number = Date.now()): string {
|
|
25
|
+
if (!iso) return AGE_UNKNOWN;
|
|
26
|
+
const parsed = Date.parse(iso);
|
|
27
|
+
if (Number.isNaN(parsed)) return AGE_UNKNOWN;
|
|
28
|
+
return formatAgeLabel(parsed, now);
|
|
29
|
+
}
|
package/src/audit-log.ts
CHANGED
|
@@ -21,6 +21,31 @@ export type AuditOperation =
|
|
|
21
21
|
| "cascade_forget"
|
|
22
22
|
| "reject";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Which revision of a memory a retrieval actually served.
|
|
26
|
+
*
|
|
27
|
+
* A `retrieve` row used to record only the query and a hit count, which cannot answer the
|
|
28
|
+
* question the audit log exists for: when an agent acted on recalled memory, *what did it
|
|
29
|
+
* read*? Memories are versioned in place — `archiveBeliefVersion` keeps the canonical id
|
|
30
|
+
* and bumps `evolution.version`, so "memory X was retrieved" is ambiguous across a belief
|
|
31
|
+
* change. Recording the revision alongside the id makes a past answer reconstructable.
|
|
32
|
+
*
|
|
33
|
+
* Field names are short because retrieval is by far the highest-frequency audited
|
|
34
|
+
* operation and `audit.jsonl` has no rotation.
|
|
35
|
+
*/
|
|
36
|
+
export interface RetrievedRevision {
|
|
37
|
+
/** First 8 chars of the memory id — enough to join against the store. */
|
|
38
|
+
id: string;
|
|
39
|
+
/** `evolution.version` at the moment it was served. */
|
|
40
|
+
rev: number;
|
|
41
|
+
/** `evolution.status`: active / superseded / archived / consolidated / pending_review. */
|
|
42
|
+
st: string;
|
|
43
|
+
/** `boundary.layer` — durable vs evidence decides how much authority the hit carried. */
|
|
44
|
+
ly?: string;
|
|
45
|
+
/** `boundary.authority` — where the content originally came from. */
|
|
46
|
+
au?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
export interface AuditEntry {
|
|
25
50
|
timestamp: string; // ISO 8601
|
|
26
51
|
operation: AuditOperation;
|
|
@@ -28,8 +53,18 @@ export interface AuditEntry {
|
|
|
28
53
|
memoryId?: string;
|
|
29
54
|
actor: string; // "manual" | "agent" | "api" | "system"
|
|
30
55
|
details?: string; // brief context (<=200 chars)
|
|
56
|
+
/** Revision + provenance of each served result. Capped — see `retrievedTotal`. */
|
|
57
|
+
retrieved?: RetrievedRevision[];
|
|
58
|
+
/**
|
|
59
|
+
* Total results served. Present only when it exceeds `retrieved.length`, so a truncated
|
|
60
|
+
* list is always visibly truncated rather than silently short.
|
|
61
|
+
*/
|
|
62
|
+
retrievedTotal?: number;
|
|
31
63
|
}
|
|
32
64
|
|
|
65
|
+
/** Upper bound on `retrieved` entries per row, keeping line size bounded. */
|
|
66
|
+
export const AUDIT_RETRIEVED_CAP = 10;
|
|
67
|
+
|
|
33
68
|
export interface AuditLogger {
|
|
34
69
|
log(entry: Omit<AuditEntry, "timestamp">): void;
|
|
35
70
|
/** Read recent entries (newest first) */
|