@fonlan/dsh-gitmemo 0.1.5 → 0.3.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/lib/index.js CHANGED
@@ -2,34 +2,43 @@
2
2
  * dsh-gitmemo — Git-backed long-term memory for DeepSeek Harness.
3
3
  *
4
4
  * A Cordis plugin mirroring gitmemo (https://github.com/fonlan/gitmemo):
5
- * the agent stores completed task outcomes as markdown entries in a local
6
- * `.mem` git repository and searches them before starting new work.
5
+ * the root agent stores completed task outcomes as immutable markdown
6
+ * entries in a local `.mem` git repository (single `main` branch, structured
7
+ * commit messages) and searches them before starting new work.
7
8
  *
8
- * This plugin registers on the host plane:
9
- * - five model-facing tools: `mem_init`, `mem_search`, `mem_read`,
10
- * `mem_write`, `mem_delete` (backed by {@link GitMemo});
11
- * - an always-on system-prompt section with the full memory workflow rules;
12
- * - a per-session seed of the most recent memory titles.
9
+ * Mechanism:
10
+ * - only ROOT agents (delegationDepth === 0) receive the gitmemo workflow
11
+ * section and the five `mem_*` tools, scoped into `agent.ctx` at
12
+ * `agent/created`; subagents carry neither (no token cost, no duplicate
13
+ * memory writes);
14
+ * - registration performs zero Git I/O;
15
+ * - no session-start seeding, no synchronous git runners, no branch
16
+ * alignment — `.mem` permanently stays on `main`;
17
+ * - the previous `mem_init` tool is gone from the model surface; legacy
18
+ * argument shapes are accepted for one version by the core engine
19
+ * compatibility adapter, while the model-facing schemas stay strict.
13
20
  *
14
21
  * @module dsh-gitmemo
15
22
  */
16
23
  import z from "@deepseek-ai/schemastery";
17
24
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
- import { GitMemo } from "./mem.js";
25
+ import { GitMemo, GitMemoError, resolveProjectRoot } from "./mem.js";
19
26
  /** Cordis plugin name. */
20
27
  const name = "dsh-gitmemo";
21
28
  /** Host services this plugin needs. */
22
29
  const inject = ["tools", "systemPrompt"];
23
- /** Plugin configuration (all optional). */
30
+ /** Plugin configuration. */
24
31
  const Config = z.object({
25
- /** Name of the memory repo directory at the project root. Default ".mem". */
32
+ /** @deprecated — the memory dir is fixed at `<projectRoot>/.mem`; kept for one version. */
26
33
  memDirName: z.string().default(".mem"),
27
- /** Max search hits per mem_search call. Default 20. */
34
+ /** Max search hits per mem_search call (page size). Default 20. */
28
35
  searchLimit: z.number().default(20),
29
- /** Align the .mem branch with the project branch on writes. Default true. */
36
+ /** @deprecated — `.mem` always stays on main; kept for one version, no effect. */
30
37
  branchAlign: z.boolean().default(true),
31
- /** Number of most recent memory titles injected into each new session's system prompt (0 disables). Default 5. */
38
+ /** @deprecated — session-start recent seeding was removed; kept for one version, no effect. */
32
39
  recentContextLimit: z.number().default(5),
40
+ /** Cross-process lock wait timeout in milliseconds. Default 30000. */
41
+ lockTimeoutMs: z.number().default(30000),
33
42
  /** Optional explicit project root; defaults to the calling session's cwd. */
34
43
  projectRoot: z.string()
35
44
  });
@@ -37,14 +46,13 @@ const Config = z.object({
37
46
  function sessionCwd(exec) {
38
47
  return exec.agent?.session?.header?.cwd;
39
48
  }
40
- /** Build the engine for one tool call. */
41
- function engineFor(exec, config) {
49
+ /** Build the engine for one tool call (project root per plan 3.1). */
50
+ async function engineFor(exec, config) {
42
51
  const cwd = sessionCwd(exec) ?? process.cwd();
43
- const root = config.projectRoot ?? cwd;
52
+ const root = config.projectRoot ?? (await resolveProjectRoot(cwd));
44
53
  const engineConfig = {
45
- memDirName: config.memDirName,
46
54
  searchLimit: config.searchLimit,
47
- branchAlign: config.branchAlign
55
+ lockTimeoutMs: config.lockTimeoutMs
48
56
  };
49
57
  return new GitMemo(root, engineConfig);
50
58
  }
@@ -53,60 +61,83 @@ const SEARCH_HIT = {
53
61
  additionalProperties: false,
54
62
  properties: {
55
63
  hash: { type: "string", required: true, description: "Full commit hash of the memory entry." },
56
- title: { type: "string", required: true, description: "Commit subject (the entry title)." },
57
- date: { type: "string", required: true, description: "Commit date in ISO format." }
64
+ title: { type: "string", required: true, description: "Entry title (commit subject)." },
65
+ date: { type: "string", required: true, description: "Commit date in ISO format." },
66
+ summary: { type: "string", required: true, description: "Entry summary (from the commit body)." },
67
+ keywords: {
68
+ type: "array",
69
+ required: true,
70
+ items: { type: "string" },
71
+ description: "Structured keywords stored with the entry."
72
+ },
73
+ score: { type: "number", required: true, description: "Number of distinct query keywords matched." },
74
+ matched_keywords: {
75
+ type: "array",
76
+ required: true,
77
+ items: { type: "string" },
78
+ description: "The query keywords that actually matched this entry."
79
+ }
58
80
  }
59
81
  };
82
+ const KEYWORDS_ARRAY = {
83
+ type: "array",
84
+ items: { type: "string" },
85
+ description: "1-12 keywords (include 中英文 synonyms when useful)."
86
+ };
87
+ const RELATED_BRANCHES = {
88
+ type: "array",
89
+ items: { type: "string" },
90
+ description: "Related code branches (max 32; the current branch is always recorded)."
91
+ };
92
+ const RELATED_PATHS = {
93
+ type: "array",
94
+ items: { type: "string" },
95
+ description: "Project-relative paths (max 128; absolute paths and escaping paths are rejected)."
96
+ };
60
97
  /** Render one tool output block as plain text. */
61
98
  function textBlock(text) {
62
99
  return [{ type: "text", text }];
63
100
  }
64
- /** Treat blank optional adapter values as omitted without weakening engine validation. */
65
- function blankToUndefined(value) {
66
- return value !== undefined && value.trim().length === 0 ? undefined : value;
101
+ function formatSearchResults(value) {
102
+ const lines = [
103
+ `snapshot=${value.snapshot} total=${value.total} next_skip=${value.next_skip ?? "null"}` +
104
+ (value.legacy === true ? " legacy=true" : "")
105
+ ];
106
+ for (const hit of value.results) {
107
+ lines.push(hit.hash +
108
+ "|" +
109
+ hit.title +
110
+ "|" +
111
+ hit.date +
112
+ "|score=" +
113
+ hit.score +
114
+ "|matched=" +
115
+ hit.matched_keywords.join(","));
116
+ if (hit.summary.length > 0)
117
+ lines.push(" summary: " + hit.summary.replace(/\s+/g, " ").slice(0, 300));
118
+ }
119
+ if (value.results.length === 0)
120
+ lines.push("(no matching memories)");
121
+ if (value.warning !== undefined)
122
+ lines.push("warning: " + value.warning);
123
+ for (const diagnostic of value.diagnostics ?? [])
124
+ lines.push("diagnostic: " + diagnostic);
125
+ return lines.join("\n");
67
126
  }
68
- /** Register every model-facing tool. */
127
+ /** Register the five model-facing tools into a scoped agent context. */
69
128
  function registerMemTools(ctx, config) {
70
- void config;
71
- ctx.tools.register(defineTool({
72
- name: "mem_init",
73
- description: "Initialize the gitmemo long-term memory repository (.mem git repo) at the project root. All other mem_* tools auto-initialize, so this is only needed to check or force initialization.",
74
- parameters: {},
75
- output: {
76
- schema: {
77
- type: "object",
78
- additionalProperties: false,
79
- properties: {
80
- path: { type: "string", required: true, description: "Absolute path of the memory repository." }
81
- }
82
- },
83
- render: (_args, value) => textBlock("OK: Memory repo ready at " + value.path)
84
- },
85
- isConcurrencySafe: () => false,
86
- async execute(_args, exec) {
87
- const memo = engineFor(exec, config);
88
- const path = await memo.init();
89
- return { path };
90
- },
91
- presentCall: () => ({ card: "generic", title: "Init gitmemo memory", kind: "other" })
92
- }));
93
129
  ctx.tools.register(defineTool({
94
130
  name: "mem_search",
95
- description: "Search the gitmemo long-term memory (.mem git repo) for past task outcomes. Call BEFORE starting work: extract 3-5 keywords from the user request, search, and reuse prior conclusions when relevant. Returns up to 20 hits formatted as `hash|title|date`; when nothing relevant appears, paginate with `skip` 20, 40, ...",
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 — select at most 5 most relevant hits and mem_read them. Paginate by passing skip and the returned snapshot back unchanged; a stale snapshot is rejected explicitly.",
96
132
  parameters: {
97
- keywords: {
98
- type: "string",
99
- required: true,
100
- description: "Comma-separated keywords (3-5 recommended), e.g. \"auth,rate-limit,login\"."
101
- },
133
+ keywords: { ...KEYWORDS_ARRAY, required: true },
102
134
  skip: {
103
135
  type: "integer",
104
136
  description: "Pagination offset (default 0)."
105
137
  },
106
- mode: {
138
+ snapshot: {
107
139
  type: "string",
108
- enum: ["and", "or", "auto"],
109
- description: "and=strict (all keywords), or=broad (any keyword), auto=AND first then OR fallback (default auto)."
140
+ description: "Snapshot returned by the first page — pass it back unchanged for stable pagination."
110
141
  }
111
142
  },
112
143
  output: {
@@ -114,34 +145,48 @@ function registerMemTools(ctx, config) {
114
145
  type: "object",
115
146
  additionalProperties: false,
116
147
  properties: {
117
- mode: { type: "string", required: true, description: "The mode actually used (and or or)." },
118
- results: { type: "array", required: true, items: SEARCH_HIT }
148
+ snapshot: { type: "string", required: true, description: "Stable search snapshot; reuse for pagination." },
149
+ total: { type: "number", required: true, description: "Total matching results in the snapshot." },
150
+ next_skip: {
151
+ oneOf: [{ type: "number" }, { type: "null" }],
152
+ required: true,
153
+ description: "Offset for the next page, or null."
154
+ },
155
+ results: { type: "array", required: true, items: SEARCH_HIT },
156
+ legacy: { type: "boolean", description: "True when searching a legacy (pre-migration) repo read-only." },
157
+ warning: { type: "string", description: "Compatibility/deprecation warning, when present." },
158
+ diagnostics: {
159
+ type: "array",
160
+ items: { type: "string" },
161
+ description: "Malformed commit/file mappings skipped during search."
162
+ }
119
163
  }
120
164
  },
121
- render: (_args, value) => {
122
- const lines = value.results.map((hit) => hit.hash + "|" + hit.title + "|" + hit.date);
123
- return textBlock(lines.length > 0 ? lines.join("\n") : "(no matching memories)");
124
- }
165
+ render: (_args, value) => textBlock(formatSearchResults(value))
125
166
  },
126
167
  isConcurrencySafe: () => true,
127
168
  async execute(args, exec) {
128
- const memo = engineFor(exec, config);
129
- return await memo.search(args.keywords, args.skip ?? 0, args.mode ?? "auto");
169
+ const memo = await engineFor(exec, config);
170
+ return await memo.search(args.keywords, {
171
+ skip: args.skip ?? 0,
172
+ snapshot: args.snapshot,
173
+ mode: args.mode
174
+ });
130
175
  },
131
176
  presentCall: (args) => ({
132
177
  card: "generic",
133
- title: "Search memory: " + args.keywords,
178
+ title: "Search memory: " + (Array.isArray(args.keywords) ? args.keywords.join(", ") : args.keywords),
134
179
  kind: "other"
135
180
  })
136
181
  }));
137
182
  ctx.tools.register(defineTool({
138
183
  name: "mem_read",
139
- 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.",
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.",
140
185
  parameters: {
141
186
  commit_hash: {
142
187
  type: "string",
143
188
  required: true,
144
- description: "Commit hash of the memory entry to read."
189
+ description: "Commit hash of the memory entry to read (create or replace hash)."
145
190
  }
146
191
  },
147
192
  output: {
@@ -151,16 +196,17 @@ function registerMemTools(ctx, config) {
151
196
  properties: {
152
197
  commit_hash: { type: "string", required: true },
153
198
  file: { type: "string", required: true, description: "Entry file path inside the memory repo." },
154
- content: { type: "string", required: true, description: "Full markdown of the memory entry." }
199
+ content: { type: "string", required: true, description: "Full markdown of the memory entry." },
200
+ legacy: { type: "boolean", description: "True for legacy-format commits (pre-migration)." }
155
201
  }
156
202
  },
157
- render: (_args, value) => textBlock(value.content)
203
+ render: (_args, value) => textBlock((value.legacy === true ? "[legacy entry]\n" : "") + value.content)
158
204
  },
159
205
  isConcurrencySafe: () => true,
160
206
  async execute(args, exec) {
161
- const memo = engineFor(exec, config);
162
- const { file, content } = await memo.read(args.commit_hash);
163
- return { commit_hash: args.commit_hash, file, content };
207
+ const memo = await engineFor(exec, config);
208
+ const { hash, file, content, legacy } = await memo.read(args.commit_hash);
209
+ return { commit_hash: hash, file, content, ...(legacy ? { legacy: true } : {}) };
164
210
  },
165
211
  presentCall: (args) => ({
166
212
  card: "generic",
@@ -170,29 +216,30 @@ function registerMemTools(ctx, config) {
170
216
  }));
171
217
  ctx.tools.register(defineTool({
172
218
  name: "mem_write",
173
- 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. Entry markdown goes through `content` (or `content_file`); `title` is a short \"[module] action + object\"; `body` is an optional 1-3 sentence commit body (never memory content). The entry is committed as `.mem/entries/<timestamp>-<slug>.md` and the .mem branch follows the project branch.",
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. Corrections use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
174
220
  parameters: {
175
221
  title: {
176
222
  type: "string",
177
223
  required: true,
178
- description: "Short \"[module] action + object\" title, e.g. \"[auth] add rate-limit for login\"."
224
+ description: "Short \"[module] action + object\" title, single line, e.g. \"[auth] add rate-limit for login\"."
179
225
  },
180
- content: {
226
+ summary: {
181
227
  type: "string",
182
- description: "The memory entry markdown (YAML front matter + sections). Provide exactly one of content / content_file / file."
228
+ required: true,
229
+ description: "1-3 sentence summary of the outcome (multi-line allowed, ≤4000 chars)."
183
230
  },
184
- content_file: {
185
- type: "string",
186
- description: "Path to a file holding the entry markdown (alternative to content; temp files are deleted after a successful write)."
231
+ keywords: {
232
+ ...KEYWORDS_ARRAY,
233
+ required: true,
234
+ description: "2-12 keywords (1-64 chars each); include 中英文同义词, e.g. [\"auth\", \"rate-limit\", \"登录\", \"限流\"]."
187
235
  },
188
- file: {
236
+ content: {
189
237
  type: "string",
190
- description: "An existing .mem/entries/... path to commit in place (alternative to content / content_file)."
238
+ required: true,
239
+ description: "The memory markdown body (engine generates front matter and standard sections)."
191
240
  },
192
- body: {
193
- type: "string",
194
- description: "Optional commit body: 1-3 sentence summary + metadata, passed inline. Not memory content."
195
- }
241
+ related_branches: RELATED_BRANCHES,
242
+ related_paths: RELATED_PATHS
196
243
  },
197
244
  output: {
198
245
  schema: {
@@ -200,25 +247,25 @@ function registerMemTools(ctx, config) {
200
247
  additionalProperties: false,
201
248
  properties: {
202
249
  hash: { type: "string", required: true, description: "Commit hash of the new memory entry." },
203
- file: { type: "string", required: true, description: "Entry file path inside the memory repo." }
250
+ file: { type: "string", required: true, description: "Entry file path inside the memory repo." },
251
+ legacy: { type: "boolean", description: "True when written through the legacy compatibility adapter." }
204
252
  }
205
253
  },
206
- render: (_args, value) => textBlock("OK: " + value.hash + "|" + value.file)
254
+ render: (_args, value) => textBlock("OK: " + value.hash + "|" + value.file + (value.legacy === true ? " (legacy adapter)" : ""))
207
255
  },
208
256
  isConcurrencySafe: () => false,
209
- async execute(
210
- // body_file is not model-facing anymore (removed from parameters above);
211
- // it is still accepted here so legacy logged calls / direct dispatches map
212
- // without breaking, and the engine still guards body vs body_file.
213
- args, exec) {
214
- const memo = engineFor(exec, config);
257
+ async execute(args, exec) {
258
+ const memo = await engineFor(exec, config);
259
+ if (args.file !== undefined) {
260
+ throw new GitMemoError("gitmemo: mem_write `file` (in-place commit) is not supported by the immutable entry model — pass content directly, or use mem_replace to update an entry");
261
+ }
215
262
  return await memo.write({
216
263
  title: args.title,
217
- content: args.content,
218
- contentFile: blankToUndefined(args.content_file),
219
- file: blankToUndefined(args.file),
220
- body: args.body,
221
- bodyFile: blankToUndefined(args.body_file)
264
+ summary: args.summary ?? "",
265
+ keywords: args.keywords ?? [],
266
+ content: args.content ?? "",
267
+ related_branches: args.related_branches,
268
+ related_paths: args.related_paths
222
269
  });
223
270
  },
224
271
  presentCall: (args) => ({
@@ -229,12 +276,17 @@ function registerMemTools(ctx, config) {
229
276
  }));
230
277
  ctx.tools.register(defineTool({
231
278
  name: "mem_delete",
232
- description: "Delete a gitmemo memory entry by commit hash. Use when the user is unsatisfied with a stored outcome: delete, redo the task from feedback, then mem_write a corrected entry.",
279
+ description: "Withdraw a gitmemo memory entry whose conclusion is obsolete and has NO replacement. Requires the active commit hash (returned by mem_search) and a required reason. Use mem_replace when a new conclusion replaces the old one.",
233
280
  parameters: {
234
281
  commit_hash: {
235
282
  type: "string",
236
283
  required: true,
237
- description: "Commit hash of the memory entry to delete."
284
+ description: "Active commit hash of the memory entry to withdraw."
285
+ },
286
+ reason: {
287
+ type: "string",
288
+ required: true,
289
+ description: "Why the conclusion is withdrawn (recorded in the delete commit)."
238
290
  }
239
291
  },
240
292
  output: {
@@ -249,8 +301,8 @@ function registerMemTools(ctx, config) {
249
301
  },
250
302
  isConcurrencySafe: () => false,
251
303
  async execute(args, exec) {
252
- const memo = engineFor(exec, config);
253
- const file = await memo.delete(args.commit_hash);
304
+ const memo = await engineFor(exec, config);
305
+ const { file } = await memo.delete({ commit_hash: args.commit_hash, reason: args.reason });
254
306
  return { file };
255
307
  },
256
308
  presentCall: (args) => ({
@@ -259,75 +311,112 @@ function registerMemTools(ctx, config) {
259
311
  kind: "other"
260
312
  })
261
313
  }));
314
+ ctx.tools.register(defineTool({
315
+ 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 when the user corrects a stored outcome — 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.",
317
+ parameters: {
318
+ commit_hash: {
319
+ type: "string",
320
+ required: true,
321
+ description: "Active commit hash of the entry to replace."
322
+ },
323
+ title: {
324
+ type: "string",
325
+ required: true,
326
+ description: "Short \"[module] action + object\" title, single line."
327
+ },
328
+ summary: {
329
+ type: "string",
330
+ required: true,
331
+ description: "1-3 sentence summary of the new conclusion."
332
+ },
333
+ keywords: {
334
+ ...KEYWORDS_ARRAY,
335
+ required: true,
336
+ description: "2-12 keywords (1-64 chars each); include 中英文同义词."
337
+ },
338
+ content: {
339
+ type: "string",
340
+ required: true,
341
+ description: "The new memory markdown body."
342
+ },
343
+ related_branches: RELATED_BRANCHES,
344
+ related_paths: RELATED_PATHS
345
+ },
346
+ output: {
347
+ schema: {
348
+ type: "object",
349
+ additionalProperties: false,
350
+ properties: {
351
+ hash: { type: "string", required: true, description: "Commit hash of the replace commit." },
352
+ file: { type: "string", required: true, description: "New entry file path inside the memory repo." }
353
+ }
354
+ },
355
+ render: (_args, value) => textBlock("OK: " + value.hash + "|" + value.file)
356
+ },
357
+ isConcurrencySafe: () => false,
358
+ async execute(args, exec) {
359
+ const memo = await engineFor(exec, config);
360
+ return await memo.replace({
361
+ commit_hash: args.commit_hash,
362
+ title: args.title,
363
+ summary: args.summary ?? "",
364
+ keywords: args.keywords ?? [],
365
+ content: args.content ?? "",
366
+ related_branches: args.related_branches,
367
+ related_paths: args.related_paths
368
+ });
369
+ },
370
+ presentCall: (args) => ({
371
+ card: "generic",
372
+ title: "Replace memory " + args.commit_hash.slice(0, 8) + ": " + args.title,
373
+ kind: "other"
374
+ })
375
+ }));
262
376
  }
263
377
  /**
264
- * Register the always-on memory workflow rules (the equivalent of gitmemo's
265
- * agents-template.md). This section is part of every session's system prompt,
266
- * so the rules cannot be missed; the mem_* tool descriptions carry the
267
- * argument contract for each operation.
378
+ * Register the gitmemo workflow rules (plan 9.2) into a scoped agent context.
268
379
  */
269
380
  function registerPromptSection(ctx) {
270
381
  ctx.systemPrompt.section({
271
382
  name: "memory:gitmemo",
272
383
  order: 150,
273
384
  text: [
274
- "This deployment provides gitmemo long-term memory: a local .mem git repository at the project root stores past task outcomes as markdown entries; git is the only dependency. Use the dedicated tools only (mem_search / mem_read / mem_write / mem_delete — all auto-initialize); never shell out to git or read .mem files directly.",
275
- "- BEFORE WORK — search: extract 3-5 keywords from the user request and run mem_search. If more than 5 relevant hits appear, select only the 5 most likely (keyword overlap, title specificity, recency) and mem_read only those; reuse their conclusions when appropriate. If nothing relevant, paginate with skip 20, 40, ...",
276
- "- USER UNSATISFIED — delete and rewrite: mem_delete the entry's commit hash, redo the task from the feedback, then mem_write a corrected entry.",
277
- "- END-OF-SESSION CHECKPOINT — the ONLY write path: when the conversation is ending, review the whole session and mem_write EVERY completed repo-related task that still lacks a memory and whose outcome is valuable and reusable OR was explicitly asked to be remembered. Never write for pure Q&A, incomplete tasks, non-repo work, or purely operational git actions (commit/push only). Never duplicate an already-written entry; if a stored outcome is outdated, mem_delete it first, then write the corrected entry. Entry title: \"[module] action + object\"; content: markdown with YAML front matter (date, status, repo_branch, mem_branch, related_paths, tags) and Original User Request / AI Understanding / Final Outcome sections."
385
+ "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); never shell out to git or read .mem files directly.",
386
+ "- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
387
+ "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;无相关结果时用返回的 snapshot + skip 翻页。",
388
+ "- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
389
+ "- USER CORRECTION — 用户更正已有结论:有替代结论时调用 mem_replace(不得先 delete 再 write);结论作废且无替代时调用带 reason 的 mem_delete。",
390
+ "- SUBAGENT RESULTS — 子代理结果由根 Agent 汇总后决定是否形成一条会话级记忆。"
278
391
  ].join("\n")
279
392
  });
280
393
  }
281
394
  /**
282
- * Seed every new ROOT agent session with the most recent memory titles.
283
- * Registered into the agent's own scope, so the block joins only that
284
- * session's prompt; subagents (delegationDepth > 0) are skipped, the lookup
285
- * is read-only (no .mem auto-init), and failures degrade to a log warning.
286
- */
287
- function registerRecentContext(ctx, config) {
288
- if (config.recentContextLimit <= 0)
289
- return;
290
- ctx.on("agent/created", ({ agent }) => {
291
- const header = agent.session?.header;
292
- if (header?.cwd === undefined || (header.delegationDepth ?? 0) > 0)
293
- return;
294
- try {
295
- // Synchronous scan: registers the context before the first request assembles.
296
- const memo = new GitMemo(config.projectRoot ?? header.cwd, {
297
- memDirName: config.memDirName,
298
- searchLimit: config.searchLimit,
299
- branchAlign: config.branchAlign
300
- });
301
- const hits = memo.recentSync(config.recentContextLimit);
302
- if (hits.length === 0)
303
- return;
304
- const lines = hits.map((hit) => hit.hash + "|" + hit.title + "|" + hit.date);
305
- agent.ctx.systemPrompt.context({
306
- name: "memory:gitmemo-recent",
307
- order: 400,
308
- text: "Recent gitmemo memories from previous sessions (mem_read <hash> for details, mem_search for targeted lookups):\n" + lines.join("\n")
309
- });
310
- }
311
- catch (error) {
312
- ctx.logger.warn(`dsh-gitmemo: recent-memory injection skipped for agent "${agent.id}": ${String(error)}`);
313
- }
314
- });
315
- }
316
- /**
317
- * Register the plugin: tools, always-on rules, and session-start seed.
318
- * @param ctx - registrant context carrying the host tool/prompt services.
319
- * @param config - plugin configuration (defaults applied by the loader).
395
+ * Register the plugin: root-agent-scoped mem tools + workflow rules. No
396
+ * global tool registration, no session-start seeding, no Git I/O at
397
+ * registration time.
320
398
  */
321
399
  async function apply(ctx, config = {}) {
322
400
  const resolved = {
323
- memDirName: config.memDirName ?? ".mem",
324
401
  searchLimit: config.searchLimit ?? 20,
325
- branchAlign: config.branchAlign ?? true,
326
- recentContextLimit: config.recentContextLimit ?? 5,
402
+ lockTimeoutMs: config.lockTimeoutMs ?? 30000,
327
403
  projectRoot: config.projectRoot
328
404
  };
329
- registerMemTools(ctx, resolved);
330
- registerPromptSection(ctx);
331
- registerRecentContext(ctx, resolved);
405
+ if (config.branchAlign !== undefined && config.branchAlign !== true) {
406
+ ctx.logger.warn("dsh-gitmemo: `branchAlign` is deprecated and has no effect — .mem always stays on main");
407
+ }
408
+ if (config.recentContextLimit !== undefined && config.recentContextLimit !== 5) {
409
+ ctx.logger.warn("dsh-gitmemo: `recentContextLimit` is deprecated and has no effect — session-start recent seeding was removed");
410
+ }
411
+ if (config.memDirName !== undefined && config.memDirName !== ".mem") {
412
+ ctx.logger.warn("dsh-gitmemo: `memDirName` is deprecated — the memory directory is fixed at <projectRoot>/.mem; pass a legacy dir as the migration --source");
413
+ }
414
+ ctx.on("agent/created", ({ agent }) => {
415
+ const header = agent.session?.header;
416
+ if ((header?.delegationDepth ?? 0) > 0)
417
+ return; // subagents: no gitmemo workflow or tools
418
+ registerMemTools(agent.ctx, resolved);
419
+ registerPromptSection(agent.ctx);
420
+ });
332
421
  }
333
422
  export { Config, apply, inject, name };