@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.
- package/README.md +84 -0
- package/README.zh.md +63 -0
- package/lib/client.js +721 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +158 -15
- package/lib/index.js +310 -4
- package/lib/systemone.d.ts +213 -0
- package/lib/systemone.js +283 -0
- package/package.json +33 -5
package/lib/index.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import z from "@deepseek-ai/schemastery";
|
|
24
24
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
25
25
|
import { GitMemo, GitMemoError, resolveProjectRoot } from "./mem.js";
|
|
26
|
+
import { evaluateGate } from "./systemone.js";
|
|
26
27
|
/** Cordis plugin name. */
|
|
27
28
|
const name = "dsh-gitmemo";
|
|
28
29
|
/** Host services this plugin needs. */
|
|
@@ -40,8 +41,160 @@ const Config = z.object({
|
|
|
40
41
|
/** Cross-process lock wait timeout in milliseconds. Default 30000. */
|
|
41
42
|
lockTimeoutMs: z.number().default(30000),
|
|
42
43
|
/** Optional explicit project root; defaults to the calling session's cwd. */
|
|
43
|
-
projectRoot: z.string()
|
|
44
|
+
projectRoot: z.string(),
|
|
45
|
+
/**
|
|
46
|
+
* Optional System-one recall gate. When an endpoint and a credential are
|
|
47
|
+
* configured, each `mem_search` page is narrowed by a System-one model
|
|
48
|
+
* (TypeSafe Jev by default) before it reaches the model; when nothing is
|
|
49
|
+
* configured the gate is a no-op and recall behaves exactly as before.
|
|
50
|
+
*
|
|
51
|
+
* `.volatile()` is REQUIRED here, not cosmetic: the settings plane only
|
|
52
|
+
* exposes a field that sits on or under a volatile node (`volatileForm()`
|
|
53
|
+
* returns undefined otherwise, and `describe()` then skips this entry
|
|
54
|
+
* entirely while `write()` throws). Verified against
|
|
55
|
+
* `@deepseek-ai/dsh-settings`. Note that DSH 0.1.7-alpha.1 ships no client
|
|
56
|
+
* that auto-generates a page from the schema, so this plugin also ships its
|
|
57
|
+
* own settings card (see `src/client/`).
|
|
58
|
+
*/
|
|
59
|
+
systemOne: z.object({
|
|
60
|
+
/** Master switch. Defaults to true; with no credential the gate stays a no-op. */
|
|
61
|
+
enabled: z.boolean().default(true).volatile(),
|
|
62
|
+
/** Endpoint accepting the System-one request contract. */
|
|
63
|
+
endpoint: z.string().default("https://api.typesafe.ai/v1/systemone").volatile(),
|
|
64
|
+
/** Model identifier sent in the request body. */
|
|
65
|
+
model: z.string().default("jev-latest").volatile(),
|
|
66
|
+
/** Literal API key. Declared secret: redacted on every read, write-only in the form. */
|
|
67
|
+
apiKey: z.string().role("secret").volatile(),
|
|
68
|
+
/** Credential reference resolved through the credentials service, then the environment. */
|
|
69
|
+
apiKeyEnv: z.string().role("credential-ref").default("TYPESAFE_API_KEY").volatile(),
|
|
70
|
+
/** Question type used to judge each candidate. */
|
|
71
|
+
mode: z.union(["noul", "score"]).default("noul").volatile(),
|
|
72
|
+
/** noul mode: keep a candidate when its probability is at least this value. */
|
|
73
|
+
threshold: z.number().min(0).max(1).default(0.5).volatile(),
|
|
74
|
+
/** score mode: keep a candidate when its score is at least this value.
|
|
75
|
+
* Jev's `score` is Σ(level_index × probability) over 0…levels−1, NOT 0–1;
|
|
76
|
+
* with the default 5 levels the range is 0…4, where 2 = "partially relevant". */
|
|
77
|
+
scoreMin: z.number().default(2).volatile(),
|
|
78
|
+
/** Retain at least this many candidates when the model rejects an entire page. */
|
|
79
|
+
minKeep: z.number().step(1).min(0).default(1).volatile(),
|
|
80
|
+
/** Never judge more than this many candidates per request. */
|
|
81
|
+
maxCandidates: z.number().step(1).min(1).default(20).volatile(),
|
|
82
|
+
/** Truncate the task text to this many characters before sending it. */
|
|
83
|
+
maxTaskChars: z.number().step(1).min(1).default(2000).volatile(),
|
|
84
|
+
/** End-to-end request deadline in milliseconds. */
|
|
85
|
+
timeoutMs: z.number().step(1).min(1).default(8000).volatile()
|
|
86
|
+
})
|
|
44
87
|
});
|
|
88
|
+
/**
|
|
89
|
+
* Read a config node that may be a plain value or a live (`.volatile()`) node.
|
|
90
|
+
*
|
|
91
|
+
* A plugin whose Config declares volatile fields receives a reactive config
|
|
92
|
+
* whose fields expose `get()`, while hand-written config objects and tests pass
|
|
93
|
+
* plain values. Both shapes are supported here so the same code path serves the
|
|
94
|
+
* settings page, `cordis.patch.yml`, and the unit tests.
|
|
95
|
+
*/
|
|
96
|
+
function liveValue(node, fallback) {
|
|
97
|
+
if (node === undefined || node === null)
|
|
98
|
+
return fallback;
|
|
99
|
+
const getter = node.get;
|
|
100
|
+
if (typeof getter === "function") {
|
|
101
|
+
const value = getter.call(node);
|
|
102
|
+
return value === undefined ? fallback : value;
|
|
103
|
+
}
|
|
104
|
+
return node;
|
|
105
|
+
}
|
|
106
|
+
/** Read the live `systemOne` section in whichever shape the loader supplied. */
|
|
107
|
+
function systemOneSection(config) {
|
|
108
|
+
const section = liveValue(config?.systemOne, undefined);
|
|
109
|
+
return (section ?? {});
|
|
110
|
+
}
|
|
111
|
+
/** Project a (possibly reactive) `systemOne` section into plain settings. */
|
|
112
|
+
function readSystemOne(config) {
|
|
113
|
+
const section = systemOneSection(config);
|
|
114
|
+
const mode = liveValue(section.mode, "noul");
|
|
115
|
+
return {
|
|
116
|
+
enabled: liveValue(section.enabled, true),
|
|
117
|
+
endpoint: liveValue(section.endpoint, "https://api.typesafe.ai/v1/systemone"),
|
|
118
|
+
model: liveValue(section.model, "jev-latest"),
|
|
119
|
+
mode: mode === "score" ? "score" : "noul",
|
|
120
|
+
threshold: liveValue(section.threshold, 0.5),
|
|
121
|
+
scoreMin: liveValue(section.scoreMin, 2),
|
|
122
|
+
minKeep: liveValue(section.minKeep, 1),
|
|
123
|
+
maxCandidates: liveValue(section.maxCandidates, 20),
|
|
124
|
+
maxTaskChars: liveValue(section.maxTaskChars, 2000),
|
|
125
|
+
timeoutMs: liveValue(section.timeoutMs, 8000),
|
|
126
|
+
apiKey: liveValue(section.apiKey, undefined),
|
|
127
|
+
apiKeyEnv: liveValue(section.apiKeyEnv, "TYPESAFE_API_KEY")
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the API key for the configured credential reference: a literal key
|
|
132
|
+
* from the settings page wins, then the harness credentials service, then the
|
|
133
|
+
* launching environment. Mirrors the shipped web-search provider's order.
|
|
134
|
+
*/
|
|
135
|
+
async function resolveApiKey(hostCtx, section) {
|
|
136
|
+
if (typeof section.apiKey === "string" && section.apiKey.length > 0)
|
|
137
|
+
return section.apiKey;
|
|
138
|
+
const ref = section.apiKeyEnv;
|
|
139
|
+
if (typeof ref !== "string" || ref.length === 0)
|
|
140
|
+
return undefined;
|
|
141
|
+
try {
|
|
142
|
+
const get = hostCtx?.get;
|
|
143
|
+
const credentials = typeof get === "function" ? get.call(hostCtx, "credentials") : undefined;
|
|
144
|
+
const resolve = credentials?.resolve;
|
|
145
|
+
if (typeof resolve === "function") {
|
|
146
|
+
const resolved = await resolve.call(credentials, ref);
|
|
147
|
+
if (resolved?.value !== undefined && resolved.value.length > 0)
|
|
148
|
+
return resolved.value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Credentials are optional: fall through to the environment.
|
|
153
|
+
}
|
|
154
|
+
const ambient = process.env[ref];
|
|
155
|
+
return ambient !== undefined && ambient.length > 0 ? ambient : undefined;
|
|
156
|
+
}
|
|
157
|
+
/** One text part's worth of content, whatever shape the message carries. */
|
|
158
|
+
function messageText(content) {
|
|
159
|
+
if (typeof content === "string")
|
|
160
|
+
return content;
|
|
161
|
+
if (!Array.isArray(content))
|
|
162
|
+
return undefined;
|
|
163
|
+
const parts = content
|
|
164
|
+
.filter((part) => part?.type === "text")
|
|
165
|
+
.map((part) => part.text)
|
|
166
|
+
.filter((text) => typeof text === "string");
|
|
167
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The agent's current task: the most recent user message in the derived
|
|
171
|
+
* history. Without it the gate would judge candidates against bare keywords,
|
|
172
|
+
* which is exactly the weak signal the gate is meant to compensate for.
|
|
173
|
+
*/
|
|
174
|
+
function currentTask(exec) {
|
|
175
|
+
const session = exec?.agent?.session;
|
|
176
|
+
const derive = session?.deriveMessages;
|
|
177
|
+
if (typeof derive !== "function")
|
|
178
|
+
return undefined;
|
|
179
|
+
let messages;
|
|
180
|
+
try {
|
|
181
|
+
messages = derive.call(session);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
if (!Array.isArray(messages))
|
|
187
|
+
return undefined;
|
|
188
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
189
|
+
const message = messages[index];
|
|
190
|
+
if (message?.role !== "user")
|
|
191
|
+
continue;
|
|
192
|
+
const text = messageText(message.content);
|
|
193
|
+
if (text !== undefined && text.trim().length > 0)
|
|
194
|
+
return text.trim();
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
45
198
|
/** The calling agent's session workspace, when it has one. */
|
|
46
199
|
function sessionCwd(exec) {
|
|
47
200
|
return exec.agent?.session?.header?.cwd;
|
|
@@ -152,6 +305,33 @@ function formatSearchResults(value) {
|
|
|
152
305
|
}
|
|
153
306
|
if (value.results.length === 0)
|
|
154
307
|
lines.push("(no matching memories)");
|
|
308
|
+
if (value.gated !== undefined) {
|
|
309
|
+
lines.push("gated: mode=" +
|
|
310
|
+
value.gated.mode +
|
|
311
|
+
" model=" +
|
|
312
|
+
value.gated.model +
|
|
313
|
+
" judged=" +
|
|
314
|
+
value.gated.judged +
|
|
315
|
+
// Only surfaced when the page exceeded maxCandidates, so the default
|
|
316
|
+
// line stays byte-identical to before this field existed.
|
|
317
|
+
(value.gated.untouched > 0 ? " untouched=" + value.gated.untouched : "") +
|
|
318
|
+
" kept=" +
|
|
319
|
+
value.gated.kept +
|
|
320
|
+
" dropped=" +
|
|
321
|
+
value.gated.dropped +
|
|
322
|
+
// A degraded gate explains itself; a floor (every candidate rejected,
|
|
323
|
+
// minKeep applied) must be just as visible — otherwise `kept=1` reads
|
|
324
|
+
// as "one candidate genuinely passed".
|
|
325
|
+
(value.gated.degraded
|
|
326
|
+
? " DEGRADED(" + (value.gated.reason ?? "unknown") + ")"
|
|
327
|
+
: value.gated.reason === undefined
|
|
328
|
+
? ""
|
|
329
|
+
: " NOTE(" + value.gated.reason + ")"));
|
|
330
|
+
if (value.gated.dropped_hashes.length > 0) {
|
|
331
|
+
lines.push(" dropped by the gate (mem_read one of these short hashes to recover it): " +
|
|
332
|
+
value.gated.dropped_hashes.join(","));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
155
335
|
if (value.warning !== undefined)
|
|
156
336
|
lines.push("warning: " + value.warning);
|
|
157
337
|
for (const diagnostic of value.diagnostics ?? [])
|
|
@@ -162,7 +342,7 @@ function formatSearchResults(value) {
|
|
|
162
342
|
function registerMemTools(ctx, config) {
|
|
163
343
|
ctx.tools.register(defineTool({
|
|
164
344
|
name: "mem_search",
|
|
165
|
-
description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-12 中英文关键词 from the user request (include synonyms in both languages when useful) and run mem_search; pure chat and general Q&A need no search. Returns a snapshot, total, next_skip and up to 20 scored hits with summary/keywords/kind/matched_keywords — score = distinct matched keywords + a mild recency bonus, so newer entries rank slightly higher and superseded conclusions lose to their replacements. Select at most 5 most relevant hits and mem_read them (prefer kind:\"topic\" hits as topic-page entry points). Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly.",
|
|
345
|
+
description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-12 中英文关键词 from the user request (include synonyms in both languages when useful) and run mem_search; pure chat and general Q&A need no search. Returns a snapshot, total, next_skip and up to 20 scored hits with summary/keywords/kind/matched_keywords — score = distinct matched keywords + a mild recency bonus, so newer entries rank slightly higher and superseded conclusions lose to their replacements. Select at most 5 most relevant hits and mem_read them (prefer kind:\"topic\" hits as topic-page entry points). Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly. An optional System-one gate may narrow the page: when the output carries a `gated` block, the entries it removed are listed as 8-char `dropped_hashes` — mem_read one of those if you suspect a relevant memory was filtered out (they stay fully readable). A `NOTE(...)` on the `gated` line means every candidate was judged irrelevant and only a `minKeep` floor was retained, not that one candidate passed.",
|
|
166
346
|
parameters: {
|
|
167
347
|
keywords: { ...KEYWORDS_ARRAY, required: true },
|
|
168
348
|
skip: {
|
|
@@ -193,6 +373,47 @@ function registerMemTools(ctx, config) {
|
|
|
193
373
|
type: "array",
|
|
194
374
|
items: { type: "string" },
|
|
195
375
|
description: "Malformed commit/file mappings skipped during search."
|
|
376
|
+
},
|
|
377
|
+
gated: {
|
|
378
|
+
type: "object",
|
|
379
|
+
additionalProperties: false,
|
|
380
|
+
description: "Present only when the System-one recall gate ran: how many candidates it judged (and how many past the cap it left untouched), how many it kept, the short hashes it dropped, and whether it had to fail open.",
|
|
381
|
+
properties: {
|
|
382
|
+
mode: { type: "string", required: true, description: "Question type used: noul or score." },
|
|
383
|
+
model: { type: "string", required: true, description: "System-one model the request named." },
|
|
384
|
+
candidates: { type: "number", required: true, description: "Size of the candidate page the gate was handed." },
|
|
385
|
+
judged: {
|
|
386
|
+
type: "number",
|
|
387
|
+
required: true,
|
|
388
|
+
description: "Candidates actually sent to the endpoint, i.e. min(maxCandidates, candidates)."
|
|
389
|
+
},
|
|
390
|
+
untouched: {
|
|
391
|
+
type: "number",
|
|
392
|
+
required: true,
|
|
393
|
+
description: "Candidates past maxCandidates: never evaluated, therefore never dropped. Non-zero only on a page larger than the cap."
|
|
394
|
+
},
|
|
395
|
+
kept: { type: "number", required: true, description: "Candidates the gate retained." },
|
|
396
|
+
dropped: { type: "number", required: true, description: "Candidates the gate removed." },
|
|
397
|
+
degraded: {
|
|
398
|
+
type: "boolean",
|
|
399
|
+
required: true,
|
|
400
|
+
description: "True when the gate failed open (endpoint/parse failure); every candidate was kept."
|
|
401
|
+
},
|
|
402
|
+
reason: {
|
|
403
|
+
type: "string",
|
|
404
|
+
description: "Why the gate degraded, or that it floored a wholly-rejected page (no candidate cleared the threshold, so minKeep were retained)."
|
|
405
|
+
},
|
|
406
|
+
input_tokens: {
|
|
407
|
+
type: "number",
|
|
408
|
+
description: "Input tokens the endpoint billed for the gate request (output tokens are free)."
|
|
409
|
+
},
|
|
410
|
+
dropped_hashes: {
|
|
411
|
+
type: "array",
|
|
412
|
+
required: true,
|
|
413
|
+
items: { type: "string" },
|
|
414
|
+
description: "8-char hashes of dropped candidates. Pass one to mem_read to recover a memory the gate judged irrelevant."
|
|
415
|
+
}
|
|
416
|
+
}
|
|
196
417
|
}
|
|
197
418
|
}
|
|
198
419
|
},
|
|
@@ -201,11 +422,64 @@ function registerMemTools(ctx, config) {
|
|
|
201
422
|
isConcurrencySafe: () => true,
|
|
202
423
|
async execute(args, exec) {
|
|
203
424
|
const memo = await engineFor(exec, config);
|
|
204
|
-
|
|
425
|
+
const result = await memo.search(args.keywords, {
|
|
205
426
|
skip: args.skip ?? 0,
|
|
206
427
|
snapshot: args.snapshot,
|
|
207
428
|
mode: args.mode
|
|
208
429
|
});
|
|
430
|
+
// System-one recall gate: purely subtractive, and only ever reached when
|
|
431
|
+
// an endpoint credential is configured. No credential => this returns the
|
|
432
|
+
// untouched engine result, so recall behaves exactly as it did before.
|
|
433
|
+
if (config.gateFor === undefined || result.results.length === 0)
|
|
434
|
+
return result;
|
|
435
|
+
const options = await config.gateFor();
|
|
436
|
+
if (options === undefined)
|
|
437
|
+
return result;
|
|
438
|
+
const task = currentTask(exec) ??
|
|
439
|
+
(Array.isArray(args.keywords) ? args.keywords.join(" ") : String(args.keywords));
|
|
440
|
+
const candidates = result.results.map((hit) => ({
|
|
441
|
+
hash: hit.hash,
|
|
442
|
+
title: hit.title,
|
|
443
|
+
summary: hit.summary,
|
|
444
|
+
keywords: hit.keywords,
|
|
445
|
+
kind: hit.kind
|
|
446
|
+
}));
|
|
447
|
+
const decision = await evaluateGate(task, candidates, options, exec?.signal);
|
|
448
|
+
// Per-candidate values go to the log (for threshold tuning) rather than
|
|
449
|
+
// into the model context, which is the very thing the gate is shrinking.
|
|
450
|
+
config.log?.("mem_search gate" +
|
|
451
|
+
(decision.degraded ? " DEGRADED" : "") +
|
|
452
|
+
" mode=" + decision.mode +
|
|
453
|
+
" model=" + decision.model +
|
|
454
|
+
" candidates=" + candidates.length +
|
|
455
|
+
" judged=" + decision.judged +
|
|
456
|
+
(decision.untouched === 0 ? "" : " untouched=" + decision.untouched) +
|
|
457
|
+
" kept=" + decision.kept.length +
|
|
458
|
+
" dropped=" + decision.dropped.length +
|
|
459
|
+
(decision.reason === undefined ? "" : " reason=" + decision.reason) +
|
|
460
|
+
(decision.usage?.inputTokens === undefined ? "" : " input_tokens=" + decision.usage.inputTokens) +
|
|
461
|
+
" verdicts=" +
|
|
462
|
+
decision.verdicts
|
|
463
|
+
.map((verdict) => verdict.hash.slice(0, 8) + ":" + (verdict.value ?? "na") + ":" + (verdict.keep ? "keep" : "drop"))
|
|
464
|
+
.join(","));
|
|
465
|
+
const kept = new Set(decision.kept);
|
|
466
|
+
return {
|
|
467
|
+
...result,
|
|
468
|
+
results: result.results.filter((hit) => kept.has(hit.hash)),
|
|
469
|
+
gated: {
|
|
470
|
+
mode: decision.mode,
|
|
471
|
+
model: decision.model,
|
|
472
|
+
candidates: candidates.length,
|
|
473
|
+
judged: decision.judged,
|
|
474
|
+
untouched: decision.untouched,
|
|
475
|
+
kept: decision.kept.length,
|
|
476
|
+
dropped: decision.dropped.length,
|
|
477
|
+
degraded: decision.degraded,
|
|
478
|
+
...(decision.reason === undefined ? {} : { reason: decision.reason }),
|
|
479
|
+
...(decision.usage?.inputTokens === undefined ? {} : { input_tokens: decision.usage.inputTokens }),
|
|
480
|
+
dropped_hashes: decision.dropped.map((hash) => hash.slice(0, 8))
|
|
481
|
+
}
|
|
482
|
+
};
|
|
209
483
|
},
|
|
210
484
|
presentCall: (args) => ({
|
|
211
485
|
card: "generic",
|
|
@@ -489,10 +763,42 @@ function registerPromptSection(ctx) {
|
|
|
489
763
|
* registration time.
|
|
490
764
|
*/
|
|
491
765
|
async function apply(ctx, config = {}) {
|
|
766
|
+
const log = (message) => {
|
|
767
|
+
if (typeof ctx.logger.info === "function")
|
|
768
|
+
ctx.logger.info(message);
|
|
769
|
+
};
|
|
492
770
|
const resolved = {
|
|
493
771
|
searchLimit: config.searchLimit ?? 20,
|
|
494
772
|
lockTimeoutMs: config.lockTimeoutMs ?? 30000,
|
|
495
|
-
projectRoot: config.projectRoot
|
|
773
|
+
projectRoot: config.projectRoot,
|
|
774
|
+
log,
|
|
775
|
+
/**
|
|
776
|
+
* Resolved per search so settings-page edits take effect immediately.
|
|
777
|
+
* Returns undefined — leaving recall byte-for-byte unchanged — whenever the
|
|
778
|
+
* gate is switched off or has no usable credential.
|
|
779
|
+
*/
|
|
780
|
+
gateFor: async () => {
|
|
781
|
+
const section = readSystemOne(config);
|
|
782
|
+
if (!section.enabled)
|
|
783
|
+
return undefined;
|
|
784
|
+
if (typeof section.endpoint !== "string" || section.endpoint.length === 0)
|
|
785
|
+
return undefined;
|
|
786
|
+
const apiKey = await resolveApiKey(ctx, section);
|
|
787
|
+
if (apiKey === undefined)
|
|
788
|
+
return undefined;
|
|
789
|
+
return {
|
|
790
|
+
endpoint: section.endpoint,
|
|
791
|
+
model: section.model,
|
|
792
|
+
mode: section.mode,
|
|
793
|
+
threshold: section.threshold,
|
|
794
|
+
scoreMin: section.scoreMin,
|
|
795
|
+
minKeep: section.minKeep,
|
|
796
|
+
maxCandidates: section.maxCandidates,
|
|
797
|
+
maxTaskChars: section.maxTaskChars,
|
|
798
|
+
timeoutMs: section.timeoutMs,
|
|
799
|
+
apiKey
|
|
800
|
+
};
|
|
801
|
+
}
|
|
496
802
|
};
|
|
497
803
|
if (config.branchAlign !== undefined && config.branchAlign !== true) {
|
|
498
804
|
ctx.logger.warn("dsh-gitmemo: `branchAlign` is deprecated and has no effect — .mem always stays on main");
|
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
/** Question types this gate can drive. */
|
|
29
|
+
export type SystemOneMode = "noul" | "score";
|
|
30
|
+
/** One candidate memory offered to the gate. */
|
|
31
|
+
export interface GateCandidate {
|
|
32
|
+
/** Commit hash — the identity used to keep/drop the candidate. */
|
|
33
|
+
hash: string;
|
|
34
|
+
/** Entry title (commit subject). */
|
|
35
|
+
title: string;
|
|
36
|
+
/** Entry summary (commit body). */
|
|
37
|
+
summary: string;
|
|
38
|
+
/** Structured keywords stored with the entry. */
|
|
39
|
+
keywords: string[];
|
|
40
|
+
/** Entry kind: `task` or `topic`. */
|
|
41
|
+
kind: string;
|
|
42
|
+
}
|
|
43
|
+
/** Resolved gate settings for one search. */
|
|
44
|
+
export interface GateOptions {
|
|
45
|
+
/** Absolute endpoint URL accepting the System-one request contract. */
|
|
46
|
+
endpoint: string;
|
|
47
|
+
/** Model identifier sent in the request body (e.g. `jev-latest`). */
|
|
48
|
+
model: string;
|
|
49
|
+
/** Question type used to judge each candidate. */
|
|
50
|
+
mode: SystemOneMode;
|
|
51
|
+
/** noul mode: keep a candidate when its probability is >= this value (0..1). */
|
|
52
|
+
threshold: number;
|
|
53
|
+
/** score mode: keep a candidate when its score is >= this value. */
|
|
54
|
+
scoreMin: number;
|
|
55
|
+
/** Retain at least this many candidates when the model rejects the whole page. */
|
|
56
|
+
minKeep: number;
|
|
57
|
+
/** Never send more than this many candidates in one request. */
|
|
58
|
+
maxCandidates: number;
|
|
59
|
+
/** Truncate the task text to this many characters. */
|
|
60
|
+
maxTaskChars: number;
|
|
61
|
+
/** End-to-end request deadline in milliseconds. */
|
|
62
|
+
timeoutMs: number;
|
|
63
|
+
/** Literal API key, when one is configured in the settings section. */
|
|
64
|
+
apiKey?: string;
|
|
65
|
+
/** Late-bound credential lookup, used when no literal key is configured. */
|
|
66
|
+
resolveApiKey?: () => Promise<string | undefined>;
|
|
67
|
+
/** Injectable fetch, for tests. */
|
|
68
|
+
fetchImpl?: typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* One candidate's verdict, retained for auditability.
|
|
72
|
+
*
|
|
73
|
+
* `value` is `null` when no readable answer came back for that candidate;
|
|
74
|
+
* `null` rather than `NaN` because the harness rejects non-finite numbers in
|
|
75
|
+
* session events and tool output must stay losslessly JSON-serializable.
|
|
76
|
+
*/
|
|
77
|
+
export interface GateVerdict {
|
|
78
|
+
hash: string;
|
|
79
|
+
/** Probability (noul) or score (score) returned for this candidate, or null. */
|
|
80
|
+
value: number | null;
|
|
81
|
+
/** Whether the candidate survived the gate. */
|
|
82
|
+
keep: boolean;
|
|
83
|
+
}
|
|
84
|
+
/** Token accounting reported by the endpoint, when it reports any. */
|
|
85
|
+
export interface GateUsage {
|
|
86
|
+
/** Input tokens billed for the request (Jev bills input only). */
|
|
87
|
+
inputTokens?: number;
|
|
88
|
+
/** Output tokens; free on Jev, but reported. */
|
|
89
|
+
outputTokens?: number;
|
|
90
|
+
}
|
|
91
|
+
/** Outcome of one gate evaluation. */
|
|
92
|
+
export interface GateDecision {
|
|
93
|
+
/** Hashes retained, in their original order. */
|
|
94
|
+
kept: string[];
|
|
95
|
+
/** Hashes removed, in their original order. */
|
|
96
|
+
dropped: string[];
|
|
97
|
+
/** Per-candidate verdicts, in candidate order. */
|
|
98
|
+
verdicts: GateVerdict[];
|
|
99
|
+
/** Question type actually used. */
|
|
100
|
+
mode: SystemOneMode;
|
|
101
|
+
/** Model identifier the request named. */
|
|
102
|
+
model: string;
|
|
103
|
+
/** True when the gate failed open (every candidate retained as a result). */
|
|
104
|
+
degraded: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Why the gate degraded — or, on a `false` {@link degraded} page, that it
|
|
107
|
+
* floored: no candidate cleared the threshold and `minKeep` was applied.
|
|
108
|
+
*/
|
|
109
|
+
reason?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Candidates actually sent to the endpoint, i.e. `min(maxCandidates, page
|
|
112
|
+
* size)`. Reported separately from the page size so a truncated judgement is
|
|
113
|
+
* never mistaken for a full one.
|
|
114
|
+
*/
|
|
115
|
+
judged: number;
|
|
116
|
+
/**
|
|
117
|
+
* Candidates past `maxCandidates`, which were never evaluated and therefore
|
|
118
|
+
* can never be dropped. Always `0` unless the page exceeded the cap.
|
|
119
|
+
*/
|
|
120
|
+
untouched: number;
|
|
121
|
+
/** Endpoint-reported token usage, so the gate's own cost is observable. */
|
|
122
|
+
usage?: GateUsage;
|
|
123
|
+
}
|
|
124
|
+
/** Score levels offered to the model in `score` mode (ordered low to high). */
|
|
125
|
+
export declare const SCORE_LEVELS: readonly ["unrelated to the task", "same general area but not actionable for this task", "partially relevant background", "substantially relevant and worth reusing", "directly reusable conclusions for this exact task"];
|
|
126
|
+
/**
|
|
127
|
+
* The judgement asked of the model, verbatim. Kept in English on purpose:
|
|
128
|
+
* Jev's primary training language is English and the vendor documents lower
|
|
129
|
+
* accuracy for other scripts, so only the (possibly CJK) candidate text and
|
|
130
|
+
* task text are non-English.
|
|
131
|
+
*/
|
|
132
|
+
export declare 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.";
|
|
133
|
+
/**
|
|
134
|
+
* Clarifies what yes and no mean for {@link NOUL_INSTRUCTION}.
|
|
135
|
+
*
|
|
136
|
+
* A noul `criteria` is an object with exactly `true` and `false` keys — a bare
|
|
137
|
+
* string is rejected by the endpoint's validation, so this shape is required.
|
|
138
|
+
*/
|
|
139
|
+
export declare const NOUL_CRITERIA: {
|
|
140
|
+
readonly true: "yes — the memory is directly reusable for this task";
|
|
141
|
+
readonly false: "no — unrelated, superseded, or only loosely related to this task";
|
|
142
|
+
};
|
|
143
|
+
/** The judgement asked of the model in `score` mode. */
|
|
144
|
+
export declare 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.";
|
|
145
|
+
/**
|
|
146
|
+
* Build the System-one request body: the task is the shared `state`, and each
|
|
147
|
+
* candidate travels inside its own question's structured `instructions`, so
|
|
148
|
+
* every candidate is judged against the same task in one request and no
|
|
149
|
+
* candidate payload is repeated across questions.
|
|
150
|
+
*
|
|
151
|
+
* @param task - the agent's current task, used as the shared state.
|
|
152
|
+
* @param candidates - candidates to judge, already capped by the caller.
|
|
153
|
+
* @param options - resolved gate settings.
|
|
154
|
+
* @returns a JSON-serializable request body.
|
|
155
|
+
*/
|
|
156
|
+
export declare function buildRequest(task: string, candidates: readonly GateCandidate[], options: Pick<GateOptions, "mode" | "model" | "maxTaskChars">): {
|
|
157
|
+
state: {
|
|
158
|
+
task: string;
|
|
159
|
+
};
|
|
160
|
+
model: string;
|
|
161
|
+
questions: Record<string, unknown>;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Extract one numeric answer per question id from a System-one response.
|
|
165
|
+
*
|
|
166
|
+
* Tolerates both documented envelope shapes (a top-level map of answers, or
|
|
167
|
+
* the same map nested under `answers`) and both `noul`/`score` field spellings,
|
|
168
|
+
* so an endpoint that is merely contract-compatible still works.
|
|
169
|
+
*
|
|
170
|
+
* @param payload - parsed response JSON.
|
|
171
|
+
* @param mode - the question type that was asked.
|
|
172
|
+
* @param count - number of questions sent (ids are `m0`..`m<count-1>`).
|
|
173
|
+
* @returns the value per question index, or undefined where absent/unreadable.
|
|
174
|
+
*/
|
|
175
|
+
export declare function parseAnswers(payload: unknown, mode: SystemOneMode, count: number): Array<number | undefined>;
|
|
176
|
+
/**
|
|
177
|
+
* Read the endpoint's token accounting.
|
|
178
|
+
*
|
|
179
|
+
* @param payload - parsed response JSON.
|
|
180
|
+
* @returns camel-cased usage, or undefined when the endpoint reported none.
|
|
181
|
+
*/
|
|
182
|
+
export declare function parseUsage(payload: unknown): GateUsage | undefined;
|
|
183
|
+
/**
|
|
184
|
+
* Turn per-candidate values into a keep/drop decision.
|
|
185
|
+
*
|
|
186
|
+
* Applies the configured threshold, then the `minKeep` floor that guarantees a
|
|
187
|
+
* non-empty page whenever candidates existed. Candidates with a missing or
|
|
188
|
+
* unreadable answer are KEPT (fail-open at the per-candidate level).
|
|
189
|
+
*
|
|
190
|
+
* @param candidates - candidates in page order.
|
|
191
|
+
* @param values - value per candidate index (undefined when unreadable).
|
|
192
|
+
* @param options - resolved gate settings.
|
|
193
|
+
* @returns kept/dropped hash lists and per-candidate verdicts.
|
|
194
|
+
*/
|
|
195
|
+
export declare function decide(candidates: readonly GateCandidate[], values: ReadonlyArray<number | undefined>, options: Pick<GateOptions, "mode" | "threshold" | "scoreMin" | "minKeep">): {
|
|
196
|
+
kept: string[];
|
|
197
|
+
dropped: string[];
|
|
198
|
+
verdicts: GateVerdict[];
|
|
199
|
+
floored: boolean;
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Evaluate the gate for one search page.
|
|
203
|
+
*
|
|
204
|
+
* Never throws and never returns an empty `kept` list while candidates exist:
|
|
205
|
+
* every failure path degrades to "keep everything" with a reason.
|
|
206
|
+
*
|
|
207
|
+
* @param task - the agent's current task text (shared state).
|
|
208
|
+
* @param candidates - page candidates; only the first `maxCandidates` are judged.
|
|
209
|
+
* @param options - resolved gate settings.
|
|
210
|
+
* @param signal - caller cancellation.
|
|
211
|
+
* @returns the decision to apply to the page.
|
|
212
|
+
*/
|
|
213
|
+
export declare function evaluateGate(task: string, candidates: readonly GateCandidate[], options: GateOptions, signal?: AbortSignal): Promise<GateDecision>;
|