@fonlan/dsh-gitmemo 0.4.0 → 0.5.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/README.md +157 -4
- package/README.zh.md +83 -3
- package/lib/client.js +721 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +266 -15
- package/lib/index.js +377 -10
- package/lib/mem.d.ts +37 -1
- package/lib/mem.js +74 -10
- package/lib/systemone.d.ts +250 -0
- package/lib/systemone.js +359 -0
- package/package.json +37 -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. */
|
|
@@ -33,6 +34,21 @@ const Config = z.object({
|
|
|
33
34
|
memDirName: z.string().default(".mem"),
|
|
34
35
|
/** Max search hits per mem_search call (page size). Default 20. */
|
|
35
36
|
searchLimit: z.number().default(20),
|
|
37
|
+
/**
|
|
38
|
+
* How a matched entry is scored (search order only — matching itself is
|
|
39
|
+
* identical in both modes).
|
|
40
|
+
*
|
|
41
|
+
* - `weighted` (default): sum of per-keyword rarity weights within the
|
|
42
|
+
* recall page, so a distinctive match outranks several common ones.
|
|
43
|
+
* - `count`: the previous default behaviour (one point per matched keyword plus the
|
|
44
|
+
* recency bonus), kept for exact backwards compatibility.
|
|
45
|
+
*
|
|
46
|
+
* Measured motivation: on LongMemEval `_S` the engine reached
|
|
47
|
+
* recall_all@50 = 0.998 while ndcg@5 was only 0.686 — evidence was reachable
|
|
48
|
+
* but ranked poorly, because a saturated-free match count cannot tell a rare
|
|
49
|
+
* keyword from a ubiquitous one.
|
|
50
|
+
*/
|
|
51
|
+
searchScoring: z.union(["count", "weighted"]).default("weighted"),
|
|
36
52
|
/** @deprecated — `.mem` always stays on main; kept for one version, no effect. */
|
|
37
53
|
branchAlign: z.boolean().default(true),
|
|
38
54
|
/** @deprecated — session-start recent seeding was removed; kept for one version, no effect. */
|
|
@@ -40,8 +56,181 @@ const Config = z.object({
|
|
|
40
56
|
/** Cross-process lock wait timeout in milliseconds. Default 30000. */
|
|
41
57
|
lockTimeoutMs: z.number().default(30000),
|
|
42
58
|
/** Optional explicit project root; defaults to the calling session's cwd. */
|
|
43
|
-
projectRoot: z.string()
|
|
59
|
+
projectRoot: z.string(),
|
|
60
|
+
/**
|
|
61
|
+
* Optional System-one recall gate. When an endpoint and a credential are
|
|
62
|
+
* configured, each `mem_search` page is narrowed by a System-one model
|
|
63
|
+
* (TypeSafe Jev by default) before it reaches the model; when nothing is
|
|
64
|
+
* configured the gate is a no-op and recall behaves exactly as before.
|
|
65
|
+
*
|
|
66
|
+
* `.volatile()` is REQUIRED here, not cosmetic: the settings plane only
|
|
67
|
+
* exposes a field that sits on or under a volatile node (`volatileForm()`
|
|
68
|
+
* returns undefined otherwise, and `describe()` then skips this entry
|
|
69
|
+
* entirely while `write()` throws). Verified against
|
|
70
|
+
* `@deepseek-ai/dsh-settings`. Note that DSH 0.1.7-alpha.1 ships no client
|
|
71
|
+
* that auto-generates a page from the schema, so this plugin also ships its
|
|
72
|
+
* own settings card (see `src/client/`).
|
|
73
|
+
*/
|
|
74
|
+
systemOne: z.object({
|
|
75
|
+
/** Master switch. Defaults to true; with no credential the gate stays a no-op. */
|
|
76
|
+
enabled: z.boolean().default(true).volatile(),
|
|
77
|
+
/** Endpoint accepting the System-one request contract. */
|
|
78
|
+
endpoint: z.string().default("https://api.typesafe.ai/v1/systemone").volatile(),
|
|
79
|
+
/** Model identifier sent in the request body. */
|
|
80
|
+
model: z.string().default("jev-latest").volatile(),
|
|
81
|
+
/** Literal API key. Declared secret: redacted on every read, write-only in the form. */
|
|
82
|
+
apiKey: z.string().role("secret").volatile(),
|
|
83
|
+
/** Credential reference resolved through the credentials service, then the environment. */
|
|
84
|
+
apiKeyEnv: z.string().role("credential-ref").default("TYPESAFE_API_KEY").volatile(),
|
|
85
|
+
/** Question type used to judge each candidate. */
|
|
86
|
+
mode: z.union(["noul", "score"]).default("noul").volatile(),
|
|
87
|
+
/** noul mode: keep a candidate when its probability is at least this value. */
|
|
88
|
+
threshold: z.number().min(0).max(1).default(0.5).volatile(),
|
|
89
|
+
/** score mode: keep a candidate when its score is at least this value.
|
|
90
|
+
* Jev's `score` is Σ(level_index × probability) over 0…levels−1, NOT 0–1;
|
|
91
|
+
* with the default 5 levels the range is 0…4, where 2 = "partially relevant". */
|
|
92
|
+
scoreMin: z.number().default(2).volatile(),
|
|
93
|
+
/** Retain at least this many candidates when the model rejects an entire page. */
|
|
94
|
+
minKeep: z.number().step(1).min(0).default(1).volatile(),
|
|
95
|
+
/**
|
|
96
|
+
* What happens to a judged candidate.
|
|
97
|
+
*
|
|
98
|
+
* `rerank` (default) keeps every candidate and lets the judge's value order
|
|
99
|
+
* the page; `filter` drops those below the threshold, bounded by
|
|
100
|
+
* `maxDropFraction`. Measured end-to-end on LongMemEval `_S` with the same
|
|
101
|
+
* judge and the same per-candidate values: filtering at the default
|
|
102
|
+
* threshold scored 0.3800, ordering scored 0.8020. The judge's AUC is
|
|
103
|
+
* ~0.71-0.75, so no threshold is safe, but the ordering is useful. See
|
|
104
|
+
* evals/longmemeval/README.md.
|
|
105
|
+
*/
|
|
106
|
+
policy: z.union(["rerank", "filter"]).default("rerank").volatile(),
|
|
107
|
+
/**
|
|
108
|
+
* `filter` policy only: never drop more than this share of the judged page,
|
|
109
|
+
* lowest-scoring first. Caps the damage a mis-calibrated threshold can do —
|
|
110
|
+
* a memory dropped is gone, a memory kept only costs context. The offline
|
|
111
|
+
* sweep held recall_all@5 at 0.8875 with a 20 % cap against 0.6875 uncapped.
|
|
112
|
+
*/
|
|
113
|
+
maxDropFraction: z.number().min(0).max(1).default(0.25).volatile(),
|
|
114
|
+
/** Never judge more than this many candidates per request. */
|
|
115
|
+
maxCandidates: z.number().step(1).min(1).default(20).volatile(),
|
|
116
|
+
/** Truncate the task text to this many characters before sending it. */
|
|
117
|
+
maxTaskChars: z.number().step(1).min(1).default(2000).volatile(),
|
|
118
|
+
/** End-to-end request deadline in milliseconds. */
|
|
119
|
+
timeoutMs: z.number().step(1).min(1).default(8000).volatile()
|
|
120
|
+
})
|
|
44
121
|
});
|
|
122
|
+
/**
|
|
123
|
+
* Read a config node that may be a plain value or a live (`.volatile()`) node.
|
|
124
|
+
*
|
|
125
|
+
* A plugin whose Config declares volatile fields receives a reactive config
|
|
126
|
+
* whose fields expose `get()`, while hand-written config objects and tests pass
|
|
127
|
+
* plain values. Both shapes are supported here so the same code path serves the
|
|
128
|
+
* settings page, `cordis.patch.yml`, and the unit tests.
|
|
129
|
+
*/
|
|
130
|
+
function liveValue(node, fallback) {
|
|
131
|
+
if (node === undefined || node === null)
|
|
132
|
+
return fallback;
|
|
133
|
+
const getter = node.get;
|
|
134
|
+
if (typeof getter === "function") {
|
|
135
|
+
const value = getter.call(node);
|
|
136
|
+
return value === undefined ? fallback : value;
|
|
137
|
+
}
|
|
138
|
+
return node;
|
|
139
|
+
}
|
|
140
|
+
/** Read the live `systemOne` section in whichever shape the loader supplied. */
|
|
141
|
+
function systemOneSection(config) {
|
|
142
|
+
const section = liveValue(config?.systemOne, undefined);
|
|
143
|
+
return (section ?? {});
|
|
144
|
+
}
|
|
145
|
+
/** Project a (possibly reactive) `systemOne` section into plain settings. */
|
|
146
|
+
function readSystemOne(config) {
|
|
147
|
+
const section = systemOneSection(config);
|
|
148
|
+
const mode = liveValue(section.mode, "noul");
|
|
149
|
+
return {
|
|
150
|
+
enabled: liveValue(section.enabled, true),
|
|
151
|
+
endpoint: liveValue(section.endpoint, "https://api.typesafe.ai/v1/systemone"),
|
|
152
|
+
model: liveValue(section.model, "jev-latest"),
|
|
153
|
+
mode: mode === "score" ? "score" : "noul",
|
|
154
|
+
threshold: liveValue(section.threshold, 0.5),
|
|
155
|
+
scoreMin: liveValue(section.scoreMin, 2),
|
|
156
|
+
minKeep: liveValue(section.minKeep, 1),
|
|
157
|
+
policy: liveValue(section.policy, "rerank") === "filter" ? "filter" : "rerank",
|
|
158
|
+
maxDropFraction: liveValue(section.maxDropFraction, 0.25),
|
|
159
|
+
maxCandidates: liveValue(section.maxCandidates, 20),
|
|
160
|
+
maxTaskChars: liveValue(section.maxTaskChars, 2000),
|
|
161
|
+
timeoutMs: liveValue(section.timeoutMs, 8000),
|
|
162
|
+
apiKey: liveValue(section.apiKey, undefined),
|
|
163
|
+
apiKeyEnv: liveValue(section.apiKeyEnv, "TYPESAFE_API_KEY")
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the API key for the configured credential reference: a literal key
|
|
168
|
+
* from the settings page wins, then the harness credentials service, then the
|
|
169
|
+
* launching environment. Mirrors the shipped web-search provider's order.
|
|
170
|
+
*/
|
|
171
|
+
async function resolveApiKey(hostCtx, section) {
|
|
172
|
+
if (typeof section.apiKey === "string" && section.apiKey.length > 0)
|
|
173
|
+
return section.apiKey;
|
|
174
|
+
const ref = section.apiKeyEnv;
|
|
175
|
+
if (typeof ref !== "string" || ref.length === 0)
|
|
176
|
+
return undefined;
|
|
177
|
+
try {
|
|
178
|
+
const get = hostCtx?.get;
|
|
179
|
+
const credentials = typeof get === "function" ? get.call(hostCtx, "credentials") : undefined;
|
|
180
|
+
const resolve = credentials?.resolve;
|
|
181
|
+
if (typeof resolve === "function") {
|
|
182
|
+
const resolved = await resolve.call(credentials, ref);
|
|
183
|
+
if (resolved?.value !== undefined && resolved.value.length > 0)
|
|
184
|
+
return resolved.value;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Credentials are optional: fall through to the environment.
|
|
189
|
+
}
|
|
190
|
+
const ambient = process.env[ref];
|
|
191
|
+
return ambient !== undefined && ambient.length > 0 ? ambient : undefined;
|
|
192
|
+
}
|
|
193
|
+
/** One text part's worth of content, whatever shape the message carries. */
|
|
194
|
+
function messageText(content) {
|
|
195
|
+
if (typeof content === "string")
|
|
196
|
+
return content;
|
|
197
|
+
if (!Array.isArray(content))
|
|
198
|
+
return undefined;
|
|
199
|
+
const parts = content
|
|
200
|
+
.filter((part) => part?.type === "text")
|
|
201
|
+
.map((part) => part.text)
|
|
202
|
+
.filter((text) => typeof text === "string");
|
|
203
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The agent's current task: the most recent user message in the derived
|
|
207
|
+
* history. Without it the gate would judge candidates against bare keywords,
|
|
208
|
+
* which is exactly the weak signal the gate is meant to compensate for.
|
|
209
|
+
*/
|
|
210
|
+
function currentTask(exec) {
|
|
211
|
+
const session = exec?.agent?.session;
|
|
212
|
+
const derive = session?.deriveMessages;
|
|
213
|
+
if (typeof derive !== "function")
|
|
214
|
+
return undefined;
|
|
215
|
+
let messages;
|
|
216
|
+
try {
|
|
217
|
+
messages = derive.call(session);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
if (!Array.isArray(messages))
|
|
223
|
+
return undefined;
|
|
224
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
225
|
+
const message = messages[index];
|
|
226
|
+
if (message?.role !== "user")
|
|
227
|
+
continue;
|
|
228
|
+
const text = messageText(message.content);
|
|
229
|
+
if (text !== undefined && text.trim().length > 0)
|
|
230
|
+
return text.trim();
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
45
234
|
/** The calling agent's session workspace, when it has one. */
|
|
46
235
|
function sessionCwd(exec) {
|
|
47
236
|
return exec.agent?.session?.header?.cwd;
|
|
@@ -52,7 +241,8 @@ async function engineFor(exec, config) {
|
|
|
52
241
|
const root = config.projectRoot ?? (await resolveProjectRoot(cwd));
|
|
53
242
|
const engineConfig = {
|
|
54
243
|
searchLimit: config.searchLimit,
|
|
55
|
-
lockTimeoutMs: config.lockTimeoutMs
|
|
244
|
+
lockTimeoutMs: config.lockTimeoutMs,
|
|
245
|
+
searchScoring: config.searchScoring
|
|
56
246
|
};
|
|
57
247
|
return new GitMemo(root, engineConfig);
|
|
58
248
|
}
|
|
@@ -78,7 +268,9 @@ const SEARCH_HIT = {
|
|
|
78
268
|
score: {
|
|
79
269
|
type: "number",
|
|
80
270
|
required: true,
|
|
81
|
-
description: "Relevance
|
|
271
|
+
description: "Relevance. In the default `weighted` scoring: the summed rarity of the matched keywords within this page " +
|
|
272
|
+
"(a distinctive match outweighs several common ones) plus a mild recency bonus (≤ +0.5, decaying to 0 by ~180 days). " +
|
|
273
|
+
"With `searchScoring: count`: distinct matched keywords + the same recency bonus."
|
|
82
274
|
},
|
|
83
275
|
matched_keywords: {
|
|
84
276
|
type: "array",
|
|
@@ -111,10 +303,17 @@ const LINK_RESOLUTION = {
|
|
|
111
303
|
error: { type: "string", description: "Present when the link target could not be resolved (dangling link)." }
|
|
112
304
|
}
|
|
113
305
|
};
|
|
306
|
+
/** Stored-entry keywords (mem_write / mem_replace): 2-12, mirrors MAX_WRITE_KEYWORDS. */
|
|
114
307
|
const KEYWORDS_ARRAY = {
|
|
115
308
|
type: "array",
|
|
116
309
|
items: { type: "string" },
|
|
117
|
-
description: "
|
|
310
|
+
description: "2-12 keywords (include 中英文 synonyms when useful)."
|
|
311
|
+
};
|
|
312
|
+
/** Query keywords (mem_search): 1-15, mirrors MAX_SEARCH_KEYWORDS. */
|
|
313
|
+
const SEARCH_KEYWORDS_ARRAY = {
|
|
314
|
+
type: "array",
|
|
315
|
+
items: { type: "string" },
|
|
316
|
+
description: "1-15 keywords (include 中英文 synonyms when useful)."
|
|
118
317
|
};
|
|
119
318
|
const RELATED_BRANCHES = {
|
|
120
319
|
type: "array",
|
|
@@ -152,6 +351,33 @@ function formatSearchResults(value) {
|
|
|
152
351
|
}
|
|
153
352
|
if (value.results.length === 0)
|
|
154
353
|
lines.push("(no matching memories)");
|
|
354
|
+
if (value.gated !== undefined) {
|
|
355
|
+
lines.push("gated: mode=" +
|
|
356
|
+
value.gated.mode +
|
|
357
|
+
" model=" +
|
|
358
|
+
value.gated.model +
|
|
359
|
+
" judged=" +
|
|
360
|
+
value.gated.judged +
|
|
361
|
+
// Only surfaced when the page exceeded maxCandidates, so the default
|
|
362
|
+
// line stays byte-identical to before this field existed.
|
|
363
|
+
(value.gated.untouched > 0 ? " untouched=" + value.gated.untouched : "") +
|
|
364
|
+
" kept=" +
|
|
365
|
+
value.gated.kept +
|
|
366
|
+
" dropped=" +
|
|
367
|
+
value.gated.dropped +
|
|
368
|
+
// A degraded gate explains itself; a floor (every candidate rejected,
|
|
369
|
+
// minKeep applied) must be just as visible — otherwise `kept=1` reads
|
|
370
|
+
// as "one candidate genuinely passed".
|
|
371
|
+
(value.gated.degraded
|
|
372
|
+
? " DEGRADED(" + (value.gated.reason ?? "unknown") + ")"
|
|
373
|
+
: value.gated.reason === undefined
|
|
374
|
+
? ""
|
|
375
|
+
: " NOTE(" + value.gated.reason + ")"));
|
|
376
|
+
if (value.gated.dropped_hashes.length > 0) {
|
|
377
|
+
lines.push(" dropped by the gate (mem_read one of these short hashes to recover it): " +
|
|
378
|
+
value.gated.dropped_hashes.join(","));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
155
381
|
if (value.warning !== undefined)
|
|
156
382
|
lines.push("warning: " + value.warning);
|
|
157
383
|
for (const diagnostic of value.diagnostics ?? [])
|
|
@@ -162,9 +388,9 @@ function formatSearchResults(value) {
|
|
|
162
388
|
function registerMemTools(ctx, config) {
|
|
163
389
|
ctx.tools.register(defineTool({
|
|
164
390
|
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-
|
|
391
|
+
description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting repo-related work: extract 1-15 中英文关键词 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
392
|
parameters: {
|
|
167
|
-
keywords: { ...
|
|
393
|
+
keywords: { ...SEARCH_KEYWORDS_ARRAY, required: true },
|
|
168
394
|
skip: {
|
|
169
395
|
type: "integer",
|
|
170
396
|
description: "Pagination offset (default 0)."
|
|
@@ -193,6 +419,47 @@ function registerMemTools(ctx, config) {
|
|
|
193
419
|
type: "array",
|
|
194
420
|
items: { type: "string" },
|
|
195
421
|
description: "Malformed commit/file mappings skipped during search."
|
|
422
|
+
},
|
|
423
|
+
gated: {
|
|
424
|
+
type: "object",
|
|
425
|
+
additionalProperties: false,
|
|
426
|
+
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.",
|
|
427
|
+
properties: {
|
|
428
|
+
mode: { type: "string", required: true, description: "Question type used: noul or score." },
|
|
429
|
+
model: { type: "string", required: true, description: "System-one model the request named." },
|
|
430
|
+
candidates: { type: "number", required: true, description: "Size of the candidate page the gate was handed." },
|
|
431
|
+
judged: {
|
|
432
|
+
type: "number",
|
|
433
|
+
required: true,
|
|
434
|
+
description: "Candidates actually sent to the endpoint, i.e. min(maxCandidates, candidates)."
|
|
435
|
+
},
|
|
436
|
+
untouched: {
|
|
437
|
+
type: "number",
|
|
438
|
+
required: true,
|
|
439
|
+
description: "Candidates past maxCandidates: never evaluated, therefore never dropped. Non-zero only on a page larger than the cap."
|
|
440
|
+
},
|
|
441
|
+
kept: { type: "number", required: true, description: "Candidates the gate retained." },
|
|
442
|
+
dropped: { type: "number", required: true, description: "Candidates the gate removed." },
|
|
443
|
+
degraded: {
|
|
444
|
+
type: "boolean",
|
|
445
|
+
required: true,
|
|
446
|
+
description: "True when the gate failed open (endpoint/parse failure); every candidate was kept."
|
|
447
|
+
},
|
|
448
|
+
reason: {
|
|
449
|
+
type: "string",
|
|
450
|
+
description: "Why the gate degraded, or that it floored a wholly-rejected page (no candidate cleared the threshold, so minKeep were retained)."
|
|
451
|
+
},
|
|
452
|
+
input_tokens: {
|
|
453
|
+
type: "number",
|
|
454
|
+
description: "Input tokens the endpoint billed for the gate request (output tokens are free)."
|
|
455
|
+
},
|
|
456
|
+
dropped_hashes: {
|
|
457
|
+
type: "array",
|
|
458
|
+
required: true,
|
|
459
|
+
items: { type: "string" },
|
|
460
|
+
description: "8-char hashes of dropped candidates. Pass one to mem_read to recover a memory the gate judged irrelevant."
|
|
461
|
+
}
|
|
462
|
+
}
|
|
196
463
|
}
|
|
197
464
|
}
|
|
198
465
|
},
|
|
@@ -201,11 +468,76 @@ function registerMemTools(ctx, config) {
|
|
|
201
468
|
isConcurrencySafe: () => true,
|
|
202
469
|
async execute(args, exec) {
|
|
203
470
|
const memo = await engineFor(exec, config);
|
|
204
|
-
|
|
471
|
+
const result = await memo.search(args.keywords, {
|
|
205
472
|
skip: args.skip ?? 0,
|
|
206
473
|
snapshot: args.snapshot,
|
|
207
474
|
mode: args.mode
|
|
208
475
|
});
|
|
476
|
+
// System-one recall gate: purely subtractive, and only ever reached when
|
|
477
|
+
// an endpoint credential is configured. No credential => this returns the
|
|
478
|
+
// untouched engine result, so recall behaves exactly as it did before.
|
|
479
|
+
if (config.gateFor === undefined || result.results.length === 0)
|
|
480
|
+
return result;
|
|
481
|
+
const options = await config.gateFor();
|
|
482
|
+
if (options === undefined)
|
|
483
|
+
return result;
|
|
484
|
+
const task = currentTask(exec) ??
|
|
485
|
+
(Array.isArray(args.keywords) ? args.keywords.join(" ") : String(args.keywords));
|
|
486
|
+
const candidates = result.results.map((hit) => ({
|
|
487
|
+
hash: hit.hash,
|
|
488
|
+
title: hit.title,
|
|
489
|
+
summary: hit.summary,
|
|
490
|
+
keywords: hit.keywords,
|
|
491
|
+
kind: hit.kind
|
|
492
|
+
}));
|
|
493
|
+
const decision = await evaluateGate(task, candidates, options, exec?.signal);
|
|
494
|
+
// Per-candidate values go to the log (for threshold tuning) rather than
|
|
495
|
+
// into the model context, which is the very thing the gate is shrinking.
|
|
496
|
+
config.log?.("mem_search gate" +
|
|
497
|
+
(decision.degraded ? " DEGRADED" : "") +
|
|
498
|
+
" mode=" + decision.mode +
|
|
499
|
+
" model=" + decision.model +
|
|
500
|
+
" candidates=" + candidates.length +
|
|
501
|
+
" judged=" + decision.judged +
|
|
502
|
+
(decision.untouched === 0 ? "" : " untouched=" + decision.untouched) +
|
|
503
|
+
" policy=" + decision.policy +
|
|
504
|
+
" kept=" + decision.kept.length +
|
|
505
|
+
" dropped=" + decision.dropped.length +
|
|
506
|
+
(decision.bounded ? " bounded=yes" : "") +
|
|
507
|
+
(decision.reason === undefined ? "" : " reason=" + decision.reason) +
|
|
508
|
+
(decision.usage?.inputTokens === undefined ? "" : " input_tokens=" + decision.usage.inputTokens) +
|
|
509
|
+
" verdicts=" +
|
|
510
|
+
decision.verdicts
|
|
511
|
+
.map((verdict) => verdict.hash.slice(0, 8) + ":" + (verdict.value ?? "na") + ":" + (verdict.keep ? "keep" : "drop"))
|
|
512
|
+
.join(","));
|
|
513
|
+
const kept = new Set(decision.kept);
|
|
514
|
+
// `rerank` reorders the page by the judge's value and drops nothing;
|
|
515
|
+
// `filter` keeps the shipped behaviour, already bounded by
|
|
516
|
+
// `maxDropFraction` inside `decide`. Both preserve page order for
|
|
517
|
+
// everything the gate left alone.
|
|
518
|
+
const surviving = result.results.filter((hit) => kept.has(hit.hash));
|
|
519
|
+
const reranked = decision.policy === "rerank"
|
|
520
|
+
? [...surviving].sort((a, b) => decision.ordered.indexOf(a.hash) - decision.ordered.indexOf(b.hash))
|
|
521
|
+
: surviving;
|
|
522
|
+
return {
|
|
523
|
+
...result,
|
|
524
|
+
results: reranked,
|
|
525
|
+
gated: {
|
|
526
|
+
policy: decision.policy,
|
|
527
|
+
...(decision.bounded ? { bounded: true } : {}),
|
|
528
|
+
mode: decision.mode,
|
|
529
|
+
model: decision.model,
|
|
530
|
+
candidates: candidates.length,
|
|
531
|
+
judged: decision.judged,
|
|
532
|
+
untouched: decision.untouched,
|
|
533
|
+
kept: decision.kept.length,
|
|
534
|
+
dropped: decision.dropped.length,
|
|
535
|
+
degraded: decision.degraded,
|
|
536
|
+
...(decision.reason === undefined ? {} : { reason: decision.reason }),
|
|
537
|
+
...(decision.usage?.inputTokens === undefined ? {} : { input_tokens: decision.usage.inputTokens }),
|
|
538
|
+
dropped_hashes: decision.dropped.map((hash) => hash.slice(0, 8))
|
|
539
|
+
}
|
|
540
|
+
};
|
|
209
541
|
},
|
|
210
542
|
presentCall: (args) => ({
|
|
211
543
|
card: "generic",
|
|
@@ -472,10 +804,10 @@ function registerPromptSection(ctx) {
|
|
|
472
804
|
order: 150,
|
|
473
805
|
text: [
|
|
474
806
|
"This deployment provides gitmemo long-term memory: a local .mem git repository at the project root stores past task outcomes as immutable markdown entries; git is the only dependency. Use the dedicated tools only (mem_search / mem_read / mem_write / mem_delete / mem_replace — all auto-initialize, and legacy-format .mem repos migrate automatically on first use); never shell out to git or read .mem files directly.",
|
|
475
|
-
"- BEFORE WORK — search: 仓库相关任务开始前提取 1–
|
|
807
|
+
"- BEFORE WORK — search: 仓库相关任务开始前提取 1–15 个中英文关键词调用 mem_search(含中英文同义词;搜索关键词上限高于写入上限,详见 KEYWORD SELECTION);纯闲聊和通用问答无需搜索。",
|
|
476
808
|
"- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;kind 为 topic 的条目是该主题的聚合入口,优先读;无相关结果时用返回的 snapshot + skip 翻页。",
|
|
477
809
|
"- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
|
|
478
|
-
"- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary
|
|
810
|
+
"- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可,2–12 个);mem_search 的查询词上限更高(1–15 个),多给近义词/中英对照可提升召回。title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
|
|
479
811
|
"- KEEP FRESH — 结论保鲜是写路径的一部分,不必等用户指出:用户更正旧结论时,有替代结论用 mem_replace(禁止先 delete 再 write),作废且无替代用带 reason 的 mem_delete;本次会话的工作覆盖/推翻了某条已存结论(被后续实现、重构取代)时,同样主动 mem_replace 或 mem_delete 对应旧条目;检索结果中新旧结论冲突时,优先采信 score 更高/日期更新的条目(score 已含温和的 recency 加成)。",
|
|
480
812
|
"- TOPIC PAGE — 同一主题已积累多条记忆、或需要一个「当前真相」入口时,用 mem_write 写一条 kind:\"topic\" 聚合条目:summary 概括当下仍然有效的结论,content 用 [[<commit-hash>]] 链接各证据条目并各配一句话状态;topic 条目靠 mem_replace 演进(kind 自动继承),不是按任务新增。",
|
|
481
813
|
"- WIKI LINKS — content 中引用其他记忆条目一律写 [[<commit-hash>]](用 mem_search/mem_read 返回的 hash);mem_read 传 expand:true 会把链接一跳展开到各条目的最新版本(悬空链接以 error 返回),读 topic 条目时建议展开。",
|
|
@@ -489,10 +821,45 @@ function registerPromptSection(ctx) {
|
|
|
489
821
|
* registration time.
|
|
490
822
|
*/
|
|
491
823
|
async function apply(ctx, config = {}) {
|
|
824
|
+
const log = (message) => {
|
|
825
|
+
if (typeof ctx.logger.info === "function")
|
|
826
|
+
ctx.logger.info(message);
|
|
827
|
+
};
|
|
492
828
|
const resolved = {
|
|
493
829
|
searchLimit: config.searchLimit ?? 20,
|
|
830
|
+
searchScoring: config.searchScoring ?? "weighted",
|
|
494
831
|
lockTimeoutMs: config.lockTimeoutMs ?? 30000,
|
|
495
|
-
projectRoot: config.projectRoot
|
|
832
|
+
projectRoot: config.projectRoot,
|
|
833
|
+
log,
|
|
834
|
+
/**
|
|
835
|
+
* Resolved per search so settings-page edits take effect immediately.
|
|
836
|
+
* Returns undefined — leaving recall byte-for-byte unchanged — whenever the
|
|
837
|
+
* gate is switched off or has no usable credential.
|
|
838
|
+
*/
|
|
839
|
+
gateFor: async () => {
|
|
840
|
+
const section = readSystemOne(config);
|
|
841
|
+
if (!section.enabled)
|
|
842
|
+
return undefined;
|
|
843
|
+
if (typeof section.endpoint !== "string" || section.endpoint.length === 0)
|
|
844
|
+
return undefined;
|
|
845
|
+
const apiKey = await resolveApiKey(ctx, section);
|
|
846
|
+
if (apiKey === undefined)
|
|
847
|
+
return undefined;
|
|
848
|
+
return {
|
|
849
|
+
endpoint: section.endpoint,
|
|
850
|
+
model: section.model,
|
|
851
|
+
mode: section.mode,
|
|
852
|
+
threshold: section.threshold,
|
|
853
|
+
scoreMin: section.scoreMin,
|
|
854
|
+
minKeep: section.minKeep,
|
|
855
|
+
policy: section.policy,
|
|
856
|
+
maxDropFraction: section.maxDropFraction,
|
|
857
|
+
maxCandidates: section.maxCandidates,
|
|
858
|
+
maxTaskChars: section.maxTaskChars,
|
|
859
|
+
timeoutMs: section.timeoutMs,
|
|
860
|
+
apiKey
|
|
861
|
+
};
|
|
862
|
+
}
|
|
496
863
|
};
|
|
497
864
|
if (config.branchAlign !== undefined && config.branchAlign !== true) {
|
|
498
865
|
ctx.logger.warn("dsh-gitmemo: `branchAlign` is deprecated and has no effect — .mem always stays on main");
|
package/lib/mem.d.ts
CHANGED
|
@@ -10,6 +10,15 @@ export declare const LOCK_FILE_NAME = ".mem.gitmemo.lock";
|
|
|
10
10
|
export declare const MIGRATION_JOURNAL_NAME = ".mem.gitmemo-migration.json";
|
|
11
11
|
/** Max entry content size. */
|
|
12
12
|
export declare const MAX_CONTENT_BYTES: number;
|
|
13
|
+
/** Upper bound on stored entry keywords (mem_write / mem_replace). */
|
|
14
|
+
export declare const MAX_WRITE_KEYWORDS = 12;
|
|
15
|
+
/**
|
|
16
|
+
* Upper bound on query keywords for one mem_search call. Deliberately higher
|
|
17
|
+
* than the write bound: recall is a fixed-string OR over the query terms, so a
|
|
18
|
+
* wider (still human-curated) query only widens recall, while every matched
|
|
19
|
+
* term still contributes to the score and the write side keeps entries tight.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MAX_SEARCH_KEYWORDS = 15;
|
|
13
22
|
/** Error type thrown by every gitmemo operation. */
|
|
14
23
|
export declare class GitMemoError extends Error {
|
|
15
24
|
}
|
|
@@ -21,6 +30,17 @@ export interface GitMemoConfig {
|
|
|
21
30
|
gitTimeoutMs?: number;
|
|
22
31
|
/** Cross-process lock wait timeout in milliseconds. Default 30000. */
|
|
23
32
|
lockTimeoutMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How `mem_search` scores a matched entry.
|
|
35
|
+
*
|
|
36
|
+
* - `weighted` (default): sum of per-keyword rarity weights over the recall
|
|
37
|
+
* page. A distinctive match outranks several common ones.
|
|
38
|
+
* - `count`: the previous default behaviour — one point per matched keyword plus the
|
|
39
|
+
* recency bonus. Kept for exact backwards compatibility.
|
|
40
|
+
*
|
|
41
|
+
* Both modes match the same entries; only the ordering differs.
|
|
42
|
+
*/
|
|
43
|
+
searchScoring?: SearchScoring;
|
|
24
44
|
/** @deprecated parsed for compatibility only — the memory dir is fixed at `<projectRoot>/.mem`. */
|
|
25
45
|
memDirName?: string;
|
|
26
46
|
/** @deprecated parsed for compatibility only — `.mem` always stays on main. */
|
|
@@ -28,6 +48,10 @@ export interface GitMemoConfig {
|
|
|
28
48
|
/** @deprecated parsed for compatibility only — session-start recent seeding was removed. */
|
|
29
49
|
recentContextLimit?: number;
|
|
30
50
|
}
|
|
51
|
+
/** Search scoring mode; see {@link GitMemoConfig.searchScoring}. */
|
|
52
|
+
export type SearchScoring = "count" | "weighted";
|
|
53
|
+
/** Accepted `searchScoring` values, for config validation and tool schemas. */
|
|
54
|
+
export declare const SEARCH_SCORING_MODES: readonly SearchScoring[];
|
|
31
55
|
/** One scored search hit. */
|
|
32
56
|
export interface SearchHit {
|
|
33
57
|
hash: string;
|
|
@@ -258,6 +282,18 @@ export declare function buildEntryMarkdown(input: ValidatedWriteInput, ctx: Code
|
|
|
258
282
|
* matched keyword, so freshness only reorders equal-match entries.
|
|
259
283
|
*/
|
|
260
284
|
export declare function recencyBonus(committerTime: number, nowMs?: number): number;
|
|
285
|
+
/**
|
|
286
|
+
* Rarity weight of a keyword matched by `df` of `n` candidates, in the
|
|
287
|
+
* BM25-style form already used across this project. A term matched by every
|
|
288
|
+
* candidate is worth almost nothing; a term matched by one candidate out of
|
|
289
|
+
* many is worth the most.
|
|
290
|
+
*
|
|
291
|
+
* Used by `searchScoring: "weighted"` so a distinctive match can outrank a
|
|
292
|
+
* pile of common ones. The weight is computed over the *recall page*, which
|
|
293
|
+
* costs no extra git call (the candidate commit messages are already in hand)
|
|
294
|
+
* and is the pool the ordering actually has to get right.
|
|
295
|
+
*/
|
|
296
|
+
export declare function keywordRarity(df: number, n: number): number;
|
|
261
297
|
/** ADD commit message (plan 4.1). */
|
|
262
298
|
export declare function buildAddMessage(title: string, summary: string, keywords: string[], digest: string, legacy?: boolean, kind?: "task" | "topic"): string;
|
|
263
299
|
/** DELETE commit message (plan 4.2). */
|
|
@@ -349,7 +385,7 @@ export declare class GitMemo {
|
|
|
349
385
|
* messages only, active-entry filtering, field-level matching and scoring,
|
|
350
386
|
* snapshot-stable pagination, in-process LRU.
|
|
351
387
|
*
|
|
352
|
-
* @param keywords - 1-
|
|
388
|
+
* @param keywords - 1-15 keywords (legacy callers may pass a comma-separated string).
|
|
353
389
|
* @param options.skip - pagination offset.
|
|
354
390
|
* @param options.snapshot - pass back the snapshot from the first page.
|
|
355
391
|
*/
|