@fonlan/dsh-gitmemo 0.3.2 → 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 +105 -9
- package/README.zh.md +77 -9
- package/lib/client.js +721 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +158 -15
- package/lib/index.js +414 -17
- package/lib/mem.d.ts +75 -5
- package/lib/mem.js +182 -18
- package/lib/migrate.js +2 -1
- 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;
|
|
@@ -70,7 +223,16 @@ const SEARCH_HIT = {
|
|
|
70
223
|
items: { type: "string" },
|
|
71
224
|
description: "Structured keywords stored with the entry."
|
|
72
225
|
},
|
|
73
|
-
|
|
226
|
+
kind: {
|
|
227
|
+
type: "string",
|
|
228
|
+
required: true,
|
|
229
|
+
description: "Entry kind: task (task-outcome record) or topic (aggregated topic page)."
|
|
230
|
+
},
|
|
231
|
+
score: {
|
|
232
|
+
type: "number",
|
|
233
|
+
required: true,
|
|
234
|
+
description: "Relevance: distinct matched keywords + a mild recency bonus (≤ +0.5, decaying to 0 by ~180 days)."
|
|
235
|
+
},
|
|
74
236
|
matched_keywords: {
|
|
75
237
|
type: "array",
|
|
76
238
|
required: true,
|
|
@@ -79,6 +241,29 @@ const SEARCH_HIT = {
|
|
|
79
241
|
}
|
|
80
242
|
}
|
|
81
243
|
};
|
|
244
|
+
const LINK_RESOLUTION = {
|
|
245
|
+
type: "object",
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
properties: {
|
|
248
|
+
link_hash: { type: "string", required: true, description: "Hash exactly as written inside the [[...]] link." },
|
|
249
|
+
resolved_hash: {
|
|
250
|
+
type: "string",
|
|
251
|
+
required: true,
|
|
252
|
+
description: "Hash actually read: the active version of the linked entry (empty string when unresolvable)."
|
|
253
|
+
},
|
|
254
|
+
active: {
|
|
255
|
+
type: "boolean",
|
|
256
|
+
required: true,
|
|
257
|
+
description: "True when the link still points at the active version; false = superseded or dangling."
|
|
258
|
+
},
|
|
259
|
+
file: { type: "string", description: "Entry file path inside the memory repo." },
|
|
260
|
+
title: { type: "string", description: "Target entry title." },
|
|
261
|
+
summary: { type: "string", description: "Target entry summary." },
|
|
262
|
+
keywords: { type: "array", items: { type: "string" }, description: "Target entry keywords." },
|
|
263
|
+
kind: { type: "string", description: "Target entry kind: task or topic." },
|
|
264
|
+
error: { type: "string", description: "Present when the link target could not be resolved (dangling link)." }
|
|
265
|
+
}
|
|
266
|
+
};
|
|
82
267
|
const KEYWORDS_ARRAY = {
|
|
83
268
|
type: "array",
|
|
84
269
|
items: { type: "string" },
|
|
@@ -109,6 +294,8 @@ function formatSearchResults(value) {
|
|
|
109
294
|
hit.title +
|
|
110
295
|
"|" +
|
|
111
296
|
hit.date +
|
|
297
|
+
"|kind=" +
|
|
298
|
+
hit.kind +
|
|
112
299
|
"|score=" +
|
|
113
300
|
hit.score +
|
|
114
301
|
"|matched=" +
|
|
@@ -118,6 +305,33 @@ function formatSearchResults(value) {
|
|
|
118
305
|
}
|
|
119
306
|
if (value.results.length === 0)
|
|
120
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
|
+
}
|
|
121
335
|
if (value.warning !== undefined)
|
|
122
336
|
lines.push("warning: " + value.warning);
|
|
123
337
|
for (const diagnostic of value.diagnostics ?? [])
|
|
@@ -128,7 +342,7 @@ function formatSearchResults(value) {
|
|
|
128
342
|
function registerMemTools(ctx, config) {
|
|
129
343
|
ctx.tools.register(defineTool({
|
|
130
344
|
name: "mem_search",
|
|
131
|
-
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/matched_keywords —
|
|
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.",
|
|
132
346
|
parameters: {
|
|
133
347
|
keywords: { ...KEYWORDS_ARRAY, required: true },
|
|
134
348
|
skip: {
|
|
@@ -159,6 +373,47 @@ function registerMemTools(ctx, config) {
|
|
|
159
373
|
type: "array",
|
|
160
374
|
items: { type: "string" },
|
|
161
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
|
+
}
|
|
162
417
|
}
|
|
163
418
|
}
|
|
164
419
|
},
|
|
@@ -167,11 +422,64 @@ function registerMemTools(ctx, config) {
|
|
|
167
422
|
isConcurrencySafe: () => true,
|
|
168
423
|
async execute(args, exec) {
|
|
169
424
|
const memo = await engineFor(exec, config);
|
|
170
|
-
|
|
425
|
+
const result = await memo.search(args.keywords, {
|
|
171
426
|
skip: args.skip ?? 0,
|
|
172
427
|
snapshot: args.snapshot,
|
|
173
428
|
mode: args.mode
|
|
174
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
|
+
};
|
|
175
483
|
},
|
|
176
484
|
presentCall: (args) => ({
|
|
177
485
|
card: "generic",
|
|
@@ -181,12 +489,16 @@ function registerMemTools(ctx, config) {
|
|
|
181
489
|
}));
|
|
182
490
|
ctx.tools.register(defineTool({
|
|
183
491
|
name: "mem_read",
|
|
184
|
-
description: "Read one gitmemo memory entry by commit hash (returned by mem_search). Select only the most relevant memories before reading — at most 5 when a search returns more. Historical entries that were replaced or deleted stay readable for audit.",
|
|
492
|
+
description: "Read one gitmemo memory entry by commit hash (returned by mem_search). Select only the most relevant memories before reading — at most 5 when a search returns more. Historical entries that were replaced or deleted stay readable for audit. Pass expand:true to also resolve [[<commit-hash>]] wiki links in the entry body one hop to their target entries (each target follows forward to its active version) — use it when reading a kind:\"topic\" topic page or any entry that links others.",
|
|
185
493
|
parameters: {
|
|
186
494
|
commit_hash: {
|
|
187
495
|
type: "string",
|
|
188
496
|
required: true,
|
|
189
497
|
description: "Commit hash of the memory entry to read (create or replace hash)."
|
|
498
|
+
},
|
|
499
|
+
expand: {
|
|
500
|
+
type: "boolean",
|
|
501
|
+
description: "Resolve [[<commit-hash>]] links in the entry body one hop to their (active) target entries. Default false."
|
|
190
502
|
}
|
|
191
503
|
},
|
|
192
504
|
output: {
|
|
@@ -197,16 +509,57 @@ function registerMemTools(ctx, config) {
|
|
|
197
509
|
commit_hash: { type: "string", required: true },
|
|
198
510
|
file: { type: "string", required: true, description: "Entry file path inside the memory repo." },
|
|
199
511
|
content: { type: "string", required: true, description: "Full markdown of the memory entry." },
|
|
512
|
+
kind: {
|
|
513
|
+
type: "string",
|
|
514
|
+
required: true,
|
|
515
|
+
description: "Entry kind: task (task-outcome record) or topic (aggregated topic page)."
|
|
516
|
+
},
|
|
517
|
+
links: {
|
|
518
|
+
type: "array",
|
|
519
|
+
items: LINK_RESOLUTION,
|
|
520
|
+
description: "One-hop resolution of the entry's [[<commit-hash>]] links; present only when expand:true."
|
|
521
|
+
},
|
|
200
522
|
legacy: { type: "boolean", description: "True for legacy-format commits (pre-migration)." }
|
|
201
523
|
}
|
|
202
524
|
},
|
|
203
|
-
render: (_args, value) =>
|
|
525
|
+
render: (_args, value) => {
|
|
526
|
+
const parts = [(value.legacy === true ? "[legacy entry]\n" : "") + value.content];
|
|
527
|
+
if (value.links !== undefined && value.links.length > 0) {
|
|
528
|
+
parts.push("--- linked entries (" + value.links.length + ") ---");
|
|
529
|
+
for (const link of value.links) {
|
|
530
|
+
if (link.error !== undefined) {
|
|
531
|
+
parts.push("x " + link.link_hash.slice(0, 8) + " — unresolved: " + link.error);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
const state = link.active
|
|
535
|
+
? "active"
|
|
536
|
+
: link.resolved_hash !== link.link_hash
|
|
537
|
+
? "superseded → " + link.resolved_hash.slice(0, 8)
|
|
538
|
+
: "not active (withdrawn)";
|
|
539
|
+
parts.push("= " + link.resolved_hash.slice(0, 8) + " [" + (link.kind ?? "task") + "] " +
|
|
540
|
+
(link.title ?? "") + " (" + state + ")");
|
|
541
|
+
if (link.summary !== undefined && link.summary.length > 0) {
|
|
542
|
+
parts.push(" " + link.summary.replace(/\s+/g, " ").slice(0, 400));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return textBlock(parts.join("\n"));
|
|
547
|
+
}
|
|
204
548
|
},
|
|
205
549
|
isConcurrencySafe: () => true,
|
|
206
550
|
async execute(args, exec) {
|
|
207
551
|
const memo = await engineFor(exec, config);
|
|
208
|
-
const { hash, file, content, legacy } = await memo.read(args.commit_hash
|
|
209
|
-
|
|
552
|
+
const { hash, file, content, kind, links, legacy } = await memo.read(args.commit_hash, {
|
|
553
|
+
expand: args.expand === true
|
|
554
|
+
});
|
|
555
|
+
return {
|
|
556
|
+
commit_hash: hash,
|
|
557
|
+
file,
|
|
558
|
+
content,
|
|
559
|
+
kind,
|
|
560
|
+
...(links !== undefined ? { links } : {}),
|
|
561
|
+
...(legacy ? { legacy: true } : {})
|
|
562
|
+
};
|
|
210
563
|
},
|
|
211
564
|
presentCall: (args) => ({
|
|
212
565
|
card: "generic",
|
|
@@ -216,7 +569,7 @@ function registerMemTools(ctx, config) {
|
|
|
216
569
|
}));
|
|
217
570
|
ctx.tools.register(defineTool({
|
|
218
571
|
name: "mem_write",
|
|
219
|
-
description: "Store a completed task outcome in the gitmemo long-term memory (.mem git repo). Write ONLY when the task is complete AND repo-related AND the outcome is valuable/reusable OR the user explicitly asked to remember. Entries are immutable: title (single line, ≤200 chars, \"[module] action + object\"), summary (≤4000 chars), keywords (2-12, include 中英文同义词) and content (markdown body; the engine generates front matter) are all required; related_branches/related_paths are optional. Keywords must ADD new search angles — pick task-related terms (中英文同义词 both fine) that are NOT already present in title/summary: words already in title/summary are searchable via those fields themselves, so repeating them does not improve recall. Corrections use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
|
|
572
|
+
description: "Store a completed task outcome in the gitmemo long-term memory (.mem git repo). Write ONLY when the task is complete AND repo-related AND the outcome is valuable/reusable OR the user explicitly asked to remember. Entries are immutable: title (single line, ≤200 chars, \"[module] action + object\"), summary (≤4000 chars), keywords (2-12, include 中英文同义词) and content (markdown body; the engine generates front matter) are all required; related_branches/related_paths are optional. kind selects the entry type: \"task\" (default) is a task-outcome record; \"topic\" is an aggregated topic page (MOC) — its summary states the currently-valid conclusions and its content links evidence entries as [[<commit-hash>]] with a one-line status each; a topic page evolves via mem_replace, not per-task rewrites. Keywords must ADD new search angles — pick task-related terms (中英文同义词 both fine) that are NOT already present in title/summary: words already in title/summary are searchable via those fields themselves, so repeating them does not improve recall. Corrections and superseded conclusions use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
|
|
220
573
|
parameters: {
|
|
221
574
|
title: {
|
|
222
575
|
type: "string",
|
|
@@ -236,7 +589,11 @@ function registerMemTools(ctx, config) {
|
|
|
236
589
|
content: {
|
|
237
590
|
type: "string",
|
|
238
591
|
required: true,
|
|
239
|
-
description: "The memory markdown body (engine generates front matter and standard sections)."
|
|
592
|
+
description: "The memory markdown body (engine generates front matter and standard sections). Reference other entries as [[<commit-hash>]]."
|
|
593
|
+
},
|
|
594
|
+
kind: {
|
|
595
|
+
type: "string",
|
|
596
|
+
description: "Entry kind: \"task\" (default) or \"topic\" (aggregated topic page linking evidence entries via [[<commit-hash>]])."
|
|
240
597
|
},
|
|
241
598
|
related_branches: RELATED_BRANCHES,
|
|
242
599
|
related_paths: RELATED_PATHS
|
|
@@ -265,7 +622,8 @@ function registerMemTools(ctx, config) {
|
|
|
265
622
|
keywords: args.keywords ?? [],
|
|
266
623
|
content: args.content ?? "",
|
|
267
624
|
related_branches: args.related_branches,
|
|
268
|
-
related_paths: args.related_paths
|
|
625
|
+
related_paths: args.related_paths,
|
|
626
|
+
kind: args.kind
|
|
269
627
|
});
|
|
270
628
|
},
|
|
271
629
|
presentCall: (args) => ({
|
|
@@ -313,7 +671,7 @@ function registerMemTools(ctx, config) {
|
|
|
313
671
|
}));
|
|
314
672
|
ctx.tools.register(defineTool({
|
|
315
673
|
name: "mem_replace",
|
|
316
|
-
description: "Replace an active gitmemo memory entry with a new conclusion in ONE atomic commit (deletes the old entry file, adds the new one). Use
|
|
674
|
+
description: "Replace an active gitmemo memory entry with a new conclusion in ONE atomic commit (deletes the old entry file, adds the new one). Use it whenever a stored conclusion is superseded — the user corrects a stored outcome, OR this session's own work overrules it (implemented/refactored away) — never delete-then-write. commit_hash must be the ACTIVE hash returned by mem_search; stale hashes are rejected. Takes the same fields as mem_write; kind defaults to the replaced entry's kind (refreshing a topic page stays a topic page).",
|
|
317
675
|
parameters: {
|
|
318
676
|
commit_hash: {
|
|
319
677
|
type: "string",
|
|
@@ -338,7 +696,11 @@ function registerMemTools(ctx, config) {
|
|
|
338
696
|
content: {
|
|
339
697
|
type: "string",
|
|
340
698
|
required: true,
|
|
341
|
-
description: "The new memory markdown body."
|
|
699
|
+
description: "The new memory markdown body. Keep [[<commit-hash>]] links to evidence entries up to date."
|
|
700
|
+
},
|
|
701
|
+
kind: {
|
|
702
|
+
type: "string",
|
|
703
|
+
description: "Entry kind: \"task\" or \"topic\". Defaults to the replaced entry's kind when omitted."
|
|
342
704
|
},
|
|
343
705
|
related_branches: RELATED_BRANCHES,
|
|
344
706
|
related_paths: RELATED_PATHS
|
|
@@ -364,7 +726,8 @@ function registerMemTools(ctx, config) {
|
|
|
364
726
|
keywords: args.keywords ?? [],
|
|
365
727
|
content: args.content ?? "",
|
|
366
728
|
related_branches: args.related_branches,
|
|
367
|
-
related_paths: args.related_paths
|
|
729
|
+
related_paths: args.related_paths,
|
|
730
|
+
kind: args.kind
|
|
368
731
|
});
|
|
369
732
|
},
|
|
370
733
|
presentCall: (args) => ({
|
|
@@ -384,10 +747,12 @@ function registerPromptSection(ctx) {
|
|
|
384
747
|
text: [
|
|
385
748
|
"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.",
|
|
386
749
|
"- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
|
|
387
|
-
"- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read
|
|
750
|
+
"- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;kind 为 topic 的条目是该主题的聚合入口,优先读;无相关结果时用返回的 snapshot + skip 翻页。",
|
|
388
751
|
"- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
|
|
389
752
|
"- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可);title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
|
|
390
|
-
"-
|
|
753
|
+
"- KEEP FRESH — 结论保鲜是写路径的一部分,不必等用户指出:用户更正旧结论时,有替代结论用 mem_replace(禁止先 delete 再 write),作废且无替代用带 reason 的 mem_delete;本次会话的工作覆盖/推翻了某条已存结论(被后续实现、重构取代)时,同样主动 mem_replace 或 mem_delete 对应旧条目;检索结果中新旧结论冲突时,优先采信 score 更高/日期更新的条目(score 已含温和的 recency 加成)。",
|
|
754
|
+
"- TOPIC PAGE — 同一主题已积累多条记忆、或需要一个「当前真相」入口时,用 mem_write 写一条 kind:\"topic\" 聚合条目:summary 概括当下仍然有效的结论,content 用 [[<commit-hash>]] 链接各证据条目并各配一句话状态;topic 条目靠 mem_replace 演进(kind 自动继承),不是按任务新增。",
|
|
755
|
+
"- WIKI LINKS — content 中引用其他记忆条目一律写 [[<commit-hash>]](用 mem_search/mem_read 返回的 hash);mem_read 传 expand:true 会把链接一跳展开到各条目的最新版本(悬空链接以 error 返回),读 topic 条目时建议展开。",
|
|
391
756
|
"- SUBAGENT RESULTS — 子代理结果由根 Agent 汇总后决定是否形成一条会话级记忆。"
|
|
392
757
|
].join("\n")
|
|
393
758
|
});
|
|
@@ -398,10 +763,42 @@ function registerPromptSection(ctx) {
|
|
|
398
763
|
* registration time.
|
|
399
764
|
*/
|
|
400
765
|
async function apply(ctx, config = {}) {
|
|
766
|
+
const log = (message) => {
|
|
767
|
+
if (typeof ctx.logger.info === "function")
|
|
768
|
+
ctx.logger.info(message);
|
|
769
|
+
};
|
|
401
770
|
const resolved = {
|
|
402
771
|
searchLimit: config.searchLimit ?? 20,
|
|
403
772
|
lockTimeoutMs: config.lockTimeoutMs ?? 30000,
|
|
404
|
-
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
|
+
}
|
|
405
802
|
};
|
|
406
803
|
if (config.branchAlign !== undefined && config.branchAlign !== true) {
|
|
407
804
|
ctx.logger.warn("dsh-gitmemo: `branchAlign` is deprecated and has no effect — .mem always stays on main");
|