@fonlan/dsh-gitmemo 0.4.0 → 0.4.1

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,283 @@
1
+ /**
2
+ * dsh-gitmemo — System-one recall gate.
3
+ *
4
+ * Optional narrowing stage for `mem_search`. After the engine produces a page
5
+ * of lexical candidates, a System-one model (TypeSafe Jev by default, or any
6
+ * endpoint speaking the same request/answer contract) is asked one typed
7
+ * question per candidate, in a single request. Candidates the model judges
8
+ * irrelevant are dropped from the page the root agent sees, which cuts the
9
+ * tokens injected into the conversation.
10
+ *
11
+ * Design rules (deliberate, see README):
12
+ * - FAIL-OPEN: the gate may only ever REMOVE noise. Any transport, HTTP,
13
+ * timeout, or parse failure keeps every candidate and reports `degraded`,
14
+ * so a broken or unconfigured endpoint can never hide a memory.
15
+ * - NEVER EMPTY: when the gate would drop every candidate on a page, the
16
+ * `minKeep` best-ranked candidates are retained. A filter that silently
17
+ * returns nothing is indistinguishable from "no memory exists", which is
18
+ * the one failure mode a recall path must not have.
19
+ * - AUDITABLE: per-candidate values and the keep/drop verdict are returned in
20
+ * the tool output's `gated` block, so the decision can be inspected rather
21
+ * than trusted.
22
+ *
23
+ * This module imports nothing from the harness and performs no I/O of its own
24
+ * beyond `fetch`, so it is unit-testable with a fake fetch implementation.
25
+ *
26
+ * @module dsh-gitmemo/systemone
27
+ */
28
+ /** Score levels offered to the model in `score` mode (ordered low to high). */
29
+ export const SCORE_LEVELS = [
30
+ "unrelated to the task",
31
+ "same general area but not actionable for this task",
32
+ "partially relevant background",
33
+ "substantially relevant and worth reusing",
34
+ "directly reusable conclusions for this exact task"
35
+ ];
36
+ /**
37
+ * The judgement asked of the model, verbatim. Kept in English on purpose:
38
+ * Jev's primary training language is English and the vendor documents lower
39
+ * accuracy for other scripts, so only the (possibly CJK) candidate text and
40
+ * task text are non-English.
41
+ */
42
+ export const NOUL_INSTRUCTION = "Does the `candidate` memory record contain conclusions, decisions, constraints, or reusable findings that are directly relevant to the `task`? Answer yes only if reading that memory would inform or change how the task is carried out. Answer no when it is merely on a related topic, is superseded, or concerns a different area.";
43
+ /**
44
+ * Clarifies what yes and no mean for {@link NOUL_INSTRUCTION}.
45
+ *
46
+ * A noul `criteria` is an object with exactly `true` and `false` keys — a bare
47
+ * string is rejected by the endpoint's validation, so this shape is required.
48
+ */
49
+ export const NOUL_CRITERIA = {
50
+ true: "yes — the memory is directly reusable for this task",
51
+ false: "no — unrelated, superseded, or only loosely related to this task"
52
+ };
53
+ /** The judgement asked of the model in `score` mode. */
54
+ export const SCORE_INSTRUCTION = "How relevant is the `candidate` memory record to the `task`? Rate how reusable its conclusions, decisions, constraints, or findings are for this exact task.";
55
+ /** Trim a candidate summary before it is sent to the model. */
56
+ function clamp(text, limit) {
57
+ const collapsed = text.replace(/\s+/g, " ").trim();
58
+ return collapsed.length > limit ? collapsed.slice(0, limit) + "…" : collapsed;
59
+ }
60
+ /**
61
+ * Build the System-one request body: the task is the shared `state`, and each
62
+ * candidate travels inside its own question's structured `instructions`, so
63
+ * every candidate is judged against the same task in one request and no
64
+ * candidate payload is repeated across questions.
65
+ *
66
+ * @param task - the agent's current task, used as the shared state.
67
+ * @param candidates - candidates to judge, already capped by the caller.
68
+ * @param options - resolved gate settings.
69
+ * @returns a JSON-serializable request body.
70
+ */
71
+ export function buildRequest(task, candidates, options) {
72
+ const questions = {};
73
+ const taskText = clamp(task, options.maxTaskChars);
74
+ candidates.forEach((candidate, index) => {
75
+ const payload = {
76
+ title: candidate.title,
77
+ summary: clamp(candidate.summary, 1200),
78
+ keywords: candidate.keywords,
79
+ kind: candidate.kind
80
+ };
81
+ questions["m" + index] =
82
+ options.mode === "noul"
83
+ ? {
84
+ type: "noul",
85
+ criteria: NOUL_CRITERIA,
86
+ instructions: { question: NOUL_INSTRUCTION, candidate: payload }
87
+ }
88
+ : {
89
+ type: "score",
90
+ criteria: [...SCORE_LEVELS],
91
+ instructions: { question: SCORE_INSTRUCTION, candidate: payload }
92
+ };
93
+ });
94
+ return { state: { task: taskText }, model: options.model, questions };
95
+ }
96
+ /**
97
+ * Extract one numeric answer per question id from a System-one response.
98
+ *
99
+ * Tolerates both documented envelope shapes (a top-level map of answers, or
100
+ * the same map nested under `answers`) and both `noul`/`score` field spellings,
101
+ * so an endpoint that is merely contract-compatible still works.
102
+ *
103
+ * @param payload - parsed response JSON.
104
+ * @param mode - the question type that was asked.
105
+ * @param count - number of questions sent (ids are `m0`..`m<count-1>`).
106
+ * @returns the value per question index, or undefined where absent/unreadable.
107
+ */
108
+ export function parseAnswers(payload, mode, count) {
109
+ const envelope = payload;
110
+ const map = envelope !== null && typeof envelope === "object" && envelope.answers !== undefined
111
+ ? envelope.answers
112
+ : payload;
113
+ const record = (map ?? {});
114
+ const values = [];
115
+ for (let index = 0; index < count; index += 1) {
116
+ const answer = (record["m" + index] ?? {});
117
+ const raw = mode === "noul" ? (answer.noul ?? answer.probability) : answer.score;
118
+ const value = typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
119
+ values.push(value);
120
+ }
121
+ return values;
122
+ }
123
+ /**
124
+ * Read the endpoint's token accounting.
125
+ *
126
+ * @param payload - parsed response JSON.
127
+ * @returns camel-cased usage, or undefined when the endpoint reported none.
128
+ */
129
+ export function parseUsage(payload) {
130
+ const usage = payload?.usage;
131
+ if (usage === null || typeof usage !== "object")
132
+ return undefined;
133
+ const record = usage;
134
+ const input = typeof record.input_tokens === "number" ? record.input_tokens : undefined;
135
+ const output = typeof record.output_tokens === "number" ? record.output_tokens : undefined;
136
+ if (input === undefined && output === undefined)
137
+ return undefined;
138
+ return {
139
+ ...(input === undefined ? {} : { inputTokens: input }),
140
+ ...(output === undefined ? {} : { outputTokens: output })
141
+ };
142
+ }
143
+ /**
144
+ * Turn per-candidate values into a keep/drop decision.
145
+ *
146
+ * Applies the configured threshold, then the `minKeep` floor that guarantees a
147
+ * non-empty page whenever candidates existed. Candidates with a missing or
148
+ * unreadable answer are KEPT (fail-open at the per-candidate level).
149
+ *
150
+ * @param candidates - candidates in page order.
151
+ * @param values - value per candidate index (undefined when unreadable).
152
+ * @param options - resolved gate settings.
153
+ * @returns kept/dropped hash lists and per-candidate verdicts.
154
+ */
155
+ export function decide(candidates, values, options) {
156
+ const cutoff = options.mode === "noul" ? options.threshold : options.scoreMin;
157
+ const verdicts = candidates.map((candidate, index) => {
158
+ const value = values[index];
159
+ if (value === undefined)
160
+ return { hash: candidate.hash, value: null, keep: true };
161
+ return { hash: candidate.hash, value, keep: value >= cutoff };
162
+ });
163
+ let kept = verdicts.filter((v) => v.keep).map((v) => v.hash);
164
+ let floored = false;
165
+ if (kept.length === 0 && candidates.length > 0 && options.minKeep > 0) {
166
+ const ranked = verdicts
167
+ .map((verdict, index) => ({ verdict, index }))
168
+ .sort((a, b) => {
169
+ const av = a.verdict.value ?? Number.NEGATIVE_INFINITY;
170
+ const bv = b.verdict.value ?? Number.NEGATIVE_INFINITY;
171
+ return bv - av || a.index - b.index;
172
+ })
173
+ .slice(0, Math.min(options.minKeep, candidates.length));
174
+ const promoted = new Set(ranked.map((entry) => entry.verdict.hash));
175
+ for (const verdict of verdicts) {
176
+ if (promoted.has(verdict.hash))
177
+ verdict.keep = true;
178
+ }
179
+ kept = verdicts.filter((v) => v.keep).map((v) => v.hash);
180
+ floored = true;
181
+ }
182
+ const keptSet = new Set(kept);
183
+ return {
184
+ kept,
185
+ dropped: candidates.map((c) => c.hash).filter((hash) => !keptSet.has(hash)),
186
+ verdicts,
187
+ floored
188
+ };
189
+ }
190
+ /** Combine a caller signal with a timeout, keeping both cancellation sources. */
191
+ function withTimeout(signal, timeoutMs) {
192
+ const timeout = AbortSignal.timeout(timeoutMs);
193
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
194
+ }
195
+ /**
196
+ * Evaluate the gate for one search page.
197
+ *
198
+ * Never throws and never returns an empty `kept` list while candidates exist:
199
+ * every failure path degrades to "keep everything" with a reason.
200
+ *
201
+ * @param task - the agent's current task text (shared state).
202
+ * @param candidates - page candidates; only the first `maxCandidates` are judged.
203
+ * @param options - resolved gate settings.
204
+ * @param signal - caller cancellation.
205
+ * @returns the decision to apply to the page.
206
+ */
207
+ export async function evaluateGate(task, candidates, options, signal) {
208
+ const mode = options.mode;
209
+ const base = {
210
+ mode,
211
+ model: options.model
212
+ };
213
+ if (candidates.length === 0) {
214
+ return { ...base, kept: [], dropped: [], verdicts: [], judged: 0, untouched: 0, degraded: false };
215
+ }
216
+ const judged = candidates.slice(0, Math.max(1, options.maxCandidates));
217
+ const untouched = candidates.slice(judged.length);
218
+ const keepAll = (reason) => ({
219
+ ...base,
220
+ kept: candidates.map((c) => c.hash),
221
+ dropped: [],
222
+ verdicts: candidates.map((c) => ({ hash: c.hash, value: null, keep: true })),
223
+ judged: judged.length,
224
+ untouched: untouched.length,
225
+ degraded: true,
226
+ reason
227
+ });
228
+ let apiKey;
229
+ try {
230
+ apiKey =
231
+ options.apiKey !== undefined && options.apiKey.length > 0
232
+ ? options.apiKey
233
+ : await options.resolveApiKey?.();
234
+ }
235
+ catch {
236
+ apiKey = undefined;
237
+ }
238
+ if (apiKey === undefined || apiKey.length === 0) {
239
+ return keepAll("no API key configured for the system-one endpoint");
240
+ }
241
+ const body = buildRequest(task, judged, options);
242
+ let payload;
243
+ try {
244
+ const response = await (options.fetchImpl ?? fetch)(options.endpoint, {
245
+ method: "POST",
246
+ headers: {
247
+ "content-type": "application/json",
248
+ authorization: "Bearer " + apiKey
249
+ },
250
+ body: JSON.stringify(body),
251
+ signal: withTimeout(signal, options.timeoutMs)
252
+ });
253
+ if (!response.ok) {
254
+ return keepAll("system-one endpoint returned HTTP " + response.status);
255
+ }
256
+ payload = await response.json();
257
+ }
258
+ catch (error) {
259
+ const detail = error instanceof Error ? error.message : String(error);
260
+ return keepAll("system-one request failed: " + detail);
261
+ }
262
+ const values = parseAnswers(payload, mode, judged.length);
263
+ const decision = decide(judged, values, options);
264
+ // Candidates past `maxCandidates` were never evaluated, so they are never
265
+ // removed: the gate may only drop what it actually judged. Leaving them out
266
+ // of the kept set would silently truncate every page larger than the cap.
267
+ const keptSet = new Set([...decision.kept, ...untouched.map((c) => c.hash)]);
268
+ const usage = parseUsage(payload);
269
+ return {
270
+ ...base,
271
+ kept: [...candidates.map((c) => c.hash).filter((hash) => keptSet.has(hash))],
272
+ dropped: decision.dropped,
273
+ verdicts: [
274
+ ...decision.verdicts,
275
+ ...untouched.map((c) => ({ hash: c.hash, value: null, keep: true }))
276
+ ],
277
+ judged: judged.length,
278
+ untouched: untouched.length,
279
+ degraded: false,
280
+ ...(usage === undefined ? {} : { usage }),
281
+ ...(decision.floored ? { reason: "no candidate scored above the threshold; retained top " + decision.kept.length } : {})
282
+ };
283
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-gitmemo",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo (single main branch, immutable entries, structured commit messages) with mem_search / mem_read / mem_write / mem_delete / mem_replace tools and fully automatic legacy migration (no CLI needed; the CLI remains for explicit control), auto-managed by the root agent with zero manual steps.",
5
5
  "author": "fonlan",
6
6
  "publishConfig": {
@@ -30,6 +30,9 @@
30
30
  "default": "./lib/migrate.js"
31
31
  },
32
32
  "./cli": "./lib/cli.js",
33
+ "./client": {
34
+ "default": "./lib/client.js"
35
+ },
33
36
  "./src/*": "./src/*",
34
37
  "./package.json": "./package.json"
35
38
  },
