dsh-context-memory 0.7.0 → 0.9.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,71 @@
1
+ /**
2
+ * The candidate queue: statements worth offering, not worth remembering unasked.
3
+ *
4
+ * A capture rule can be generous or be safe. Explicit verbs ("记住…", "remember that…")
5
+ * are safe because the user asked; widening the patterns to ordinary declarative
6
+ * sentences is not, because the same shape produces "we use sqlite" and "we use two
7
+ * queues in this diagram". Writing both to the log would make recall worse than the
8
+ * narrower rule it replaced.
9
+ *
10
+ * So the wider rule produces a *question*. A candidate is proposed to the panel, and
11
+ * only a person turns it into a memory. The queue is append-only for the same reason the
12
+ * log is: "we offered this and you dismissed it" has to stay answerable, and a mutable
13
+ * queue would let a later run silently re-ask what was already declined.
14
+ *
15
+ * A dismissed candidate is remembered as dismissed rather than deleted, so the same
16
+ * sentence in the same session is not proposed again on every step.
17
+ *
18
+ * @module context-memory/candidates
19
+ */
20
+ import type { Candidate, CandidateState, MemoryKind } from './types.js';
21
+ /** What a caller proposes, before an id exists. */
22
+ export interface ProposeInput {
23
+ text: string;
24
+ kind?: MemoryKind;
25
+ sessionId?: string;
26
+ }
27
+ /** Enough of `ContextMemory` to answer "is this already remembered". */
28
+ export interface KnownMemories {
29
+ has(id: string): boolean;
30
+ }
31
+ export declare class CandidateQueue {
32
+ private readonly dir;
33
+ private readonly logPath;
34
+ /**
35
+ * The last parsed log, revalidated against the file's size and mtime on every read.
36
+ *
37
+ * Same reasoning as `MemoryStore`: the pre-step asks the queue one question per
38
+ * noticed statement (`propose` → `has`/`get`), and re-parsing an unchanged file for
39
+ * each of them is work that answers nothing new. A change on disk invalidates it, so
40
+ * two processes still agree.
41
+ */
42
+ private cached;
43
+ constructor(dir: string);
44
+ get path(): string;
45
+ private signature;
46
+ private append;
47
+ /**
48
+ * Fold the queue's log.
49
+ *
50
+ * The same shape as the memory fold, and the same reason: two processes reading one
51
+ * file must agree on which questions are still open. A decision naming a proposal that
52
+ * is not in the log is skipped rather than inventing a candidate.
53
+ *
54
+ * The returned array is shared with the cache: treat it as read-only.
55
+ */
56
+ all(): CandidateState[];
57
+ private parse;
58
+ pending(): CandidateState[];
59
+ get(id: string): CandidateState | undefined;
60
+ /**
61
+ * Offer a statement, unless it is already settled.
62
+ *
63
+ * Three suppressions, each for a different noise source: an active memory (the user
64
+ * already has it), a pending candidate (this step and the last one said the same
65
+ * thing), and a dismissed one (a question already answered — re-asking it every step
66
+ * is how a prompt becomes nagging).
67
+ */
68
+ propose(input: ProposeInput, known: KnownMemories): Candidate | undefined;
69
+ /** Record a decision. Returns false when there is no such open candidate. */
70
+ decide(id: string, status: 'accepted' | 'dismissed', at?: number): boolean;
71
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The candidate queue: statements worth offering, not worth remembering unasked.
3
+ *
4
+ * A capture rule can be generous or be safe. Explicit verbs ("记住…", "remember that…")
5
+ * are safe because the user asked; widening the patterns to ordinary declarative
6
+ * sentences is not, because the same shape produces "we use sqlite" and "we use two
7
+ * queues in this diagram". Writing both to the log would make recall worse than the
8
+ * narrower rule it replaced.
9
+ *
10
+ * So the wider rule produces a *question*. A candidate is proposed to the panel, and
11
+ * only a person turns it into a memory. The queue is append-only for the same reason the
12
+ * log is: "we offered this and you dismissed it" has to stay answerable, and a mutable
13
+ * queue would let a later run silently re-ask what was already declined.
14
+ *
15
+ * A dismissed candidate is remembered as dismissed rather than deleted, so the same
16
+ * sentence in the same session is not proposed again on every step.
17
+ *
18
+ * @module context-memory/candidates
19
+ */
20
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { memoryId, normaliseSubject } from './identity.js';
23
+ function candidateId(text) {
24
+ // Same derivation the memory uses, so a candidate and the memory it becomes answer
25
+ // "is this the same statement" the same way. Two derivations would drift.
26
+ return memoryId(normaliseSubject(text), text.trim());
27
+ }
28
+ export class CandidateQueue {
29
+ dir;
30
+ logPath;
31
+ /**
32
+ * The last parsed log, revalidated against the file's size and mtime on every read.
33
+ *
34
+ * Same reasoning as `MemoryStore`: the pre-step asks the queue one question per
35
+ * noticed statement (`propose` → `has`/`get`), and re-parsing an unchanged file for
36
+ * each of them is work that answers nothing new. A change on disk invalidates it, so
37
+ * two processes still agree.
38
+ */
39
+ cached;
40
+ constructor(dir) {
41
+ this.dir = dir;
42
+ this.logPath = join(dir, 'candidates.jsonl');
43
+ }
44
+ get path() {
45
+ return this.logPath;
46
+ }
47
+ signature() {
48
+ try {
49
+ const stats = statSync(this.logPath);
50
+ return `${stats.size}:${stats.mtimeMs}`;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ append(event) {
57
+ if (!existsSync(this.dir))
58
+ mkdirSync(this.dir, { recursive: true });
59
+ appendFileSync(this.logPath, `${JSON.stringify(event)}\n`, 'utf8');
60
+ this.cached = undefined;
61
+ }
62
+ /**
63
+ * Fold the queue's log.
64
+ *
65
+ * The same shape as the memory fold, and the same reason: two processes reading one
66
+ * file must agree on which questions are still open. A decision naming a proposal that
67
+ * is not in the log is skipped rather than inventing a candidate.
68
+ *
69
+ * The returned array is shared with the cache: treat it as read-only.
70
+ */
71
+ all() {
72
+ const key = this.signature();
73
+ if (this.cached !== undefined && this.cached.key === key)
74
+ return this.cached.value;
75
+ const value = key === null ? [] : this.parse();
76
+ this.cached = { key, value };
77
+ return value;
78
+ }
79
+ parse() {
80
+ const byId = new Map();
81
+ const lines = readFileSync(this.logPath, 'utf8').split('\n');
82
+ for (let index = 0; index < lines.length; index += 1) {
83
+ const line = lines[index].trim();
84
+ if (line.length === 0)
85
+ continue;
86
+ let event;
87
+ try {
88
+ event = JSON.parse(line);
89
+ }
90
+ catch {
91
+ // A torn final line is the expected shape after a crash; anything else is damage.
92
+ const rest = lines.slice(index + 1).every((remaining) => remaining.trim().length === 0);
93
+ if (rest)
94
+ break;
95
+ throw new Error(`${this.logPath}: line ${index + 1} is not valid JSON`);
96
+ }
97
+ if (event.type === 'propose') {
98
+ byId.set(event.candidate.id, { ...event.candidate, status: 'pending' });
99
+ continue;
100
+ }
101
+ const target = byId.get(event.id);
102
+ if (target)
103
+ target.status = event.status;
104
+ }
105
+ return [...byId.values()];
106
+ }
107
+ pending() {
108
+ return this.all().filter((candidate) => candidate.status === 'pending');
109
+ }
110
+ get(id) {
111
+ return this.all().find((candidate) => candidate.id === id);
112
+ }
113
+ /**
114
+ * Offer a statement, unless it is already settled.
115
+ *
116
+ * Three suppressions, each for a different noise source: an active memory (the user
117
+ * already has it), a pending candidate (this step and the last one said the same
118
+ * thing), and a dismissed one (a question already answered — re-asking it every step
119
+ * is how a prompt becomes nagging).
120
+ */
121
+ propose(input, known) {
122
+ const text = input.text.trim();
123
+ if (text.length === 0)
124
+ return undefined;
125
+ const id = candidateId(text);
126
+ if (known.has(id))
127
+ return undefined;
128
+ const existing = this.get(id);
129
+ if (existing !== undefined)
130
+ return undefined;
131
+ const candidate = {
132
+ id,
133
+ text,
134
+ kind: input.kind ?? 'fact',
135
+ proposedAt: Date.now(),
136
+ };
137
+ if (input.sessionId !== undefined)
138
+ candidate.sessionId = input.sessionId;
139
+ this.append({ type: 'propose', at: candidate.proposedAt, candidate });
140
+ return candidate;
141
+ }
142
+ /** Record a decision. Returns false when there is no such open candidate. */
143
+ decide(id, status, at = Date.now()) {
144
+ const target = this.get(id);
145
+ if (target === undefined || target.status !== 'pending')
146
+ return false;
147
+ this.append({ type: 'decide', at, id, status });
148
+ return true;
149
+ }
150
+ }
package/lib/capture.d.ts CHANGED
@@ -16,11 +16,32 @@
16
16
  *
17
17
  * @module context-memory/capture
18
18
  */
19
+ import type { MemoryKind } from './types.js';
19
20
  import type { RememberInput } from './memory.js';
20
21
  /** One distilled statement ready for `memory.remember()`. */
21
22
  export type DistilledMemory = RememberInput & {
22
23
  sessionId: string;
23
24
  };
25
+ /** A statement worth *offering*, not worth remembering unasked. */
26
+ export interface CapturedCandidate {
27
+ text: string;
28
+ kind: MemoryKind;
29
+ sessionId: string;
30
+ }
31
+ /**
32
+ * Statements that look like durable facts but were not asked to be kept.
33
+ *
34
+ * These are deliberately **not** memories. The rules below are heuristics — "we use
35
+ * sqlite" reads like a fact, and so does "we use two queues" or a sentence quoted from
36
+ * somewhere else — and a heuristic that writes to the log is a heuristic that fills the
37
+ * log with things the user never said they wanted. A candidate is a question put to the
38
+ * user, which is the cheapest way to be generous without eroding what recall is worth.
39
+ *
40
+ * The bar is set by `technicalSignal`: without a token that looks like a name — `pnpm`,
41
+ * `node_modules`, `MySQL`, `@scope/pkg` — a sentence is ordinary conversation, and
42
+ * "今天用两个服务" must not become a memory.
43
+ */
44
+ export declare function candidates(text: string, sessionId: string, explicit?: readonly string[]): CapturedCandidate[];
24
45
  /**
25
46
  * Extract remember-worthy statements from one incoming user message.
26
47
  *
package/lib/capture.js CHANGED
@@ -16,6 +16,92 @@
16
16
  *
17
17
  * @module context-memory/capture
18
18
  */
19
+ /**
20
+ * Statements that look like durable facts but were not asked to be kept.
21
+ *
22
+ * These are deliberately **not** memories. The rules below are heuristics — "we use
23
+ * sqlite" reads like a fact, and so does "we use two queues" or a sentence quoted from
24
+ * somewhere else — and a heuristic that writes to the log is a heuristic that fills the
25
+ * log with things the user never said they wanted. A candidate is a question put to the
26
+ * user, which is the cheapest way to be generous without eroding what recall is worth.
27
+ *
28
+ * The bar is set by `technicalSignal`: without a token that looks like a name — `pnpm`,
29
+ * `node_modules`, `MySQL`, `@scope/pkg` — a sentence is ordinary conversation, and
30
+ * "今天用两个服务" must not become a memory.
31
+ */
32
+ export function candidates(text, sessionId, explicit = []) {
33
+ const already = new Set(explicit.map((statement) => statement.trim().toLowerCase()));
34
+ const seen = new Set();
35
+ const out = [];
36
+ for (const sentence of sentenceCandidates(text)) {
37
+ const normalized = sentence.text.trim();
38
+ if (normalized.length < 6 || normalized.length > 160)
39
+ continue;
40
+ const key = normalized.toLowerCase();
41
+ // An explicit "记住…" in the same message already covers this sentence; proposing it
42
+ // again would ask the user to confirm something they just asked for.
43
+ if (already.has(key) || seen.has(key))
44
+ continue;
45
+ seen.add(key);
46
+ out.push({ text: normalized, kind: sentence.kind, sessionId });
47
+ // Three per message is a ceiling, not a target: a message that trips four patterns is
48
+ // usually a message about something else, and a panel that offers ten rows at once
49
+ // gets ignored exactly like one that offers none.
50
+ if (out.length >= 3)
51
+ break;
52
+ }
53
+ return out;
54
+ }
55
+ /**
56
+ * A token that looks like a technology, a package, or a path.
57
+ *
58
+ * This is what separates "we use pnpm for this repo" from "我们用两个队列". Both are
59
+ * declarative; only the first names something a future reader could act on. Requiring an
60
+ * ASCII token of at least two characters is crude and deliberately so: a vocabulary list
61
+ * would go stale, and semantic judgment is the model's job, not a regex's.
62
+ */
63
+ const TECHNICAL_SIGNAL = /[A-Za-z][A-Za-z0-9_.\-+]*[A-Za-z0-9]|\/[A-Za-z0-9_.-]+\//;
64
+ /** A question is not a statement, whatever else it contains. */
65
+ const QUESTION = /(?:\?|?)\s*$|(?:吗|么|呢)\s*[。.]?\s*$/;
66
+ /** Openers that make a sentence an instruction to the agent, not a fact about the world. */
67
+ const DIRECTIVE = /^(?:please\b|帮(?:我|忙)|请|麻烦|看一下|看看|检查|修改|修复|添加|实现|重构|运行|跑一下|add\b|fix\b|check\b|look\b|show\b|run\b|update\b|refactor\b|implement\b)/i;
68
+ /** Sentence shapes that assert something stable about the project or the user. */
69
+ const SHAPES = [
70
+ // English: we use X / this project builds with X / I rely on X
71
+ { pattern: /^(?:we|i|this project|the project|our team)\s+(?:use|uses|build with|builds with|rely on|relies on|depend on|depends on|test with|tests with)\s+/i, kind: 'fact' },
72
+ // English: we always/never/prefer X — a standing preference or a rule.
73
+ { pattern: /^(?:we|i)\s+(?:always|never|prefer|avoid|stick to)\b/i, kind: 'preference' },
74
+ { pattern: /^(?:don'?t|do not|never)\s+(?:use|commit|add|write)\b/i, kind: 'pitfall' },
75
+ { pattern: /^the\s+(?:build|test suite|package manager|project)\s+(?:uses|runs|is)\b/i, kind: 'fact' },
76
+ // Chinese: 我们用 X / 本项目用 X 构建 / 团队统一用 X
77
+ { pattern: /^(?:我们|我|团队|本项目|这个项目|该项目)?\s*(?:都|一直|统一|一律)?\s*用\s*\S/i, kind: 'fact' },
78
+ { pattern: /^(?:我们|我|团队|本项目|这个项目|该项目)?\s*(?:都|统一)?\s*(?:用|使用)\S+\s*(?:来)?\s*(?:构建|打包|管理|存|跑|测试|开发|部署|做)/, kind: 'fact' },
79
+ // Chinese: X 是 (我们的) 构建工具 / 包管理器 / 数据库 …
80
+ { pattern: /是(?:我们的|本项目的|这个项目的)?(?:构建工具|包管理器|测试框架|数据库|持久化|运行时|框架|语言)/, kind: 'fact' },
81
+ // Chinese: 我们习惯 / 偏好 / 倾向
82
+ { pattern: /^(?:我们|我)(?:习惯|倾向|偏好|喜欢|不喜欢)/, kind: 'preference' },
83
+ // Chinese: 不要用 X / 别碰 X / 禁止 X
84
+ { pattern: /^(?:不要|别|禁止|不能|不许)(?:再|直接)?\s*(?:用|使用|碰|提交|提交到|写|引入)/, kind: 'pitfall' },
85
+ ];
86
+ function sentenceCandidates(text) {
87
+ const out = [];
88
+ for (const raw of text.split(/(?<=[。!?!?\n])/)) {
89
+ const sentence = raw.trim().replace(/[。.!!]+$/, '').trim();
90
+ if (sentence.length === 0)
91
+ continue;
92
+ if (QUESTION.test(raw.trim()))
93
+ continue;
94
+ if (DIRECTIVE.test(sentence))
95
+ continue;
96
+ if (!TECHNICAL_SIGNAL.test(sentence))
97
+ continue;
98
+ const shape = SHAPES.find((candidate) => candidate.pattern.test(sentence));
99
+ if (shape === undefined)
100
+ continue;
101
+ out.push({ text: sentence, kind: shape.kind });
102
+ }
103
+ return out;
104
+ }
19
105
  /**
20
106
  * Extract remember-worthy statements from one incoming user message.
21
107
  *
package/lib/cli.js CHANGED
@@ -7,7 +7,9 @@
7
7
  * @module context-memory/cli
8
8
  */
9
9
  import { parseArgs } from 'node:util';
10
+ import { describeContradiction, describeGuard } from './guard.js';
10
11
  import { ContextMemory } from './memory.js';
12
+ import { resolveProjectStore, userStoreDir } from './scope.js';
11
13
  export function run(argv) {
12
14
  const { positionals, values } = parseArgs({
13
15
  args: argv,
@@ -15,28 +17,38 @@ export function run(argv) {
15
17
  options: {
16
18
  subject: { type: 'string' },
17
19
  kind: { type: 'string' },
20
+ scope: { type: 'string' },
18
21
  source: { type: 'string' },
22
+ global: { type: 'boolean' },
19
23
  },
20
24
  });
21
- const memory = new ContextMemory(process.cwd());
25
+ // The CLI runs outside a session, so its launch directory is the only working
26
+ // directory it can know. `resolveProjectStore` still climbs to the project root, which
27
+ // is what makes `cd packages/x && dsh-context-memory list` see the same log as the root.
28
+ const root = resolveProjectStore();
29
+ const memory = new ContextMemory(root, { userDir: userStoreDir() });
22
30
  const cmd = positionals[0] ?? 'list';
23
31
  switch (cmd) {
24
32
  case 'remember': {
25
33
  const text = positionals.slice(1).join(' ');
26
34
  if (text.trim().length === 0) {
27
- console.log('Usage: dsh-context-memory remember "<statement>" [--subject <what it is about>]');
35
+ console.log('Usage: dsh-context-memory remember "<statement>" [--subject <what it is about>] [--global]');
28
36
  return 1;
29
37
  }
38
+ // `--global` records a working preference that follows the user across projects;
39
+ // the default is the narrow claim, because most statements are about one checkout.
40
+ const scope = values['global'] === true || values['scope'] === 'user' ? 'user' : 'project';
30
41
  // `--subject` is what makes a conflict expressible from the command line: two
31
42
  // different statements about one thing stay separate records instead of folding.
32
43
  const stored = memory.remember({
33
44
  text,
45
+ scope,
34
46
  ...(values['subject'] !== undefined ? { subject: String(values['subject']) } : {}),
35
47
  ...(values['kind'] !== undefined ? { kind: String(values['kind']) } : {}),
36
48
  ...(values['source'] !== undefined ? { source: String(values['source']) } : {}),
37
49
  });
38
- const guard = stored.guard ? ` (guard: ${stored.guard.command})` : ' (no check could be compiled)';
39
- console.log(`Remembered ${stored.id}: ${stored.text}${guard}`);
50
+ const guard = stored.guard ? ` (check: ${describeGuard(stored.guard)})` : ' (no check could be compiled)';
51
+ console.log(`Remembered ${stored.id} [${stored.scope}]: ${stored.text}${guard}`);
40
52
  // A memory with no executable check is recorded, and the caller is told plainly
41
53
  // that nothing will verify it — rather than reporting a success that implies more.
42
54
  return 0;
@@ -48,8 +60,16 @@ export function run(argv) {
48
60
  return 0;
49
61
  }
50
62
  for (const item of active) {
51
- const mark = item.verification?.outcome === 'passed' ? '✓' : item.verification ? '✗' : '·';
52
- console.log(`${mark} ${item.id} ${item.text}`);
63
+ const mark = item.verification?.outcome === 'passed'
64
+ ? '✓'
65
+ : item.verification?.outcome === 'violated'
66
+ ? '!'
67
+ : item.verification ? '✗' : '·';
68
+ console.log(`${mark} ${item.id} [${item.scope ?? 'project'}] ${item.text}`);
69
+ }
70
+ const contradicted = active.filter((item) => item.verification?.outcome === 'violated');
71
+ if (contradicted.length > 0) {
72
+ console.log(`\n${contradicted.length} memor${contradicted.length === 1 ? 'y' : 'ies'} contradicted by a check — not offered as guidance.`);
53
73
  }
54
74
  return 0;
55
75
  }
@@ -78,8 +98,12 @@ export function run(argv) {
78
98
  console.log('A reason is required: a superseded memory is kept as the record of why it stopped being true.');
79
99
  return 1;
80
100
  }
81
- const ok = memory.forget(id, reason);
82
- console.log(ok ? `Superseded ${id}: ${reason}` : `No memory with id ${id}`);
101
+ // `--scope` disambiguates the one case the id alone cannot: the same sentence held
102
+ // in both logs has one id and two rows, and retiring "the" memory would be whichever
103
+ // log the fold listed first.
104
+ const scope = values['scope'] === 'user' || values['scope'] === 'project' ? values['scope'] : undefined;
105
+ const ok = memory.forget(id, reason, scope === undefined ? {} : { scope });
106
+ console.log(ok ? `Superseded ${id}: ${reason}` : `No memory with id ${id}${scope === undefined ? '' : ` in the ${scope} log`}`);
83
107
  return ok ? 0 : 1;
84
108
  }
85
109
  case 'conflicts': {
@@ -103,6 +127,13 @@ export function run(argv) {
103
127
  if (result.outcome === 'broken' && result.output.trim().length > 0) {
104
128
  console.log(` the check could not run: ${result.output.trim().split('\n')[0]}`);
105
129
  }
130
+ // A check that ran and came back false reports no output of its own; without the
131
+ // operand it tested, "violated" is a verdict the reader cannot act on. The guard
132
+ // rides along on the result — looking it up here would re-fold the whole log once
133
+ // per violated memory, and the number of those is unbounded.
134
+ if (result.outcome === 'violated') {
135
+ console.log(` contradicted: ${describeContradiction(result.guard, result.output)}`);
136
+ }
106
137
  }
107
138
  console.log(`\npassed ${summary.passed}, violated ${summary.violated}, broken ${summary.broken}, uncheckable ${summary.uncheckable}`);
108
139
  if (summary.broken > 0) {
@@ -110,19 +141,67 @@ export function run(argv) {
110
141
  }
111
142
  return ContextMemory.exitCode(summary);
112
143
  }
144
+ case 'candidates': {
145
+ const pending = memory.candidates();
146
+ if (pending.length === 0) {
147
+ console.log('No candidates waiting. (These come from ordinary declarative statements; they are never remembered without being accepted.)');
148
+ return 0;
149
+ }
150
+ for (const candidate of pending) {
151
+ console.log(`${candidate.id} [${candidate.kind}] ${candidate.text}`);
152
+ }
153
+ console.log(`\n${pending.length} candidate${pending.length === 1 ? '' : 's'} — accept with \`accept <id>\`, decline with \`dismiss <id>\`.`);
154
+ return 0;
155
+ }
156
+ case 'accept': {
157
+ const id = positionals[1] ?? '';
158
+ if (id.length === 0) {
159
+ console.log('Usage: dsh-context-memory accept <candidate id> [--kind <kind>] [--global]');
160
+ return 1;
161
+ }
162
+ const scope = values['global'] === true || values['scope'] === 'user' ? 'user' : 'project';
163
+ const stored = memory.accept(id, {
164
+ scope,
165
+ ...(values['kind'] !== undefined ? { kind: String(values['kind']) } : {}),
166
+ });
167
+ if (stored === undefined) {
168
+ console.log(`No pending candidate with id ${id}`);
169
+ return 1;
170
+ }
171
+ console.log(`Remembered ${stored.id}: ${stored.text}`);
172
+ return 0;
173
+ }
174
+ case 'dismiss': {
175
+ const id = positionals[1] ?? '';
176
+ if (id.length === 0) {
177
+ console.log('Usage: dsh-context-memory dismiss <candidate id>');
178
+ return 1;
179
+ }
180
+ const ok = memory.dismiss(id);
181
+ console.log(ok ? `Dismissed ${id}` : `No pending candidate with id ${id}`);
182
+ return ok ? 0 : 1;
183
+ }
113
184
  case 'help':
114
185
  default:
115
186
  console.log('Usage: dsh-context-memory <command>');
116
187
  console.log('Commands:');
117
188
  console.log(' remember "<statement>" record a memory and compile a check if possible');
118
189
  console.log(' --subject <what it is about> make a later disagreeing statement a conflict');
190
+ console.log(' --scope <project|user> which log to write (default project)');
191
+ console.log(' --global shorthand for --scope user');
119
192
  console.log(' --kind <fact|decision|preference|pitfall>');
120
193
  console.log(' --source <user|agent|plugin>');
121
- console.log(' list active memories, with verification status');
194
+ console.log(' list active memories, with scope and verification status');
122
195
  console.log(' recall "<query>" rank memories by relevance');
123
196
  console.log(' forget <id> "<reason>" retire a memory, keeping the reason');
124
197
  console.log(' conflicts active memories that disagree');
198
+ console.log(' candidates statements noticed but not remembered');
199
+ console.log(' accept <candidate id> turn a candidate into a memory');
200
+ console.log(' dismiss <candidate id> decline a candidate, keeping the answer');
125
201
  console.log(' verify re-run every compiled check; exit 1 only on a real violation');
202
+ console.log(`\nproject log: ${memory.locations.project}`);
203
+ if (memory.locations.user)
204
+ console.log(`user log: ${memory.locations.user}`);
126
205
  return 0;
127
206
  }
128
207
  }
@@ -8,30 +8,24 @@
8
8
  *
9
9
  * @module client
10
10
  */
11
- import type { ComponentType } from 'react';
11
+ import type { Context } from '@deepseek-ai/cordis';
12
+ import type { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client';
13
+ import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client';
14
+ import { zh } from './locales.js';
12
15
  export declare const inject: string[];
13
- interface SlotsService {
14
- inject(name: string, register: () => void): void;
15
- register(options: {
16
- name: string;
17
- id: string;
18
- order: number;
19
- label: () => string;
20
- locale: string;
21
- }, component: ComponentType<{
22
- t: (key: string, params?: Record<string, unknown>) => string;
23
- }>): unknown;
24
- }
25
- interface ClientContext {
26
- slots: SlotsService;
27
- locale: {
28
- register(ns: string, dicts: {
29
- zh: unknown;
30
- en: unknown;
31
- }): () => void;
32
- bind(ns: string): (key: string, params?: Record<string, unknown>) => string;
33
- };
34
- effect(callback: () => unknown, label?: string): unknown;
16
+ type ClientContext = Context & {
17
+ slots: SlotRegistry;
18
+ locale: LocaleRuntime;
19
+ };
20
+ /**
21
+ * Declaring the namespace is what lets the registration below name it: the host's
22
+ * `LocaleNamespaceMap` is merge-extensible and `locale: NS` is checked against it,
23
+ * so an undeclared namespace does not type-check at all.
24
+ */
25
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
26
+ interface LocaleNamespaceMap {
27
+ contextMemory: keyof typeof zh;
28
+ }
35
29
  }
36
30
  export declare function apply(ctx: ClientContext): void;
37
31
  export {};
@@ -50,6 +50,17 @@ export declare const zh: {
50
50
  integration: string;
51
51
  autoCaptureLabel: string;
52
52
  recallInjectLabel: string;
53
+ memoryAdded: string;
54
+ memoryTextPlaceholder: string;
55
+ subjectPlaceholder: string;
56
+ forgotten: string;
57
+ confirmForget: string;
58
+ candidates: string;
59
+ candidatesHint: string;
60
+ accept: string;
61
+ dismiss: string;
62
+ candidateAccepted: string;
63
+ candidateDismissed: string;
53
64
  };
54
65
  /** English dictionary, checked complete against the zh key set. */
55
66
  export declare const en: typeof zh;
@@ -50,6 +50,17 @@ export const zh = {
50
50
  'integration': '集成',
51
51
  'autoCaptureLabel': '自动捕获:用户消息里的「记住…」「以后…」自动入库',
52
52
  'recallInjectLabel': '召回注入:每次请求前把相关记忆折进上下文',
53
+ 'memoryAdded': '已记录',
54
+ 'memoryTextPlaceholder': '要记住的内容,一句话说清',
55
+ 'subjectPlaceholder': '留空则自动取主语',
56
+ 'forgotten': '已遗忘',
57
+ 'confirmForget': '这条记忆会被删除,且不再参与召回。',
58
+ 'candidates': '待确认',
59
+ 'candidatesHint': '这些是你说过、但没让我记的话。确认后才会进召回,不确认就一直留在这里。',
60
+ 'accept': '记下',
61
+ 'dismiss': '不用',
62
+ 'candidateAccepted': '已记下',
63
+ 'candidateDismissed': '已忽略',
53
64
  };
54
65
  /** English dictionary, checked complete against the zh key set. */
55
66
  export const en = {
@@ -92,4 +103,15 @@ export const en = {
92
103
  'integration': 'Integration',
93
104
  'autoCaptureLabel': 'Auto capture: "remember that…" statements land in the log',
94
105
  'recallInjectLabel': 'Recall injection: fold relevant memories into each request',
106
+ 'memoryAdded': 'Memory added',
107
+ 'memoryTextPlaceholder': 'What to remember, in one sentence',
108
+ 'subjectPlaceholder': 'Leave empty to infer the subject',
109
+ 'forgotten': 'Forgotten',
110
+ 'confirmForget': 'This memory is deleted and no longer recalled.',
111
+ 'candidates': 'Suggested',
112
+ 'candidatesHint': 'Things you said that were not asked to be remembered. They only enter recall once you accept them.',
113
+ 'accept': 'Remember it',
114
+ 'dismiss': 'No thanks',
115
+ 'candidateAccepted': 'Remembered',
116
+ 'candidateDismissed': 'Dismissed',
95
117
  };
@@ -2,12 +2,19 @@
2
2
  * Pure rendering half of the contextMemory page. Uses shared UI kit.
3
3
  * @module client/view
4
4
  */
5
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
5
6
  import type { ReactNode } from 'react';
6
7
  import type { MemoryState } from '../types.js';
7
- export type Translate = (key: string, params?: Record<string, unknown>) => string;
8
+ /**
9
+ * The translate seat the host injects for this plugin's namespace, taken from the
10
+ * official type so the panel and its registration agree on one key domain.
11
+ */
12
+ export type Translate = TranslateNS<'contextMemory'>;
8
13
  export interface PanelProps {
9
14
  t: Translate;
10
15
  }
11
16
  export declare function checkLabel(memory: MemoryState, t: Translate): string;
12
17
  export declare function provenance(memory: MemoryState): string;
18
+ /** A row key that is unique across scopes, where the id alone is not. */
19
+ export declare function memoryKey(memory: MemoryState): string;
13
20
  export declare function ContextMemoryPanel({ t }: PanelProps): ReactNode;