dsh-context-memory 0.7.1 → 0.10.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/lib/recall.js CHANGED
@@ -11,6 +11,10 @@
11
11
  * encoding that in one number is what makes "recall the most reliable thing you know"
12
12
  * expressible at all.
13
13
  *
14
+ * A memory a check *contradicted*, however, is not a low-scoring candidate — it is the
15
+ * wrong answer. It is filtered out here rather than down-weighted, because no amount of
16
+ * lexical overlap makes "the build uses yarn" useful once `package.json` disagrees.
17
+ *
14
18
  * @module context-memory/recall
15
19
  */
16
20
  import { normaliseSubject } from './identity.js';
@@ -21,14 +25,52 @@ const STOP_WORDS = new Set([
21
25
  'their', 'them', 'they', 'this', 'to', 'was', 'we', 'were', 'what', 'when', 'where',
22
26
  'which', 'who', 'will', 'with', 'you', 'your',
23
27
  ]);
24
- /** Split a query into distinct signal-bearing tokens. */
28
+ /**
29
+ * Characters treated as part of a Latin word.
30
+ *
31
+ * Everything else — including punctuation, whitespace, and every CJK code point — splits
32
+ * a Latin token. CJK is handled by its own branch below rather than here.
33
+ */
34
+ const LATIN_WORD = /[a-z0-9_.-]+/g;
35
+ /**
36
+ * A run of CJK characters.
37
+ *
38
+ * Covers the unified ideographs (the bulk of Chinese), the extension-A block, and the
39
+ * compatibility ideographs. Kana and Hangul are deliberately excluded: they are not the
40
+ * scripts this plugin's second language is written in, and including them would produce
41
+ * 2-grams that match nothing.
42
+ */
43
+ const CJK_RUN = /[㐀-䶿一-鿿豈-﫿]+/g;
44
+ /** Shortest CJK run that still yields a usable 2-gram. */
45
+ const CJK_MIN_RUN = 2;
46
+ /**
47
+ * Split a query into distinct signal-bearing tokens.
48
+ *
49
+ * Latin text is split on word boundaries as before. CJK text has no spaces, so a
50
+ * whitespace tokenizer discards it entirely — which made recall return nothing for every
51
+ * Chinese query while capture happily stored Chinese statements. Each CJK run is instead
52
+ * cut into overlapping 2-grams: two characters is the smallest unit that carries meaning
53
+ * in Chinese ("构建" does, "建" alone does not), and a 2-gram index needs no dictionary,
54
+ * no dependency, and no runtime model.
55
+ *
56
+ * A genuine word longer than two characters overlaps into several grams, so a query and a
57
+ * memory that share one word still match even when the surrounding wording differs.
58
+ */
25
59
  export function tokenize(text) {
60
+ const lowered = text.toLowerCase();
26
61
  const seen = new Set();
27
- for (const raw of text.toLowerCase().split(/[^a-z0-9_.-]+/)) {
62
+ for (const raw of lowered.match(LATIN_WORD) ?? []) {
28
63
  if (raw.length < 2 || STOP_WORDS.has(raw))
29
64
  continue;
30
65
  seen.add(raw);
31
66
  }
67
+ for (const run of lowered.match(CJK_RUN) ?? []) {
68
+ if (run.length < CJK_MIN_RUN)
69
+ continue;
70
+ for (let index = 0; index + CJK_MIN_RUN <= run.length; index += 1) {
71
+ seen.add(run.slice(index, index + CJK_MIN_RUN));
72
+ }
73
+ }
32
74
  return [...seen];
33
75
  }
34
76
  /** How much each part of the score can contribute. Sums to 1. */
@@ -39,6 +81,56 @@ const WEIGHTS = {
39
81
  };
40
82
  /** A memory older than this contributes no recency credit. */
41
83
  const RECENCY_HORIZON_MS = 90 * 24 * 60 * 60 * 1000;
84
+ /**
85
+ * The text a query is matched against, normalised once per memory.
86
+ *
87
+ * Keyed on the memory object, which the fold produces once and then reuses, so this is
88
+ * one normalisation per memory rather than one per memory per query — the lower-casing
89
+ * and three regex passes in `normaliseSubject` were being redone on every call, for
90
+ * every memory, on every step. A `WeakMap` rather than a field on `MemoryState` so the
91
+ * public shape (which the panel renders and the log emits) does not grow a derived
92
+ * value that could be stale for a hand-built object.
93
+ */
94
+ const SEARCHABLE_TEXT = new WeakMap();
95
+ function searchableText(memory) {
96
+ const cached = SEARCHABLE_TEXT.get(memory);
97
+ if (cached !== undefined)
98
+ return cached;
99
+ const value = normaliseSubject(`${memory.subject} ${memory.text}`);
100
+ SEARCHABLE_TEXT.set(memory, value);
101
+ return value;
102
+ }
103
+ /**
104
+ * Inverse document frequency over the population being ranked.
105
+ *
106
+ * Why this exists: plain overlap (`matched / tokens`) gives "项目" and "构建" the same
107
+ * weight. In a log about one project the first appears in nearly every memory and
108
+ * matches everything, so a query that also names a rare term ("pnpm") is diluted by the
109
+ * common ones. IDF is the standard correction — a term that appears in fewer memories
110
+ * carries more of the query meaning.
111
+ *
112
+ * Computed per `recall()` call over the filtered population, so the corpus is exactly the
113
+ * set of candidates being ranked: no global statistics to keep in sync, and no behaviour
114
+ * change when every term is equally common (equal weights reduce the weighted fraction
115
+ * to `matched / tokens`).
116
+ */
117
+ export function buildIdf(memories, tokens) {
118
+ const idf = new Map();
119
+ if (memories.length === 0 || tokens.length === 0)
120
+ return idf;
121
+ const texts = memories.map((memory) => searchableText(memory));
122
+ const n = texts.length;
123
+ for (const token of tokens) {
124
+ let df = 0;
125
+ for (const text of texts)
126
+ if (text.includes(token))
127
+ df += 1;
128
+ // Smoothed IDF, always positive: a term in every document scores ~0 rather than
129
+ // going negative and inverting the ranking.
130
+ idf.set(token, Math.log(1 + (n - df + 0.5) / (df + 0.5)));
131
+ }
132
+ return idf;
133
+ }
42
134
  /**
43
135
  * Score how relevant one memory is to a query.
44
136
  *
@@ -48,30 +140,98 @@ const RECENCY_HORIZON_MS = 90 * 24 * 60 * 60 * 1000;
48
140
  * passed a check or it did not, and giving partial credit for "was checked once and has
49
141
  * not been rechecked" would reward staleness.
50
142
  */
51
- export function scoreMemory(memory, tokens, now = Date.now()) {
52
- const subject = normaliseSubject(`${memory.subject} ${memory.text}`);
143
+ export function scoreMemory(memory, tokens, now = Date.now(), idf) {
144
+ const subject = searchableText(memory);
53
145
  const matched = tokens.filter((token) => subject.includes(token));
54
- const overlap = tokens.length === 0 ? 0 : matched.length / tokens.length;
146
+ // Overlap is the fraction of the query's *signal* that this memory covers. Without an
147
+ // IDF map it is the plain ratio; with one, each matched term contributes in proportion
148
+ // to how rare it is across the population (see buildIdf). The denominator keeps every
149
+ // query term — one that matches nothing still counts against the memory, which is what
150
+ // stops a stray rare word from inflating a score.
151
+ let overlap;
152
+ if (idf === undefined || tokens.length === 0) {
153
+ overlap = tokens.length === 0 ? 0 : matched.length / tokens.length;
154
+ }
155
+ else {
156
+ let weightTotal = 0;
157
+ let weightMatched = 0;
158
+ const matchedSet = new Set(matched);
159
+ for (const token of tokens) {
160
+ const weight = idf.get(token) ?? 1;
161
+ weightTotal += weight;
162
+ if (matchedSet.has(token))
163
+ weightMatched += weight;
164
+ }
165
+ overlap = weightTotal === 0 ? 0 : weightMatched / weightTotal;
166
+ }
55
167
  const age = Math.max(0, now - memory.createdAt);
56
168
  const recency = Math.max(0, 1 - age / RECENCY_HORIZON_MS);
57
169
  const verified = memory.verification?.outcome === 'passed' ? 1 : 0;
58
170
  const score = overlap * WEIGHTS.overlap + recency * WEIGHTS.recency + verified * WEIGHTS.verification;
59
- const why = `overlap ${matched.length}/${tokens.length}, recency ${recency.toFixed(2)}, ${verified ? 'verified' : 'unverified'}`;
171
+ const idfPart = idf === undefined ? '' : ` (idf ${overlap.toFixed(2)})`;
172
+ const why = `overlap ${matched.length}/${tokens.length}${idfPart}, recency ${recency.toFixed(2)}, ${verified ? 'verified' : 'unverified'}`;
60
173
  return { score, matched, why };
61
174
  }
62
175
  /**
63
- * Rank active memories against a query.
176
+ * Whether the world has denied this memory.
177
+ *
178
+ * Distinct from both `superseded` (a person retired it) and `broken` (the check could not
179
+ * run). Only a check that ran and failed counts: an unusable check says nothing about the
180
+ * memory, and treating it as a denial is how a typo would erase a true statement.
181
+ */
182
+ export function isContradicted(memory) {
183
+ return memory.status === 'active' && memory.verification?.outcome === 'violated';
184
+ }
185
+ /**
186
+ * Whether a memory belongs to the population `target` names.
64
187
  *
65
- * Superseded memories are excluded here rather than filtered by the caller: a memory
66
- * that was replaced is not a weaker answer, it is a wrong one, and ranking it anywhere
67
- * would put it back in front of the agent.
188
+ * Exported because two callers need the same answer and must not drift: ranking uses it
189
+ * to filter, and the memory service uses it to answer "what is in this population" for
190
+ * the pull tool. A second definition of "guidance" is how the push and pull paths would
191
+ * end up disagreeing about what the plugin considers offerable.
68
192
  */
69
- export function recall(memories, query, limit = 5, now = Date.now()) {
193
+ export function inTarget(memory, target) {
194
+ switch (target) {
195
+ case 'guidance': return memory.status === 'active' && !isContradicted(memory);
196
+ case 'contradicted': return isContradicted(memory);
197
+ case 'superseded': return memory.status === 'superseded';
198
+ case 'all': return true;
199
+ }
200
+ }
201
+ /**
202
+ * Rank memories against a query.
203
+ *
204
+ * The default population is guidance: superseded and contradicted memories are excluded
205
+ * here rather than filtered by the caller, because one was replaced and the other was
206
+ * disproved. Neither is a weaker answer — both are wrong ones, and ranking them anywhere
207
+ * would put them back in front of the agent unasked.
208
+ *
209
+ * `target` exists for the opposite caller: a model that explicitly asks what was
210
+ * recorded, or what a check disproved. Widening it is a deliberate, per-call decision
211
+ * and never the default, because the two paths want different answers to "what is
212
+ * true" — the push path wants guidance, the pull path wants the log.
213
+ */
214
+ export function recall(memories, query, limit = 5, now = Date.now(), target = 'guidance') {
70
215
  const tokens = tokenize(query);
71
- return memories
72
- .filter((memory) => memory.status === 'active')
73
- .map((memory) => ({ memory, ...scoreMemory(memory, tokens, now) }))
216
+ const population = memories.filter((memory) => inTarget(memory, target));
217
+ const idf = buildIdf(population, tokens);
218
+ const hits = population
219
+ .map((memory) => ({ memory, ...scoreMemory(memory, tokens, now, idf) }))
220
+ // A query with no signal-bearing tokens matches nothing rather than everything: an
221
+ // empty query is a question about nothing, and answering it with the whole log would
222
+ // make every ranking unexplainable.
74
223
  .filter((hit) => hit.matched.length > 0)
75
- .sort((a, b) => b.score - a.score || b.memory.createdAt - a.memory.createdAt)
76
- .slice(0, limit);
224
+ .sort((a, b) => b.score - a.score || b.memory.createdAt - a.memory.createdAt);
225
+ // One claim, one hit. The id is derived from the statement, so the same sentence
226
+ // recorded in both scopes arrives twice with the same id and — by construction — the
227
+ // same score and the same text. Handing both to the caller would put the same line into
228
+ // the injected context twice and make `limit` mean "hits" instead of "memories".
229
+ // `scope` is still reported on the survivor, so which log holds it stays visible.
230
+ const seen = new Set();
231
+ return hits.filter((hit) => {
232
+ if (seen.has(hit.memory.id))
233
+ return false;
234
+ seen.add(hit.memory.id);
235
+ return true;
236
+ }).slice(0, limit);
77
237
  }
package/lib/routes.d.ts CHANGED
@@ -15,4 +15,5 @@ export declare const MEMORY_RECALL_PATH = "/api/context-memory.recall";
15
15
  export declare const MEMORY_REMEMBER_PATH = "/api/context-memory.remember";
16
16
  export declare const MEMORY_FORGET_PATH = "/api/context-memory.forget";
17
17
  export declare const MEMORY_PREFS_PATH = "/api/context-memory.prefs";
18
+ export declare const MEMORY_CANDIDATES_PATH = "/api/context-memory.candidates";
18
19
  export declare function registerMemoryRoutes(ctx: Context, memory: MemoryService): void;
package/lib/routes.js CHANGED
@@ -13,6 +13,7 @@ export const MEMORY_RECALL_PATH = '/api/context-memory.recall';
13
13
  export const MEMORY_REMEMBER_PATH = '/api/context-memory.remember';
14
14
  export const MEMORY_FORGET_PATH = '/api/context-memory.forget';
15
15
  export const MEMORY_PREFS_PATH = '/api/context-memory.prefs';
16
+ export const MEMORY_CANDIDATES_PATH = '/api/context-memory.candidates';
16
17
  export function registerMemoryRoutes(ctx, memory) {
17
18
  // Injecting rather than reading `ctx.connection` directly: a plain property access on
18
19
  // a service that was not declared throws, and the web connection is absent in headless
@@ -71,6 +72,8 @@ export function registerMemoryRoutes(ctx, memory) {
71
72
  input.subject = body.subject;
72
73
  if (body.kind)
73
74
  input.kind = body.kind;
75
+ if (body.scope === 'user' || body.scope === 'project')
76
+ input.scope = body.scope;
74
77
  if (body.source)
75
78
  input.source = body.source;
76
79
  const mem = memory.remember(input);
@@ -92,12 +95,58 @@ export function registerMemoryRoutes(ctx, memory) {
92
95
  }
93
96
  if (!body.id)
94
97
  return Response.json({ error: 'missing id' }, { status: 400 });
95
- const ok = memory.forget(body.id, body.reason ?? 'superseded from panel', body.replacedBy);
98
+ const scope = body.scope === 'user' || body.scope === 'project' ? body.scope : undefined;
99
+ const ok = memory.forget(body.id, body.reason ?? 'superseded from panel', {
100
+ ...(body.replacedBy !== undefined ? { replacedBy: body.replacedBy } : {}),
101
+ ...(scope !== undefined ? { scope } : {}),
102
+ });
96
103
  if (!ok)
97
104
  return Response.json({ error: 'not found' }, { status: 404 });
98
105
  return Response.json({ ok: true });
99
106
  },
100
107
  });
108
+ // Candidate decisions — accept writes a memory, dismiss records the answer.
109
+ //
110
+ // Accepting is the only path from a heuristic to the log, which is why it takes an
111
+ // explicit id: the caller names the proposal it is confirming, and there is no route
112
+ // that turns "a sentence that looked like a fact" into a memory on its own.
113
+ connection.fetch.register({
114
+ path: MEMORY_CANDIDATES_PATH,
115
+ methods: ['GET', 'POST'],
116
+ requestBody: 'buffered',
117
+ fetch: async (request) => {
118
+ if (request.method === 'GET') {
119
+ return Response.json({ candidates: memory.candidates() }, { headers: { 'cache-control': 'no-store' } });
120
+ }
121
+ let body;
122
+ try {
123
+ body = await request.json();
124
+ }
125
+ catch {
126
+ return Response.json({ error: 'invalid JSON' }, { status: 400 });
127
+ }
128
+ if (body.id === undefined || body.id.length === 0)
129
+ return Response.json({ error: 'missing id' }, { status: 400 });
130
+ if (body.action === 'dismiss') {
131
+ const ok = memory.dismiss(body.id);
132
+ return ok ? Response.json({ ok: true }) : Response.json({ error: 'not found' }, { status: 404 });
133
+ }
134
+ if (body.action === 'accept') {
135
+ const overrides = {};
136
+ if (body.text !== undefined && body.text.trim().length > 0)
137
+ overrides.text = body.text;
138
+ if (body.kind !== undefined)
139
+ overrides.kind = body.kind;
140
+ if (body.scope === 'user' || body.scope === 'project')
141
+ overrides.scope = body.scope;
142
+ const stored = memory.accept(body.id, overrides);
143
+ return stored === undefined
144
+ ? Response.json({ error: 'not found' }, { status: 404 })
145
+ : Response.json(stored);
146
+ }
147
+ return Response.json({ error: 'action must be accept or dismiss' }, { status: 400 });
148
+ },
149
+ });
101
150
  // Feature switches — GET to read, POST a partial patch to set
102
151
  connection.fetch.register({
103
152
  path: MEMORY_PREFS_PATH,
package/lib/scope.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Where a memory lives.
3
+ *
4
+ * Two scopes, because "the build uses pnpm" and "answer me in Chinese" are stored in
5
+ * different places for different reasons. A project convention is only true inside that
6
+ * checkout and should travel with it; a working preference is true everywhere the user
7
+ * works and should not be re-stated in every repository.
8
+ *
9
+ * The scope is a property of the memory, not of the installation: one log holds both,
10
+ * and each entry is routed to the store its scope names. That keeps one fold and one
11
+ * panel while still letting the two populations move independently.
12
+ *
13
+ * @module context-memory/scope
14
+ */
15
+ /**
16
+ * Directory names that mark the top of a project, in probe order.
17
+ *
18
+ * `package.json` is deliberately absent. It is the marker everyone reaches for and it is
19
+ * the wrong one: a stray `package.json` in a home directory makes every unrelated folder
20
+ * beneath it one project, so a scratch directory on the Desktop would share a memory log
21
+ * with everything else in the profile. `.git` marks a checkout, and a workspace file only
22
+ * appears at the top of a workspace, which is what a project root means here.
23
+ *
24
+ * This matches `dsh-agent-instructions`, whose default `projectRootMarkers` is `['.git']`.
25
+ */
26
+ export declare const PROJECT_ROOT_MARKERS: readonly [".git", "pnpm-workspace.yaml"];
27
+ /** Where the user-global log lives, under the harness home. */
28
+ export declare function userStoreDir(): string;
29
+ /**
30
+ * Find the top of the project containing `start`.
31
+ *
32
+ * Mirrors `dsh-agent-instructions`'s discovery: climb while the marker is *absent*, and
33
+ * stop at the first directory that carries one. The difference from `process.cwd()` is
34
+ * the whole point — a session started three levels down inside a monorepo is still the
35
+ * same project, and two directories that are not the same project must not share a log.
36
+ *
37
+ * When no marker is found anywhere on the way up, `start` is its own project — which is
38
+ * honest: there is no evidence that anything above it belongs to the same work.
39
+ *
40
+ * @param start - absolute directory to begin from.
41
+ * @returns the discovered project root, or `start` when nothing marks one.
42
+ */
43
+ export declare function findProjectRoot(start: string): string;
44
+ /** Resolve the directory a project memory is stored under. */
45
+ export declare function resolveProjectStore(cwd?: string): string;
package/lib/scope.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Where a memory lives.
3
+ *
4
+ * Two scopes, because "the build uses pnpm" and "answer me in Chinese" are stored in
5
+ * different places for different reasons. A project convention is only true inside that
6
+ * checkout and should travel with it; a working preference is true everywhere the user
7
+ * works and should not be re-stated in every repository.
8
+ *
9
+ * The scope is a property of the memory, not of the installation: one log holds both,
10
+ * and each entry is routed to the store its scope names. That keeps one fold and one
11
+ * panel while still letting the two populations move independently.
12
+ *
13
+ * @module context-memory/scope
14
+ */
15
+ import { statSync } from 'node:fs';
16
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
17
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
18
+ /**
19
+ * Directory names that mark the top of a project, in probe order.
20
+ *
21
+ * `package.json` is deliberately absent. It is the marker everyone reaches for and it is
22
+ * the wrong one: a stray `package.json` in a home directory makes every unrelated folder
23
+ * beneath it one project, so a scratch directory on the Desktop would share a memory log
24
+ * with everything else in the profile. `.git` marks a checkout, and a workspace file only
25
+ * appears at the top of a workspace, which is what a project root means here.
26
+ *
27
+ * This matches `dsh-agent-instructions`, whose default `projectRootMarkers` is `['.git']`.
28
+ */
29
+ export const PROJECT_ROOT_MARKERS = ['.git', 'pnpm-workspace.yaml'];
30
+ /** Where the user-global log lives, under the harness home. */
31
+ export function userStoreDir() {
32
+ return dshHomePath('context-memory');
33
+ }
34
+ /**
35
+ * Find the top of the project containing `start`.
36
+ *
37
+ * Mirrors `dsh-agent-instructions`'s discovery: climb while the marker is *absent*, and
38
+ * stop at the first directory that carries one. The difference from `process.cwd()` is
39
+ * the whole point — a session started three levels down inside a monorepo is still the
40
+ * same project, and two directories that are not the same project must not share a log.
41
+ *
42
+ * When no marker is found anywhere on the way up, `start` is its own project — which is
43
+ * honest: there is no evidence that anything above it belongs to the same work.
44
+ *
45
+ * @param start - absolute directory to begin from.
46
+ * @returns the discovered project root, or `start` when nothing marks one.
47
+ */
48
+ export function findProjectRoot(start) {
49
+ const origin = resolve(start);
50
+ let current = origin;
51
+ for (;;) {
52
+ for (const marker of PROJECT_ROOT_MARKERS) {
53
+ if (exists(join(current, marker)))
54
+ return current;
55
+ }
56
+ const parent = dirname(current);
57
+ if (parent === current)
58
+ return origin;
59
+ current = parent;
60
+ }
61
+ }
62
+ /** `statSync` that answers a yes/no question without throwing on absence. */
63
+ function exists(path) {
64
+ try {
65
+ statSync(path);
66
+ return true;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /** Resolve the directory a project memory is stored under. */
73
+ export function resolveProjectStore(cwd) {
74
+ return findProjectRoot(cwd !== undefined && isAbsolute(cwd) ? cwd : resolve('.'));
75
+ }
package/lib/store.d.ts CHANGED
@@ -11,11 +11,31 @@
11
11
  */
12
12
  import type { MemoryEvent } from './types.js';
13
13
  export declare class MemoryStore {
14
- private readonly projectDir;
15
14
  private readonly dir;
16
15
  private readonly logPath;
17
- constructor(projectDir: string);
16
+ /**
17
+ * The parsed log, together with the file identity it was parsed from.
18
+ *
19
+ * This is not the cache the module comment warns about. That one was rejected because
20
+ * it would let one process's view of history outlive the events that contradict it;
21
+ * this one is revalidated against the file's own size and mtime on every read, so an
22
+ * append by another process is seen on the next call exactly as before. What it avoids
23
+ * is re-parsing an unchanged file — the same log is folded several times inside one
24
+ * pre-step, and parsing it once per fold is the only part of that which is avoidable.
25
+ */
26
+ private cached;
27
+ /** @param dir - the `.memory` directory itself, already resolved for its scope. */
28
+ constructor(dir: string);
18
29
  get path(): string;
30
+ /**
31
+ * An identity for the file as it is right now, or null when there is no file.
32
+ *
33
+ * Size alone would miss a rewrite; mtime alone is too coarse on some Windows
34
+ * filesystems. Together they change on every append this process or another makes.
35
+ * `stat` costs a syscall; reading and parsing the file costs a syscall plus an
36
+ * allocation plus a JSON.parse per line, which is why this is worth doing.
37
+ */
38
+ signature(): string | null;
19
39
  append(event: MemoryEvent): void;
20
40
  /**
21
41
  * Read the whole log.
@@ -25,6 +45,9 @@ export declare class MemoryStore {
25
45
  * recoverable loss for a total one — so a torn *final* line is dropped. Corruption
26
46
  * anywhere else is a real problem and is reported rather than skipped, because
27
47
  * silently ignoring a damaged record is how a log stops being evidence.
48
+ *
49
+ * The returned array is shared with the cache: treat it as read-only.
28
50
  */
29
51
  readAll(): MemoryEvent[];
52
+ private parse;
30
53
  }
package/lib/store.js CHANGED
@@ -9,25 +9,55 @@
9
9
  *
10
10
  * @module context-memory/store
11
11
  */
12
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  export class MemoryStore {
15
- projectDir;
16
15
  dir;
17
16
  logPath;
18
- constructor(projectDir) {
19
- this.projectDir = projectDir;
20
- this.dir = join(projectDir, '.memory');
17
+ /**
18
+ * The parsed log, together with the file identity it was parsed from.
19
+ *
20
+ * This is not the cache the module comment warns about. That one was rejected because
21
+ * it would let one process's view of history outlive the events that contradict it;
22
+ * this one is revalidated against the file's own size and mtime on every read, so an
23
+ * append by another process is seen on the next call exactly as before. What it avoids
24
+ * is re-parsing an unchanged file — the same log is folded several times inside one
25
+ * pre-step, and parsing it once per fold is the only part of that which is avoidable.
26
+ */
27
+ cached;
28
+ /** @param dir - the `.memory` directory itself, already resolved for its scope. */
29
+ constructor(dir) {
30
+ this.dir = dir;
21
31
  this.logPath = join(this.dir, 'events.jsonl');
22
32
  }
23
33
  get path() {
24
34
  return this.logPath;
25
35
  }
36
+ /**
37
+ * An identity for the file as it is right now, or null when there is no file.
38
+ *
39
+ * Size alone would miss a rewrite; mtime alone is too coarse on some Windows
40
+ * filesystems. Together they change on every append this process or another makes.
41
+ * `stat` costs a syscall; reading and parsing the file costs a syscall plus an
42
+ * allocation plus a JSON.parse per line, which is why this is worth doing.
43
+ */
44
+ signature() {
45
+ try {
46
+ const stats = statSync(this.logPath);
47
+ return `${stats.size}:${stats.mtimeMs}`;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
26
53
  append(event) {
27
54
  if (!existsSync(this.dir))
28
55
  mkdirSync(this.dir, { recursive: true });
29
56
  // One `write` of one line: a crash can truncate the final line, but cannot interleave.
30
57
  appendFileSync(this.logPath, `${JSON.stringify(event)}\n`, 'utf8');
58
+ // Not strictly needed — the next `signature()` no longer matches — but it keeps the
59
+ // cached array from being handed out after this process has moved the log on.
60
+ this.cached = undefined;
31
61
  }
32
62
  /**
33
63
  * Read the whole log.
@@ -37,10 +67,18 @@ export class MemoryStore {
37
67
  * recoverable loss for a total one — so a torn *final* line is dropped. Corruption
38
68
  * anywhere else is a real problem and is reported rather than skipped, because
39
69
  * silently ignoring a damaged record is how a log stops being evidence.
70
+ *
71
+ * The returned array is shared with the cache: treat it as read-only.
40
72
  */
41
73
  readAll() {
42
- if (!existsSync(this.logPath))
43
- return [];
74
+ const key = this.signature();
75
+ if (this.cached !== undefined && this.cached.key === key)
76
+ return this.cached.events;
77
+ const events = key === null ? [] : this.parse();
78
+ this.cached = { key, events };
79
+ return events;
80
+ }
81
+ parse() {
44
82
  const lines = readFileSync(this.logPath, 'utf8').split('\n');
45
83
  const events = [];
46
84
  for (let index = 0; index < lines.length; index += 1) {
package/lib/tools.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Model-facing tools: the pull half of memory.
3
+ *
4
+ * Recall injection pushes what the plugin thinks is relevant. That is one direction only,
5
+ * and it leaves the model unable to ask the question it most often needs answered:
6
+ * "what did we already decide about this?" This module registers that question as two
7
+ * tools, so memory is something the model can consult rather than only something that
8
+ * arrives unasked.
9
+ *
10
+ * The two directions deliberately answer differently. The push path may only offer
11
+ * guidance — active and not contradicted — because unsolicited text influences the turn.
12
+ * The pull path is answering a question someone actually asked, so it can widen to
13
+ * contradicted or superseded records when asked to by name. The difference is carried by
14
+ * the `include` argument rather than by a second implementation, and `guidance` is still
15
+ * the default: a model that does not think about populations gets the same conservatism
16
+ * the push path applies.
17
+ *
18
+ * @module context-memory/tools
19
+ */
20
+ import type { Context } from '@deepseek-ai/cordis';
21
+ import type { Agent } from '@deepseek-ai/dsh-agent';
22
+ import type { ContextMemory } from './memory.js';
23
+ /** How this plugin finds the log belonging to one call's recording context. */
24
+ export interface MemoryToolOptions {
25
+ /**
26
+ * The instance for the session that issued the call.
27
+ *
28
+ * A tool call belongs to an agent, and an agent belongs to a session with its own
29
+ * working directory — which may not be the project the last pre-step resolved. Reading
30
+ * the shared "current" instance instead would answer a question about one project with
31
+ * another project's log.
32
+ */
33
+ resolve(agent: Agent | undefined, signal: AbortSignal): Promise<ContextMemory>;
34
+ }
35
+ /**
36
+ * Register both tools, once the host's tool registry exists.
37
+ *
38
+ * Registered through `ctx.inject` rather than a static `inject` entry for the same reason
39
+ * the working directory is read through `ctx.get`: this plugin must keep working on a host
40
+ * that mounts no tool registry (the CLI, a headless profile without tools), and a declared
41
+ * dependency on `tools` would leave the whole plugin pending — including the pre-step
42
+ * injection and the panel — on such a host. The tools are an addition, not a precondition.
43
+ */
44
+ export declare function registerMemoryTools(ctx: Context, options: MemoryToolOptions): void;