@@ -40,8 +43,10 @@
40
43
  "README.zh.md"
41
44
  ],
42
45
  "scripts": {
43
- "build": "tsc -p tsconfig.json && chmod +x lib/cli.js",
44
- "typecheck": "tsc --noEmit -p tsconfig.json",
46
+ "build": "tsc -p tsconfig.json && chmod +x lib/cli.js && tsdown",
47
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
48
+ "bundle": "tsdown",
49
+ "watch": "tsdown --watch",
45
50
  "test": "npm run build && node --test",
46
51
  "prepublishOnly": "npm run build",
47
52
  "release:patch": "npm version patch && git push && git push --tags",
@@ -49,14 +54,37 @@
49
54
  "release:major": "npm version major && git push && git push --tags"
50
55
  },
51
56
  "devDependencies": {
52
- "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
53
- "@deepseek-ai/schemastery": "^3.18.2",
57
+ "@deepseek-ai/cordis": "^4.0.2",
58
+ "@deepseek-ai/dsh-api-gateway": "^0.1.7-alpha.1",
59
+ "@deepseek-ai/dsh-api-remotes": "^0.1.7-alpha.1",
60
+ "@deepseek-ai/dsh-api-settings-controller": "^0.1.7-alpha.1",
61
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-alpha.1",
62
+ "@deepseek-ai/dsh-client-store": "^0.1.7-alpha.1",
63
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-alpha.1",
64
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-alpha.1",
65
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-alpha.1",
66
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-alpha.1",
67
+ "@deepseek-ai/dsh-tools": "^0.1.7-alpha.1",
68
+ "@deepseek-ai/schemastery": "^3.18.4",
54
69
  "@types/node": "^26.2.0",
70
+ "@types/react": "~18.3.1",
71
+ "react": "^18.2.0",
72
+ "tsdown": "^0.22.14",
55
73
  "typescript": "^5.5.0"
56
74
  },
57
75
  "dsh": {
58
76
  "bundle": {
59
77
  "patch": "./cordis.patch.yml"
78
+ },
79
+ "client": {
80
+ "platform": "web",
81
+ "inject": [
82
+ "@deepseek-ai/dsh-api-remotes",
83
+ "@deepseek-ai/dsh-client-locale",
84
+ "@deepseek-ai/dsh-client-ui-primitives",
85
+ "@deepseek-ai/dsh-client-ui-settings",
86
+ "@deepseek-ai/dsh-client-ui-slots"
87
+ ]
60
88
  }
61
89
  },
62
90
  "keywords": [