dsh-context-memory 0.1.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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Memory identity.
3
+ *
4
+ * Ids are derived from the normalised subject rather than assigned, so remembering the
5
+ * same thing twice folds into one memory instead of accumulating near-duplicates. A
6
+ * counter-based id would make every repeat a new record, and the panel would fill with
7
+ * the same fact in five slightly different phrasings.
8
+ *
9
+ * The subject is also what conflicts are decided on, which is why it is normalised here
10
+ * once and shown everywhere it is used — a conflict the user cannot see the basis of is
11
+ * not actionable.
12
+ *
13
+ * @module context-memory/identity
14
+ */
15
+ /**
16
+ * Reduce a memory to the key it is stored and compared under.
17
+ *
18
+ * Lowercased, punctuation-stripped, whitespace-collapsed, and with a leading article
19
+ * dropped, so "The build uses pnpm" and "build uses pnpm" are the same memory. Filler
20
+ * that varies in wording but not meaning — "always", "never", "please" — is stripped
21
+ * too: those words change how a memory is checked, not what it is about, and keeping
22
+ * them would make the same fact conflict with itself.
23
+ */
24
+ export declare function normaliseSubject(text: string): string;
25
+ /**
26
+ * A stable short id for a memory, derived from its subject *and* its text.
27
+ *
28
+ * Both parts are hashed, which is what makes conflict detection possible at all. Keying
29
+ * on the text alone would give two disagreeing statements about one subject two
30
+ * unrelated ids, so nothing could tell they were about the same thing; keying on the
31
+ * subject alone would make the second statement fold into the first and silently
32
+ * overwrite it. Hashing the pair keeps the two cases distinct: the same statement
33
+ * repeated folds into one memory, while a different statement about the same subject
34
+ * stays separate and is reported as a conflict.
35
+ */
36
+ export declare function memoryId(subject: string, text: string): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Memory identity.
3
+ *
4
+ * Ids are derived from the normalised subject rather than assigned, so remembering the
5
+ * same thing twice folds into one memory instead of accumulating near-duplicates. A
6
+ * counter-based id would make every repeat a new record, and the panel would fill with
7
+ * the same fact in five slightly different phrasings.
8
+ *
9
+ * The subject is also what conflicts are decided on, which is why it is normalised here
10
+ * once and shown everywhere it is used — a conflict the user cannot see the basis of is
11
+ * not actionable.
12
+ *
13
+ * @module context-memory/identity
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ /**
17
+ * Reduce a memory to the key it is stored and compared under.
18
+ *
19
+ * Lowercased, punctuation-stripped, whitespace-collapsed, and with a leading article
20
+ * dropped, so "The build uses pnpm" and "build uses pnpm" are the same memory. Filler
21
+ * that varies in wording but not meaning — "always", "never", "please" — is stripped
22
+ * too: those words change how a memory is checked, not what it is about, and keeping
23
+ * them would make the same fact conflict with itself.
24
+ */
25
+ export function normaliseSubject(text) {
26
+ const stripped = text
27
+ .toLowerCase()
28
+ .replace(/[`"'.,;:!?()[\]]/g, ' ')
29
+ .replace(/\s+/g, ' ')
30
+ .replace(/^(?:always|never|please|note that|remember that|the)\s+/i, '')
31
+ .trim();
32
+ // Applied repeatedly: fillers stack ("please note that the build uses pnpm"), and a
33
+ // single pass would leave "note that …" behind, so the same fact said politely and
34
+ // said plainly would be two subjects instead of one.
35
+ let result = stripped;
36
+ let previous;
37
+ do {
38
+ previous = result;
39
+ result = result.replace(/^(?:always|never|please|note that|remember that|the)\s+/i, '');
40
+ } while (result !== previous);
41
+ return result;
42
+ }
43
+ /**
44
+ * A stable short id for a memory, derived from its subject *and* its text.
45
+ *
46
+ * Both parts are hashed, which is what makes conflict detection possible at all. Keying
47
+ * on the text alone would give two disagreeing statements about one subject two
48
+ * unrelated ids, so nothing could tell they were about the same thing; keying on the
49
+ * subject alone would make the second statement fold into the first and silently
50
+ * overwrite it. Hashing the pair keeps the two cases distinct: the same statement
51
+ * repeated folds into one memory, while a different statement about the same subject
52
+ * stays separate and is reported as a conflict.
53
+ */
54
+ export function memoryId(subject, text) {
55
+ return createHash('sha256').update(`${subject}\u0000${normaliseSubject(text)}`).digest('hex').slice(0, 12);
56
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Host half of dsh-context-memory.
3
+ *
4
+ * `apply` is the only entry point the loader calls, and everything the plugin does
5
+ * happens here rather than at install time — dsh rejects plugins whose dependencies
6
+ * carry install lifecycle scripts, so there is no `prepare` step to hide behind.
7
+ *
8
+ * @module context-memory/index
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ import { ContextMemory, type RememberInput } from './memory.js';
12
+ import type { MemoryKind, MemorySource, PanelPayload, RecallHit, VerifySummary } from './types.js';
13
+ export declare const name = "dsh-context-memory";
14
+ /** The service shape other plugins and the panel use. */
15
+ export interface MemoryService {
16
+ remember(input: RememberInput): ReturnType<ContextMemory['remember']>;
17
+ forget(id: string, reason: string, replacedBy?: string): boolean;
18
+ recall(query: string, limit?: number): RecallHit[];
19
+ conflicts(): ReturnType<ContextMemory['conflicts']>;
20
+ verify(): VerifySummary;
21
+ active(): ReturnType<ContextMemory['active']>;
22
+ panel(): PanelPayload;
23
+ }
24
+ export declare function apply(ctx: Context): void;
25
+ export type { MemoryKind, MemorySource };
package/lib/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Host half of dsh-context-memory.
3
+ *
4
+ * `apply` is the only entry point the loader calls, and everything the plugin does
5
+ * happens here rather than at install time — dsh rejects plugins whose dependencies
6
+ * carry install lifecycle scripts, so there is no `prepare` step to hide behind.
7
+ *
8
+ * @module context-memory/index
9
+ */
10
+ import { resolve } from 'node:path';
11
+ import { ContextMemory } from './memory.js';
12
+ import { registerMemoryRoutes } from './routes.js';
13
+ export const name = 'dsh-context-memory';
14
+ export function apply(ctx) {
15
+ const root = resolve(process.cwd());
16
+ const memory = new ContextMemory(root);
17
+ const service = {
18
+ remember: (input) => memory.remember(input),
19
+ forget: (id, reason, replacedBy) => memory.forget(id, reason, replacedBy),
20
+ recall: (query, limit) => memory.recall(query, limit),
21
+ conflicts: () => memory.conflicts(),
22
+ verify: () => memory.verify(),
23
+ active: () => memory.active(),
24
+ panel: () => memory.panel(),
25
+ };
26
+ ctx.provide('contextMemory', service);
27
+ registerMemoryRoutes(ctx, service);
28
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The memory service.
3
+ *
4
+ * Current state is always a fold over the event log, recomputed rather than cached.
5
+ * That is the point of the plugin: two processes reading the same log must agree on
6
+ * what is remembered, and a cache would let one process's view of history outlive the
7
+ * events that contradict it.
8
+ *
9
+ * @module context-memory/memory
10
+ */
11
+ import type { Conflict, Memory, MemoryKind, MemorySource, MemoryState, PanelPayload, RecallHit, VerifySummary } from './types.js';
12
+ export interface RememberInput {
13
+ text: string;
14
+ /**
15
+ * What the memory is about. Defaults to the statement itself, so repeats fold; supply
16
+ * it to make two different statements about one thing reportable as a conflict.
17
+ */
18
+ subject?: string;
19
+ kind?: MemoryKind;
20
+ source?: MemorySource;
21
+ sessionId?: string;
22
+ step?: number;
23
+ }
24
+ export declare class ContextMemory {
25
+ private readonly projectDir;
26
+ private readonly store;
27
+ constructor(projectDir: string);
28
+ get logPath(): string;
29
+ /**
30
+ * Fold the log into current state.
31
+ *
32
+ * Events are applied in log order and a memory is always addressable by id, so a
33
+ * supersede or verify that names a memory not yet seen is skipped rather than
34
+ * creating a phantom entry — a log that lost its `remember` line must not resurrect
35
+ * the memory from its consequences.
36
+ */
37
+ all(): MemoryState[];
38
+ active(): MemoryState[];
39
+ get(id: string): MemoryState | undefined;
40
+ /**
41
+ * Record a memory.
42
+ *
43
+ * The id is derived from the subject and the text, so remembering the same statement
44
+ * again refreshes its guard rather than creating a second copy — but it does not
45
+ * silently supersede it either. Re-remembering is not evidence that the old statement
46
+ * became wrong; only an explicit supersede says that. Two *different* statements about
47
+ * the same subject stay separate, and `conflicts` reports them together.
48
+ *
49
+ * A guard is compiled only from the statement's own text. Whatever the caller passes
50
+ * is never executed: memories can be written by the agent, so a memory-supplied
51
+ * command would be an injection path.
52
+ */
53
+ remember(input: RememberInput, now?: number): Memory;
54
+ /**
55
+ * Retire a memory, with a reason.
56
+ *
57
+ * Deletion is not offered. A memory that turns out to be wrong is the most valuable
58
+ * record in the log — it is the only thing that can explain a past decision — so it is
59
+ * marked superseded and kept, and the reason is required because "why did this stop
60
+ * being true" is the question that will be asked later.
61
+ */
62
+ forget(id: string, reason: string, replacedBy?: string, now?: number): boolean;
63
+ /**
64
+ * Group active memories that share a subject but say different things.
65
+ *
66
+ * The plugin deliberately does not pick a winner. Two memories with the same subject
67
+ * and different text means one of them is wrong and the log does not say which; a
68
+ * plausible-looking choice would be exactly the unfounded assertion this plugin exists
69
+ * to eliminate.
70
+ */
71
+ conflicts(): Conflict[];
72
+ /** Rank active memories against a query. */
73
+ recall(query: string, limit?: number, now?: number): RecallHit[];
74
+ /**
75
+ * Re-run every compiled guard.
76
+ *
77
+ * Keys accumulate because they are not interchangeable: `violated` is the world
78
+ * disagreeing with a memory, `broken` is the check itself being unusable, and
79
+ * `uncheckable` is there being no check. Only the first is evidence about the memory,
80
+ * so only the first may influence a caller's exit code.
81
+ */
82
+ verify(now?: number): VerifySummary;
83
+ /**
84
+ * The exit code a caller should use.
85
+ *
86
+ * Only a rule the world actually contradicted makes this non-zero. A guard that could
87
+ * not run is a defect in the check, not in the memory, and letting it turn a pipeline
88
+ * red is how a typo would get a true memory marked false.
89
+ */
90
+ static exitCode(summary: VerifySummary): number;
91
+ panel(): PanelPayload;
92
+ }
package/lib/memory.js ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The memory service.
3
+ *
4
+ * Current state is always a fold over the event log, recomputed rather than cached.
5
+ * That is the point of the plugin: two processes reading the same log must agree on
6
+ * what is remembered, and a cache would let one process's view of history outlive the
7
+ * events that contradict it.
8
+ *
9
+ * @module context-memory/memory
10
+ */
11
+ import { compileGuard, runGuard } from './guard.js';
12
+ import { memoryId, normaliseSubject } from './identity.js';
13
+ import { recall as rankRecall } from './recall.js';
14
+ import { MemoryStore } from './store.js';
15
+ export class ContextMemory {
16
+ projectDir;
17
+ store;
18
+ constructor(projectDir) {
19
+ this.projectDir = projectDir;
20
+ this.store = new MemoryStore(projectDir);
21
+ }
22
+ get logPath() {
23
+ return this.store.path;
24
+ }
25
+ /**
26
+ * Fold the log into current state.
27
+ *
28
+ * Events are applied in log order and a memory is always addressable by id, so a
29
+ * supersede or verify that names a memory not yet seen is skipped rather than
30
+ * creating a phantom entry — a log that lost its `remember` line must not resurrect
31
+ * the memory from its consequences.
32
+ */
33
+ all() {
34
+ const byId = new Map();
35
+ for (const event of this.store.readAll()) {
36
+ if (event.type === 'remember') {
37
+ byId.set(event.memory.id, { ...event.memory, status: 'active' });
38
+ continue;
39
+ }
40
+ const target = byId.get(event.id);
41
+ if (!target)
42
+ continue;
43
+ if (event.type === 'supersede') {
44
+ target.status = 'superseded';
45
+ target.supersededReason = event.reason;
46
+ if (event.replacedBy !== undefined)
47
+ target.supersededBy = event.replacedBy;
48
+ continue;
49
+ }
50
+ target.verification = { at: event.at, outcome: event.outcome, exitCode: event.exitCode, output: event.output };
51
+ }
52
+ return [...byId.values()].sort((a, b) => b.createdAt - a.createdAt);
53
+ }
54
+ active() {
55
+ return this.all().filter((memory) => memory.status === 'active');
56
+ }
57
+ get(id) {
58
+ return this.all().find((memory) => memory.id === id);
59
+ }
60
+ /**
61
+ * Record a memory.
62
+ *
63
+ * The id is derived from the subject and the text, so remembering the same statement
64
+ * again refreshes its guard rather than creating a second copy — but it does not
65
+ * silently supersede it either. Re-remembering is not evidence that the old statement
66
+ * became wrong; only an explicit supersede says that. Two *different* statements about
67
+ * the same subject stay separate, and `conflicts` reports them together.
68
+ *
69
+ * A guard is compiled only from the statement's own text. Whatever the caller passes
70
+ * is never executed: memories can be written by the agent, so a memory-supplied
71
+ * command would be an injection path.
72
+ */
73
+ remember(input, now = Date.now()) {
74
+ const text = input.text.trim();
75
+ if (text.length === 0)
76
+ throw new Error('cannot remember an empty statement');
77
+ const subject = input.subject !== undefined && input.subject.trim().length > 0
78
+ ? normaliseSubject(input.subject)
79
+ : normaliseSubject(text);
80
+ const id = memoryId(subject, text);
81
+ const guard = compileGuard(text);
82
+ const memory = { id, subject, text, kind: input.kind ?? 'fact', source: input.source ?? 'user', createdAt: now };
83
+ if (input.sessionId !== undefined)
84
+ memory.sessionId = input.sessionId;
85
+ if (input.step !== undefined)
86
+ memory.step = input.step;
87
+ if (guard)
88
+ memory.guard = guard;
89
+ const existing = this.get(id);
90
+ // Keep the original creation time: re-stating a memory is not the memory being new,
91
+ // and resetting it would let any repeat silently outrank older, better-evidenced ones.
92
+ if (existing)
93
+ memory.createdAt = existing.createdAt;
94
+ this.store.append({ type: 'remember', at: now, memory });
95
+ return memory;
96
+ }
97
+ /**
98
+ * Retire a memory, with a reason.
99
+ *
100
+ * Deletion is not offered. A memory that turns out to be wrong is the most valuable
101
+ * record in the log — it is the only thing that can explain a past decision — so it is
102
+ * marked superseded and kept, and the reason is required because "why did this stop
103
+ * being true" is the question that will be asked later.
104
+ */
105
+ forget(id, reason, replacedBy, now = Date.now()) {
106
+ if (!this.get(id))
107
+ return false;
108
+ if (reason.trim().length === 0)
109
+ throw new Error('a memory can only be superseded with a reason');
110
+ const event = { type: 'supersede', at: now, id, reason: reason.trim() };
111
+ if (replacedBy !== undefined)
112
+ event.replacedBy = replacedBy;
113
+ this.store.append(event);
114
+ return true;
115
+ }
116
+ /**
117
+ * Group active memories that share a subject but say different things.
118
+ *
119
+ * The plugin deliberately does not pick a winner. Two memories with the same subject
120
+ * and different text means one of them is wrong and the log does not say which; a
121
+ * plausible-looking choice would be exactly the unfounded assertion this plugin exists
122
+ * to eliminate.
123
+ */
124
+ conflicts() {
125
+ const groups = new Map();
126
+ for (const memory of this.active()) {
127
+ const group = groups.get(memory.subject);
128
+ if (group)
129
+ group.push(memory);
130
+ else
131
+ groups.set(memory.subject, [memory]);
132
+ }
133
+ const conflicts = [];
134
+ for (const [subject, memories] of groups) {
135
+ if (memories.length > 1)
136
+ conflicts.push({ subject, memories });
137
+ }
138
+ return conflicts.sort((a, b) => b.memories.length - a.memories.length);
139
+ }
140
+ /** Rank active memories against a query. */
141
+ recall(query, limit = 5, now = Date.now()) {
142
+ return rankRecall(this.all(), query, limit, now);
143
+ }
144
+ /**
145
+ * Re-run every compiled guard.
146
+ *
147
+ * Keys accumulate because they are not interchangeable: `violated` is the world
148
+ * disagreeing with a memory, `broken` is the check itself being unusable, and
149
+ * `uncheckable` is there being no check. Only the first is evidence about the memory,
150
+ * so only the first may influence a caller's exit code.
151
+ */
152
+ verify(now = Date.now()) {
153
+ const summary = { passed: 0, violated: 0, broken: 0, uncheckable: 0, results: [] };
154
+ for (const memory of this.active()) {
155
+ if (!memory.guard) {
156
+ summary.uncheckable += 1;
157
+ summary.results.push({ id: memory.id, text: memory.text, outcome: 'uncheckable', exitCode: null, output: '' });
158
+ continue;
159
+ }
160
+ const run = runGuard(memory.id, memory.guard, this.projectDir, new Date(now));
161
+ this.store.append({ type: 'verify', at: now, id: memory.id, outcome: run.outcome, exitCode: run.exitCode, output: run.output });
162
+ const result = { id: memory.id, text: memory.text, outcome: run.outcome, exitCode: run.exitCode, output: run.output };
163
+ summary.results.push(result);
164
+ if (run.outcome === 'passed')
165
+ summary.passed += 1;
166
+ else if (run.outcome === 'violated')
167
+ summary.violated += 1;
168
+ else
169
+ summary.broken += 1;
170
+ }
171
+ return summary;
172
+ }
173
+ /**
174
+ * The exit code a caller should use.
175
+ *
176
+ * Only a rule the world actually contradicted makes this non-zero. A guard that could
177
+ * not run is a defect in the check, not in the memory, and letting it turn a pipeline
178
+ * red is how a typo would get a true memory marked false.
179
+ */
180
+ static exitCode(summary) {
181
+ return summary.violated > 0 ? 1 : 0;
182
+ }
183
+ panel() {
184
+ const all = this.all();
185
+ const active = all.filter((memory) => memory.status === 'active');
186
+ const superseded = all.filter((memory) => memory.status === 'superseded');
187
+ return {
188
+ active,
189
+ superseded,
190
+ conflicts: this.conflicts(),
191
+ totals: {
192
+ active: active.length,
193
+ superseded: superseded.length,
194
+ withGuard: active.filter((memory) => memory.guard).length,
195
+ verified: active.filter((memory) => memory.verification?.outcome === 'passed').length,
196
+ unverified: active.filter((memory) => memory.verification?.outcome !== 'passed').length,
197
+ },
198
+ };
199
+ }
200
+ }
@@ -0,0 +1,60 @@
1
+ // src/client/view.tsx
2
+ function escapeHtml(value) {
3
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4
+ }
5
+ function verificationLabel(memory) {
6
+ if (!memory.guard) return "no check";
7
+ switch (memory.verification?.outcome) {
8
+ case "passed":
9
+ return "checked, holds";
10
+ case "violated":
11
+ return "checked, does not hold";
12
+ case "broken":
13
+ return "check unusable";
14
+ default:
15
+ return "not yet checked";
16
+ }
17
+ }
18
+ function rows(memories) {
19
+ return memories.map((memory) => {
20
+ const provenance = [
21
+ memory.source,
22
+ memory.sessionId ? `session ${memory.sessionId}` : null,
23
+ memory.step !== void 0 ? `step ${memory.step}` : null
24
+ ].filter(Boolean).join(" \xB7 ");
25
+ return `<tr><td><code>${escapeHtml(memory.id)}</code></td><td>${escapeHtml(memory.text)}</td><td>${escapeHtml(memory.kind)}</td><td>${escapeHtml(verificationLabel(memory))}</td><td>${escapeHtml(provenance)}</td></tr>`;
26
+ }).join("");
27
+ }
28
+ function renderPanel(payload) {
29
+ const { totals } = payload;
30
+ const summary = `<p class="memory-summary">${totals.active} active (${totals.verified} checked and holding, ${totals.unverified} not), ${totals.superseded} superseded, ${totals.withGuard} with a compiled check.</p>`;
31
+ const conflictBlock = payload.conflicts.length === 0 ? "<p>No conflicts.</p>" : `<div class="memory-conflicts"><h3>Conflicts</h3><ul>${payload.conflicts.map((conflict) => `<li><strong>${escapeHtml(conflict.subject)}</strong><ul>${conflict.memories.map((memory) => `<li><code>${escapeHtml(memory.id)}</code> ${escapeHtml(memory.text)}</li>`).join("")}</ul></li>`).join("")}</ul></div>`;
32
+ const activeBlock = payload.active.length === 0 ? "<p>Nothing is remembered yet.</p>" : `<table><thead><tr><th>Id</th><th>Memory</th><th>Kind</th><th>Status</th><th>From</th></tr></thead><tbody>${rows(payload.active)}</tbody></table>`;
33
+ const supersededBlock = payload.superseded.length === 0 ? "" : `<details><summary>Superseded (${payload.superseded.length})</summary><table><thead><tr><th>Id</th><th>Memory</th><th>Kind</th><th>Status</th><th>From</th></tr></thead><tbody>${rows(payload.superseded)}</tbody></table></details>`;
34
+ return `<div class="context-memory-panel"><h2>Context memory</h2>${summary}${conflictBlock}<h3>Active</h3>${activeBlock}${supersededBlock}</div>`;
35
+ }
36
+
37
+ // src/client/index.tsx
38
+ var inject = ["@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-connection"];
39
+ function apply(ctx) {
40
+ ctx.inject(inject, (settings, connection) => {
41
+ const s = settings;
42
+ const c = connection;
43
+ s.section("context-memory", {
44
+ title: "Context memory",
45
+ render: async () => {
46
+ try {
47
+ const res = await c.fetch("/api/context-memory.panel");
48
+ const payload = await res.json();
49
+ return renderPanel(payload);
50
+ } catch (error) {
51
+ return `<div class="context-memory-panel"><h2>Context memory</h2><p>Could not load memories: ${String(error)}</p></div>`;
52
+ }
53
+ }
54
+ });
55
+ });
56
+ }
57
+ export {
58
+ apply,
59
+ inject
60
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Recall: which memories are relevant to a query.
3
+ *
4
+ * The alternative to ranking is pasting every stored memory into the context window,
5
+ * which costs tokens on every turn and buries the one that mattered. So retrieval is
6
+ * scored, and the score is broken down into named parts that are returned alongside the
7
+ * result — a ranking the user cannot inspect is a ranking they have to trust.
8
+ *
9
+ * Verification strength is part of the score, not a separate display concern: a memory
10
+ * that was checked against the world is worth more than one that was merely stated, and
11
+ * encoding that in one number is what makes "recall the most reliable thing you know"
12
+ * expressible at all.
13
+ *
14
+ * @module context-memory/recall
15
+ */
16
+ import type { MemoryState, RecallHit } from './types.js';
17
+ /** Split a query into distinct signal-bearing tokens. */
18
+ export declare function tokenize(text: string): string[];
19
+ /**
20
+ * Score how relevant one memory is to a query.
21
+ *
22
+ * Overlap dominates because it is the only part grounded in the query. Recency is a
23
+ * tiebreaker, not a signal: a three-year-old decision that matches the query beats a
24
+ * fresh memory that does not. Verification credit is all-or-nothing — a memory either
25
+ * passed a check or it did not, and giving partial credit for "was checked once and has
26
+ * not been rechecked" would reward staleness.
27
+ */
28
+ export declare function scoreMemory(memory: MemoryState, tokens: string[], now?: number): {
29
+ score: number;
30
+ matched: string[];
31
+ why: string;
32
+ };
33
+ /**
34
+ * Rank active memories against a query.
35
+ *
36
+ * Superseded memories are excluded here rather than filtered by the caller: a memory
37
+ * that was replaced is not a weaker answer, it is a wrong one, and ranking it anywhere
38
+ * would put it back in front of the agent.
39
+ */
40
+ export declare function recall(memories: MemoryState[], query: string, limit?: number, now?: number): RecallHit[];
package/lib/recall.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Recall: which memories are relevant to a query.
3
+ *
4
+ * The alternative to ranking is pasting every stored memory into the context window,
5
+ * which costs tokens on every turn and buries the one that mattered. So retrieval is
6
+ * scored, and the score is broken down into named parts that are returned alongside the
7
+ * result — a ranking the user cannot inspect is a ranking they have to trust.
8
+ *
9
+ * Verification strength is part of the score, not a separate display concern: a memory
10
+ * that was checked against the world is worth more than one that was merely stated, and
11
+ * encoding that in one number is what makes "recall the most reliable thing you know"
12
+ * expressible at all.
13
+ *
14
+ * @module context-memory/recall
15
+ */
16
+ import { normaliseSubject } from './identity.js';
17
+ /** Words that carry no retrieval signal and would match almost any memory. */
18
+ const STOP_WORDS = new Set([
19
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'do', 'does', 'for', 'from',
20
+ 'has', 'have', 'how', 'i', 'in', 'is', 'it', 'its', 'of', 'on', 'or', 'that', 'the',
21
+ 'their', 'them', 'they', 'this', 'to', 'was', 'we', 'were', 'what', 'when', 'where',
22
+ 'which', 'who', 'will', 'with', 'you', 'your',
23
+ ]);
24
+ /** Split a query into distinct signal-bearing tokens. */
25
+ export function tokenize(text) {
26
+ const seen = new Set();
27
+ for (const raw of text.toLowerCase().split(/[^a-z0-9_.-]+/)) {
28
+ if (raw.length < 2 || STOP_WORDS.has(raw))
29
+ continue;
30
+ seen.add(raw);
31
+ }
32
+ return [...seen];
33
+ }
34
+ /** How much each part of the score can contribute. Sums to 1. */
35
+ const WEIGHTS = {
36
+ overlap: 0.6,
37
+ recency: 0.2,
38
+ verification: 0.2,
39
+ };
40
+ /** A memory older than this contributes no recency credit. */
41
+ const RECENCY_HORIZON_MS = 90 * 24 * 60 * 60 * 1000;
42
+ /**
43
+ * Score how relevant one memory is to a query.
44
+ *
45
+ * Overlap dominates because it is the only part grounded in the query. Recency is a
46
+ * tiebreaker, not a signal: a three-year-old decision that matches the query beats a
47
+ * fresh memory that does not. Verification credit is all-or-nothing — a memory either
48
+ * passed a check or it did not, and giving partial credit for "was checked once and has
49
+ * not been rechecked" would reward staleness.
50
+ */
51
+ export function scoreMemory(memory, tokens, now = Date.now()) {
52
+ const subject = normaliseSubject(`${memory.subject} ${memory.text}`);
53
+ const matched = tokens.filter((token) => subject.includes(token));
54
+ const overlap = tokens.length === 0 ? 0 : matched.length / tokens.length;
55
+ const age = Math.max(0, now - memory.createdAt);
56
+ const recency = Math.max(0, 1 - age / RECENCY_HORIZON_MS);
57
+ const verified = memory.verification?.outcome === 'passed' ? 1 : 0;
58
+ 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'}`;
60
+ return { score, matched, why };
61
+ }
62
+ /**
63
+ * Rank active memories against a query.
64
+ *
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.
68
+ */
69
+ export function recall(memories, query, limit = 5, now = Date.now()) {
70
+ const tokens = tokenize(query);
71
+ return memories
72
+ .filter((memory) => memory.status === 'active')
73
+ .map((memory) => ({ memory, ...scoreMemory(memory, tokens, now) }))
74
+ .filter((hit) => hit.matched.length > 0)
75
+ .sort((a, b) => b.score - a.score || b.memory.createdAt - a.memory.createdAt)
76
+ .slice(0, limit);
77
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Host routes for the panel.
3
+ *
4
+ * Registered on the host's web connection when one exists. A host running headless
5
+ * simply skips registration and every other part of the plugin keeps working — the
6
+ * panel is a view of the log, not a precondition for keeping it.
7
+ *
8
+ * @module context-memory/routes
9
+ */
10
+ import type { Context } from '@deepseek-ai/cordis';
11
+ import type { MemoryService } from './index.js';
12
+ /** The panel path, shared with the browser half so the two cannot drift apart. */
13
+ export declare const MEMORY_PANEL_PATH = "/api/context-memory.panel";
14
+ export declare function registerMemoryRoutes(ctx: Context, memory: MemoryService): void;