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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/cordis.patch.yml +6 -0
- package/lib/bin.d.ts +2 -0
- package/lib/bin.js +3 -0
- package/lib/cli.d.ts +9 -0
- package/lib/cli.js +128 -0
- package/lib/client/index.d.ts +13 -0
- package/lib/client/index.js +32 -0
- package/lib/client/view.d.ts +20 -0
- package/lib/client/view.js +64 -0
- package/lib/guard.d.ts +66 -0
- package/lib/guard.js +139 -0
- package/lib/identity.d.ts +36 -0
- package/lib/identity.js +56 -0
- package/lib/index.d.ts +25 -0
- package/lib/index.js +28 -0
- package/lib/memory.d.ts +92 -0
- package/lib/memory.js +200 -0
- package/lib/memory.web.js +60 -0
- package/lib/recall.d.ts +40 -0
- package/lib/recall.js +77 -0
- package/lib/routes.d.ts +14 -0
- package/lib/routes.js +41 -0
- package/lib/store.d.ts +30 -0
- package/lib/store.js +62 -0
- package/lib/types.d.ts +114 -0
- package/lib/types.js +12 -0
- package/package.json +45 -0
|
@@ -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;
|
package/lib/identity.js
ADDED
|
@@ -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
|
+
}
|
package/lib/memory.d.ts
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
};
|
package/lib/recall.d.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/routes.d.ts
ADDED
|
@@ -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;
|