@fonlan/dsh-gitmemo 0.3.0 → 0.3.2
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 +3 -1
- package/README.zh.md +1 -1
- package/lib/cli.js +5 -0
- package/lib/index.js +5 -4
- package/lib/mem.d.ts +16 -3
- package/lib/mem.js +62 -28
- package/lib/migrate.d.ts +22 -0
- package/lib/migrate.js +362 -278
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -103,7 +103,9 @@ git -C .mem show <commit-hash>
|
|
|
103
103
|
`mem_write` every completed repo-related task that still lacks a memory and whose outcome is
|
|
104
104
|
**valuable/reusable** (or was explicitly asked to be remembered). Never duplicate an
|
|
105
105
|
already-written entry. Never write for pure Q&A, incomplete tasks, non-repo work, or purely
|
|
106
|
-
operational git actions.
|
|
106
|
+
operational git actions. `keywords` should be task-related words NOT already present in
|
|
107
|
+
`title` / `summary` (中英文同义词 both fine) — words already there are searchable via
|
|
108
|
+
`title` / `summary` themselves, so repeating them does not improve recall.
|
|
107
109
|
4. **User correction.** If a stored conclusion is outdated and a replacement exists →
|
|
108
110
|
`mem_replace` (never delete-then-write). If it is obsolete with no replacement →
|
|
109
111
|
`mem_delete` with a `reason`.
|
package/README.zh.md
CHANGED
|
@@ -94,7 +94,7 @@ git -C .mem show <commit-hash>
|
|
|
94
94
|
`mem_read` 5 条最相关记忆;用 `skip` + 返回的 `snapshot` 翻页。
|
|
95
95
|
3. **会话结束检查点 —— 唯一的写入路径。** 对话即将结束时,`mem_write` **每一条**已完成、与仓库
|
|
96
96
|
相关、且结论**有价值/可复用**(或用户明确要求记住)但还没有记忆的任务。绝不重复写已存在的条目。
|
|
97
|
-
纯问答、未完成任务、与仓库无关的工作、纯操作性的 git
|
|
97
|
+
纯问答、未完成任务、与仓库无关的工作、纯操作性的 git 动作一律不写。`keywords` 应选用与任务相关但未出现在 `title` / `summary` 中的词(中英文同义词皆可)——`title` / `summary` 已有的词本身就能被检索到,重复它们不会提高召回率。
|
|
98
98
|
4. **用户更正。** 已存结论过时且有替代结论 → `mem_replace`(禁止先 delete 再 write);结论作废且无
|
|
99
99
|
替代 → 带 `reason` 的 `mem_delete`。
|
|
100
100
|
5. **子代理结果。** 是否形成一条会话级记忆,由根 Agent 汇总后决定。
|
package/lib/cli.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* dsh-gitmemo migrate --project-root <path> [--dry-run|--apply]
|
|
6
6
|
* [--source <legacy-mem-dir>] [--out <baseline-file>] [--baseline <file>]
|
|
7
7
|
*
|
|
8
|
+
* Migration is AUTOMATIC inside the engine (the first operation on a legacy
|
|
9
|
+
* `.mem` repo migrates it in place). This CLI remains for explicit control:
|
|
10
|
+
* non-default --source directories, baseline review, and repos where
|
|
11
|
+
* automatic migration is blocked (conflicts, dirty worktree, ...).
|
|
12
|
+
*
|
|
8
13
|
* Dry-run scans the legacy `.mem` repo without touching any ref/index/work
|
|
9
14
|
* tree state and prints the plan (dedupe, conflicts, estimated commits).
|
|
10
15
|
* `--apply` re-verifies the baseline refs, then migrates inside the
|
package/lib/index.js
CHANGED
|
@@ -216,7 +216,7 @@ function registerMemTools(ctx, config) {
|
|
|
216
216
|
}));
|
|
217
217
|
ctx.tools.register(defineTool({
|
|
218
218
|
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. Corrections use mem_replace, withdrawal uses mem_delete — never rewrite in place.",
|
|
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.",
|
|
220
220
|
parameters: {
|
|
221
221
|
title: {
|
|
222
222
|
type: "string",
|
|
@@ -231,7 +231,7 @@ function registerMemTools(ctx, config) {
|
|
|
231
231
|
keywords: {
|
|
232
232
|
...KEYWORDS_ARRAY,
|
|
233
233
|
required: true,
|
|
234
|
-
description: "2-12 keywords (1-64 chars each); include 中英文同义词, e.g. [\"auth\", \"rate-limit\", \"登录\", \"限流\"]."
|
|
234
|
+
description: "2-12 keywords (1-64 chars each); task-related words NOT already present in title/summary (重复 title/summary 已有的词不会提高召回率); include 中英文同义词, e.g. [\"auth\", \"rate-limit\", \"登录\", \"限流\"]."
|
|
235
235
|
},
|
|
236
236
|
content: {
|
|
237
237
|
type: "string",
|
|
@@ -333,7 +333,7 @@ function registerMemTools(ctx, config) {
|
|
|
333
333
|
keywords: {
|
|
334
334
|
...KEYWORDS_ARRAY,
|
|
335
335
|
required: true,
|
|
336
|
-
description: "2-12 keywords (1-64 chars each); include 中英文同义词."
|
|
336
|
+
description: "2-12 keywords (1-64 chars each); task-related words NOT already present in title/summary; include 中英文同义词."
|
|
337
337
|
},
|
|
338
338
|
content: {
|
|
339
339
|
type: "string",
|
|
@@ -382,10 +382,11 @@ function registerPromptSection(ctx) {
|
|
|
382
382
|
name: "memory:gitmemo",
|
|
383
383
|
order: 150,
|
|
384
384
|
text: [
|
|
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.",
|
|
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, and legacy-format .mem repos migrate automatically on first use); never shell out to git or read .mem files directly.",
|
|
386
386
|
"- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
|
|
387
387
|
"- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;无相关结果时用返回的 snapshot + skip 翻页。",
|
|
388
388
|
"- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
|
|
389
|
+
"- KEYWORD SELECTION — mem_write/mem_replace 的 keywords 应选择与任务相关但未出现在 title/summary 中的词(中英文同义词皆可);title/summary 里已有的词本身就能被检索到,重复它们不会提高召回率。",
|
|
389
390
|
"- USER CORRECTION — 用户更正已有结论:有替代结论时调用 mem_replace(不得先 delete 再 write);结论作废且无替代时调用带 reason 的 mem_delete。",
|
|
390
391
|
"- SUBAGENT RESULTS — 子代理结果由根 Agent 汇总后决定是否形成一条会话级记忆。"
|
|
391
392
|
].join("\n")
|
package/lib/mem.d.ts
CHANGED
|
@@ -262,16 +262,29 @@ export declare function rollbackJournal(memDir: string, journal: TransactionJour
|
|
|
262
262
|
export declare function ensureInit(root: string, memDir: string, gitTimeoutMs: number): Promise<void>;
|
|
263
263
|
/**
|
|
264
264
|
* Git-backed long-term memory engine operating on one project root.
|
|
265
|
-
* Every operation auto-initializes a new-format repo; legacy repos
|
|
266
|
-
*
|
|
265
|
+
* Every operation auto-initializes a new-format repo; legacy `.mem` repos
|
|
266
|
+
* are migrated automatically on first use (no external CLI needed). Only
|
|
267
|
+
* when automatic migration is blocked (conflicts, dirty worktree, ...) do
|
|
268
|
+
* legacy repos stay read-only for search/read with a clear warning.
|
|
267
269
|
*/
|
|
268
270
|
export declare class GitMemo {
|
|
269
271
|
readonly root: string;
|
|
270
272
|
readonly memDir: string;
|
|
271
273
|
private readonly config;
|
|
272
274
|
constructor(root: string, config?: GitMemoConfig);
|
|
273
|
-
/** Initialize the memory repo when missing (idempotent). */
|
|
275
|
+
/** Initialize the memory repo when missing (idempotent). Legacy repos are migrated automatically. */
|
|
274
276
|
init(): Promise<string>;
|
|
277
|
+
/**
|
|
278
|
+
* Fully automatic legacy migration (no external CLI needed): when `.mem`
|
|
279
|
+
* exists in legacy format, rebuild it into the new format in place —
|
|
280
|
+
* dry-run + apply in one step. The caller must already hold the operation
|
|
281
|
+
* lock. Blocking conditions (conflicts, dirty worktree, interrupted legacy
|
|
282
|
+
* journal, ...) are returned, never thrown, so operations can fall back to
|
|
283
|
+
* legacy read-only behavior; the next operation retries automatically.
|
|
284
|
+
*/
|
|
285
|
+
private autoMigrateIfNeeded;
|
|
286
|
+
/** Ensure the repo is initialized and writable (new format), migrating legacy repos automatically. */
|
|
287
|
+
private ensureWritable;
|
|
275
288
|
/**
|
|
276
289
|
* Search past memories (plan 6): fixed-string OR grep recall over commit
|
|
277
290
|
* messages only, active-entry filtering, field-level matching and scoring,
|
package/lib/mem.js
CHANGED
|
@@ -717,9 +717,13 @@ export async function recoverMigrationSwap(root, gitTimeoutMs) {
|
|
|
717
717
|
function sleep(ms) {
|
|
718
718
|
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
719
719
|
}
|
|
720
|
-
function legacyWarning(
|
|
721
|
-
return ("gitmemo: .mem is in legacy format — search is read-only over the current branch
|
|
722
|
-
|
|
720
|
+
function legacyWarning() {
|
|
721
|
+
return ("gitmemo: .mem is in legacy format and automatic migration is blocked — search is read-only over the current branch");
|
|
722
|
+
}
|
|
723
|
+
/** Refusal message for write/delete/replace when automatic migration cannot run. */
|
|
724
|
+
function legacyWriteRefusal(outcome) {
|
|
725
|
+
const detail = outcome.blocked?.detail ?? "unknown blocking condition";
|
|
726
|
+
return ("gitmemo: .mem exists in legacy format — write operations are disabled and automatic migration is blocked: " + detail);
|
|
723
727
|
}
|
|
724
728
|
/** Detect the repo format from `.gitmemo-format` at the given snapshot. */
|
|
725
729
|
export async function repoFormat(memDir, gitTimeoutMs, snapshot = "HEAD") {
|
|
@@ -804,9 +808,9 @@ async function resolveSnapshot(memDir, gitTimeoutMs, root, input) {
|
|
|
804
808
|
if (!(await gitOk(memDir, ["merge-base", "--is-ancestor", full, head], gitTimeoutMs))) {
|
|
805
809
|
throw new GitMemoError(`gitmemo: stale legacy snapshot ${input} is no longer part of the current HEAD history — restart pagination`);
|
|
806
810
|
}
|
|
807
|
-
return { snapshot: full, legacy: true, warning: legacyWarning(
|
|
811
|
+
return { snapshot: full, legacy: true, warning: legacyWarning() };
|
|
808
812
|
}
|
|
809
|
-
return { snapshot: head, legacy: true, warning: legacyWarning(
|
|
813
|
+
return { snapshot: head, legacy: true, warning: legacyWarning() };
|
|
810
814
|
}
|
|
811
815
|
const format = await repoFormat(memDir, gitTimeoutMs, main);
|
|
812
816
|
if (format !== "new") {
|
|
@@ -815,9 +819,9 @@ async function resolveSnapshot(memDir, gitTimeoutMs, root, input) {
|
|
|
815
819
|
if (!(await gitOk(memDir, ["merge-base", "--is-ancestor", full, main], gitTimeoutMs))) {
|
|
816
820
|
throw new GitMemoError(`gitmemo: stale legacy snapshot ${input} is no longer part of the current branch history — restart pagination`);
|
|
817
821
|
}
|
|
818
|
-
return { snapshot: full, legacy: true, warning: legacyWarning(
|
|
822
|
+
return { snapshot: full, legacy: true, warning: legacyWarning() };
|
|
819
823
|
}
|
|
820
|
-
return { snapshot: main, legacy: true, warning: legacyWarning(
|
|
824
|
+
return { snapshot: main, legacy: true, warning: legacyWarning() };
|
|
821
825
|
}
|
|
822
826
|
if (input !== undefined) {
|
|
823
827
|
const full = await resolveCommit(memDir, input, gitTimeoutMs);
|
|
@@ -1059,14 +1063,6 @@ async function assertOnMain(memDir, gitTimeoutMs) {
|
|
|
1059
1063
|
throw new GitMemoError(`gitmemo: .mem HEAD is not on refs/heads/main (${sym || "unborn HEAD"}) — gitmemo requires its single main branch`);
|
|
1060
1064
|
}
|
|
1061
1065
|
}
|
|
1062
|
-
/** Write/delete/replace must run on the new format; legacy repos need migration. */
|
|
1063
|
-
async function assertNewFormat(memDir, gitTimeoutMs, root) {
|
|
1064
|
-
const format = await repoFormat(memDir, gitTimeoutMs);
|
|
1065
|
-
if (format !== "new") {
|
|
1066
|
-
throw new GitMemoError("gitmemo: .mem exists in legacy format — write operations are disabled. " +
|
|
1067
|
-
`Run \`dsh-gitmemo migrate --project-root ${root} --dry-run\` to plan migration, then --apply.`);
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
1066
|
/** Current code branch + HEAD SHA for entry metadata (plan 7.2/7.3). */
|
|
1071
1067
|
async function codeContext(root, gitTimeoutMs) {
|
|
1072
1068
|
try {
|
|
@@ -1190,8 +1186,10 @@ function cacheSearch(key, value) {
|
|
|
1190
1186
|
// ---------------------------------------------------------------------------
|
|
1191
1187
|
/**
|
|
1192
1188
|
* Git-backed long-term memory engine operating on one project root.
|
|
1193
|
-
* Every operation auto-initializes a new-format repo; legacy repos
|
|
1194
|
-
*
|
|
1189
|
+
* Every operation auto-initializes a new-format repo; legacy `.mem` repos
|
|
1190
|
+
* are migrated automatically on first use (no external CLI needed). Only
|
|
1191
|
+
* when automatic migration is blocked (conflicts, dirty worktree, ...) do
|
|
1192
|
+
* legacy repos stay read-only for search/read with a clear warning.
|
|
1195
1193
|
*/
|
|
1196
1194
|
export class GitMemo {
|
|
1197
1195
|
root;
|
|
@@ -1214,13 +1212,45 @@ export class GitMemo {
|
|
|
1214
1212
|
};
|
|
1215
1213
|
this.memDir = join(this.root, ".mem");
|
|
1216
1214
|
}
|
|
1217
|
-
/** Initialize the memory repo when missing (idempotent). */
|
|
1215
|
+
/** Initialize the memory repo when missing (idempotent). Legacy repos are migrated automatically. */
|
|
1218
1216
|
async init() {
|
|
1219
1217
|
await withLock(this.root, this.config, "init", async () => {
|
|
1220
1218
|
await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
|
|
1219
|
+
await this.autoMigrateIfNeeded();
|
|
1221
1220
|
});
|
|
1222
1221
|
return this.memDir;
|
|
1223
1222
|
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Fully automatic legacy migration (no external CLI needed): when `.mem`
|
|
1225
|
+
* exists in legacy format, rebuild it into the new format in place —
|
|
1226
|
+
* dry-run + apply in one step. The caller must already hold the operation
|
|
1227
|
+
* lock. Blocking conditions (conflicts, dirty worktree, interrupted legacy
|
|
1228
|
+
* journal, ...) are returned, never thrown, so operations can fall back to
|
|
1229
|
+
* legacy read-only behavior; the next operation retries automatically.
|
|
1230
|
+
*/
|
|
1231
|
+
async autoMigrateIfNeeded() {
|
|
1232
|
+
if (!(await pathExists(join(this.memDir, ".git")))) {
|
|
1233
|
+
return { migrated: false, blocked: { code: "no-repo", detail: "no .mem repository" } };
|
|
1234
|
+
}
|
|
1235
|
+
if ((await repoFormat(this.memDir, this.config.gitTimeoutMs)) === "new") {
|
|
1236
|
+
return { migrated: false, alreadyNewFormat: true };
|
|
1237
|
+
}
|
|
1238
|
+
const { migrateAutoUnlocked } = await import("./migrate.js");
|
|
1239
|
+
return migrateAutoUnlocked(this.root, {
|
|
1240
|
+
gitTimeoutMs: this.config.gitTimeoutMs,
|
|
1241
|
+
lockTimeoutMs: this.config.lockTimeoutMs
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
/** Ensure the repo is initialized and writable (new format), migrating legacy repos automatically. */
|
|
1245
|
+
async ensureWritable() {
|
|
1246
|
+
await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
|
|
1247
|
+
const migration = await this.autoMigrateIfNeeded();
|
|
1248
|
+
if (migration.alreadyNewFormat === true)
|
|
1249
|
+
return; // fresh/new-format repo — nothing to migrate
|
|
1250
|
+
if (!migration.migrated) {
|
|
1251
|
+
throw new GitMemoError(legacyWriteRefusal(migration));
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1224
1254
|
/**
|
|
1225
1255
|
* Search past memories (plan 6): fixed-string OR grep recall over commit
|
|
1226
1256
|
* messages only, active-entry filtering, field-level matching and scoring,
|
|
@@ -1262,12 +1292,18 @@ export class GitMemo {
|
|
|
1262
1292
|
? options.snapshot.trim()
|
|
1263
1293
|
: undefined;
|
|
1264
1294
|
// Read barrier: resolve the immutable snapshot under the lock (creating
|
|
1265
|
-
// the repo when missing
|
|
1266
|
-
|
|
1295
|
+
// the repo when missing; legacy repos are migrated automatically when
|
|
1296
|
+
// safe), then scan without holding it.
|
|
1297
|
+
const { snapshot, legacy, warning, blockedNote } = await withLock(this.root, this.config, "search", async () => {
|
|
1267
1298
|
if (!(await pathExists(join(this.memDir, ".git")))) {
|
|
1268
1299
|
await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
|
|
1269
1300
|
}
|
|
1270
|
-
|
|
1301
|
+
const migration = await this.autoMigrateIfNeeded();
|
|
1302
|
+
const resolved = await resolveSnapshot(this.memDir, this.config.gitTimeoutMs, this.root, requestedSnapshot);
|
|
1303
|
+
const note = !migration.migrated && migration.blocked !== undefined && resolved.legacy
|
|
1304
|
+
? " Automatic migration was attempted but blocked: " + migration.blocked.detail
|
|
1305
|
+
: undefined;
|
|
1306
|
+
return { ...resolved, blockedNote: note };
|
|
1271
1307
|
});
|
|
1272
1308
|
const cacheKey = this.memDir + "\x00" + snapshot + "\x00" + normalized.join("\x00");
|
|
1273
1309
|
let computation = searchCache.get(cacheKey);
|
|
@@ -1277,7 +1313,7 @@ export class GitMemo {
|
|
|
1277
1313
|
}
|
|
1278
1314
|
const total = computation.hits.length;
|
|
1279
1315
|
const next_skip = skip + limit < total ? skip + limit : null;
|
|
1280
|
-
const allWarnings = [adapterWarning, warning].filter((w) => w !== undefined).join(" ");
|
|
1316
|
+
const allWarnings = [adapterWarning, warning, blockedNote].filter((w) => w !== undefined).join(" ");
|
|
1281
1317
|
return {
|
|
1282
1318
|
snapshot,
|
|
1283
1319
|
total,
|
|
@@ -1355,6 +1391,7 @@ export class GitMemo {
|
|
|
1355
1391
|
if (!(await pathExists(join(this.memDir, ".git")))) {
|
|
1356
1392
|
await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
|
|
1357
1393
|
}
|
|
1394
|
+
await this.autoMigrateIfNeeded();
|
|
1358
1395
|
const full = await resolveCommit(this.memDir, commitHash, this.config.gitTimeoutMs);
|
|
1359
1396
|
const added = await addedFilesOf(this.memDir, full, this.config.gitTimeoutMs);
|
|
1360
1397
|
if (added.length !== 1) {
|
|
@@ -1378,8 +1415,7 @@ export class GitMemo {
|
|
|
1378
1415
|
const validated = validateWriteInput(adapted, legacy);
|
|
1379
1416
|
const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
|
|
1380
1417
|
return withLock(this.root, this.config, "write", async () => {
|
|
1381
|
-
await
|
|
1382
|
-
await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
|
|
1418
|
+
await this.ensureWritable();
|
|
1383
1419
|
await recoverJournal(this.memDir, this.config.gitTimeoutMs);
|
|
1384
1420
|
await assertClean(this.memDir, this.config.gitTimeoutMs);
|
|
1385
1421
|
await assertOnMain(this.memDir, this.config.gitTimeoutMs);
|
|
@@ -1440,8 +1476,7 @@ export class GitMemo {
|
|
|
1440
1476
|
assertNoControlChars(reason, "reason");
|
|
1441
1477
|
assertNoTrailerInjection(reason, "reason");
|
|
1442
1478
|
return withLock(this.root, this.config, "delete", async () => {
|
|
1443
|
-
await
|
|
1444
|
-
await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
|
|
1479
|
+
await this.ensureWritable();
|
|
1445
1480
|
await recoverJournal(this.memDir, this.config.gitTimeoutMs);
|
|
1446
1481
|
await assertClean(this.memDir, this.config.gitTimeoutMs);
|
|
1447
1482
|
await assertOnMain(this.memDir, this.config.gitTimeoutMs);
|
|
@@ -1487,8 +1522,7 @@ export class GitMemo {
|
|
|
1487
1522
|
const validated = validateWriteInput(input);
|
|
1488
1523
|
const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
|
|
1489
1524
|
return withLock(this.root, this.config, "replace", async () => {
|
|
1490
|
-
await
|
|
1491
|
-
await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
|
|
1525
|
+
await this.ensureWritable();
|
|
1492
1526
|
await recoverJournal(this.memDir, this.config.gitTimeoutMs);
|
|
1493
1527
|
await assertClean(this.memDir, this.config.gitTimeoutMs);
|
|
1494
1528
|
await assertOnMain(this.memDir, this.config.gitTimeoutMs);
|
package/lib/migrate.d.ts
CHANGED
|
@@ -60,6 +60,19 @@ export interface MigrationOptions {
|
|
|
60
60
|
gitTimeoutMs?: number;
|
|
61
61
|
lockTimeoutMs?: number;
|
|
62
62
|
}
|
|
63
|
+
/** Why automatic migration could not run (user-resolvable blocking conditions). */
|
|
64
|
+
export interface AutoMigrateBlock {
|
|
65
|
+
code: "no-repo" | "no-branches" | "conflicts" | "dirty" | "legacy-journal" | "other";
|
|
66
|
+
/** Human-readable detail; safe to surface to the user. */
|
|
67
|
+
detail: string;
|
|
68
|
+
}
|
|
69
|
+
/** Outcome of an automatic migration attempt (never throws for blocking conditions). */
|
|
70
|
+
export interface AutoMigrateOutcome {
|
|
71
|
+
migrated: boolean;
|
|
72
|
+
alreadyNewFormat?: boolean;
|
|
73
|
+
blocked?: AutoMigrateBlock;
|
|
74
|
+
report?: MigrationApplyReport;
|
|
75
|
+
}
|
|
63
76
|
/**
|
|
64
77
|
* Scan a legacy `.mem` repo and produce the migration plan. Read-only: no
|
|
65
78
|
* ref, index or worktree state is modified (the lock file is transient).
|
|
@@ -71,3 +84,12 @@ export declare function migrateDryRun(root: string, options?: MigrationOptions):
|
|
|
71
84
|
* new canonical main in a temp repo, then swaps directories atomically.
|
|
72
85
|
*/
|
|
73
86
|
export declare function migrateApply(root: string, baseline: MigrationBaseline, options?: MigrationOptions): Promise<MigrationApplyReport>;
|
|
87
|
+
/**
|
|
88
|
+
* Fully automatic migration for engine use: dry-run + apply in one step,
|
|
89
|
+
* without taking the cross-process lock (the caller must already hold it).
|
|
90
|
+
*
|
|
91
|
+
* Never throws for user-resolvable blocking conditions — it returns
|
|
92
|
+
* `blocked` instead, so the caller can fall back to legacy read-only mode
|
|
93
|
+
* and retry automatically on a later operation.
|
|
94
|
+
*/
|
|
95
|
+
export declare function migrateAutoUnlocked(root: string, options?: MigrationOptions): Promise<AutoMigrateOutcome>;
|
package/lib/migrate.js
CHANGED
|
@@ -2,9 +2,14 @@
|
|
|
2
2
|
* Legacy → new-format migration for dsh-gitmemo (plan 10).
|
|
3
3
|
*
|
|
4
4
|
* Legacy `.mem` repos (no `.gitmemo-format` marker, possibly multi-branch)
|
|
5
|
-
* are migrated
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* are migrated fully automatically by the engine: the first operation on a
|
|
6
|
+
* legacy repo runs dry-run + apply in one step (no `dsh-gitmemo` command
|
|
7
|
+
* needed). The CLI (`dsh-gitmemo migrate --project-root <path>
|
|
8
|
+
* --dry-run|--apply`) remains for explicit control — non-default --source
|
|
9
|
+
* directories, baseline review, and repos where automatic migration is
|
|
10
|
+
* blocked (conflicts, dirty worktree, ...).
|
|
11
|
+
*
|
|
12
|
+
* Apply mechanics:
|
|
8
13
|
* 1. backup refs for every old branch tip (`refs/gitmemo/backup/<ts>/<b>`);
|
|
9
14
|
* 2. a brand-new canonical `main` is built in a sibling temp repo (init
|
|
10
15
|
* commit + one immutable ADD commit per unique active entry);
|
|
@@ -27,6 +32,15 @@ function safeLegacyDate(epochRaw) {
|
|
|
27
32
|
const ms = Number(epochRaw ?? 0) * 1000;
|
|
28
33
|
return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? new Date(ms) : new Date(0);
|
|
29
34
|
}
|
|
35
|
+
async function isGitRepo(dir, gitTimeoutMs) {
|
|
36
|
+
try {
|
|
37
|
+
await git(dir, ["rev-parse", "--git-dir"], gitTimeoutMs);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
30
44
|
async function listTips(memDir, gitTimeoutMs) {
|
|
31
45
|
const out = (await git(memDir, ["for-each-ref", "--format=%(refname:short)\t%(objectname)", "refs/heads"], gitTimeoutMs)).trim();
|
|
32
46
|
const branches = [];
|
|
@@ -191,136 +205,138 @@ async function buildPlanEntry(args) {
|
|
|
191
205
|
* ref, index or worktree state is modified (the lock file is transient).
|
|
192
206
|
*/
|
|
193
207
|
export async function migrateDryRun(root, options = {}) {
|
|
194
|
-
const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
|
|
195
208
|
const lockTimeoutMs = options.lockTimeoutMs ?? 30000;
|
|
209
|
+
return withLock(root, { lockTimeoutMs }, "migrate-dry-run", () => migrateDryRunUnlocked(root, options));
|
|
210
|
+
}
|
|
211
|
+
/** Unlocked core of {@link migrateDryRun}; the caller must hold the cross-process lock. */
|
|
212
|
+
async function migrateDryRunUnlocked(root, options = {}) {
|
|
213
|
+
const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
|
|
196
214
|
const memDir = resolve(options.source ?? join(root, ".mem"));
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
215
|
+
try {
|
|
216
|
+
await git(memDir, ["rev-parse", "--git-dir"], gitTimeoutMs);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new GitMemoError("gitmemo: no .mem repository at " + memDir);
|
|
220
|
+
}
|
|
221
|
+
if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
|
|
222
|
+
return {
|
|
223
|
+
legacy: false,
|
|
224
|
+
alreadyNewFormat: true,
|
|
225
|
+
branches: [],
|
|
226
|
+
tips: {},
|
|
227
|
+
oldEntryCount: 0,
|
|
228
|
+
uniqueEntryCount: 0,
|
|
229
|
+
conflictCount: 0,
|
|
230
|
+
estimatedMainCommits: 0,
|
|
231
|
+
baseline: {
|
|
232
|
+
format: "dsh-gitmemo-migration-baseline",
|
|
233
|
+
version: 1,
|
|
234
|
+
root: resolve(root),
|
|
235
|
+
memDir,
|
|
236
|
+
scannedAt: new Date().toISOString(),
|
|
209
237
|
tips: {},
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const files = filesOut.split("\n").filter((line) => line.startsWith(ENTRY_DIR + "/") && line.endsWith(".md"));
|
|
241
|
-
for (const file of files.sort()) {
|
|
242
|
-
oldEntryCount += 1;
|
|
243
|
-
const content = await git(memDir, ["show", tip + ":" + file], gitTimeoutMs);
|
|
244
|
-
const createOut = (await git(memDir, ["log", "-1", "--format=%ct%x00%H%x00%s%x00%B", tip, "--", file], gitTimeoutMs)).trim();
|
|
245
|
-
const [ctRaw, hash, subject, body] = createOut.split("\x00");
|
|
246
|
-
const contentDigest = createHash("sha256").update(content, "utf8").digest("hex");
|
|
247
|
-
let pathVariants = variantsByPath.get(file);
|
|
248
|
-
if (pathVariants === undefined) {
|
|
249
|
-
pathVariants = new Map();
|
|
250
|
-
variantsByPath.set(file, pathVariants);
|
|
251
|
-
}
|
|
252
|
-
pathVariants.set(branch, { branch, hash, digest: contentDigest });
|
|
253
|
-
let paths = occurrences.get(contentDigest);
|
|
254
|
-
if (paths === undefined) {
|
|
255
|
-
paths = new Set();
|
|
256
|
-
occurrences.set(contentDigest, paths);
|
|
257
|
-
}
|
|
258
|
-
paths.add(file);
|
|
259
|
-
if (!byDigest.has(contentDigest)) {
|
|
260
|
-
const plan = await buildPlanEntry({
|
|
261
|
-
memDir,
|
|
262
|
-
gitTimeoutMs,
|
|
263
|
-
branch,
|
|
264
|
-
file,
|
|
265
|
-
content,
|
|
266
|
-
hash,
|
|
267
|
-
date: safeLegacyDate(ctRaw),
|
|
268
|
-
subject: subject ?? "",
|
|
269
|
-
body: body ?? "",
|
|
270
|
-
warnings
|
|
271
|
-
});
|
|
272
|
-
byDigest.set(contentDigest, plan);
|
|
273
|
-
}
|
|
238
|
+
entries: [],
|
|
239
|
+
conflicts: [],
|
|
240
|
+
warnings: []
|
|
241
|
+
},
|
|
242
|
+
warnings: ["already in new format — nothing to migrate"]
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const { branches, tips } = await listTips(memDir, gitTimeoutMs);
|
|
246
|
+
if (branches.length === 0) {
|
|
247
|
+
throw new GitMemoError("gitmemo: legacy .mem has no branches — nothing to migrate");
|
|
248
|
+
}
|
|
249
|
+
const warnings = [];
|
|
250
|
+
const variantsByPath = new Map();
|
|
251
|
+
const occurrences = new Map(); // content digest -> paths
|
|
252
|
+
const byDigest = new Map();
|
|
253
|
+
let oldEntryCount = 0;
|
|
254
|
+
for (const branch of branches.sort()) {
|
|
255
|
+
const tip = tips[branch];
|
|
256
|
+
const filesOut = (await git(memDir, ["ls-tree", "-r", "--name-only", tip, "--", ENTRY_DIR + "/"], gitTimeoutMs)).trim();
|
|
257
|
+
const files = filesOut.split("\n").filter((line) => line.startsWith(ENTRY_DIR + "/") && line.endsWith(".md"));
|
|
258
|
+
for (const file of files.sort()) {
|
|
259
|
+
oldEntryCount += 1;
|
|
260
|
+
const content = await git(memDir, ["show", tip + ":" + file], gitTimeoutMs);
|
|
261
|
+
const createOut = (await git(memDir, ["log", "-1", "--format=%ct%x00%H%x00%s%x00%B", tip, "--", file], gitTimeoutMs)).trim();
|
|
262
|
+
const [ctRaw, hash, subject, body] = createOut.split("\x00");
|
|
263
|
+
const contentDigest = createHash("sha256").update(content, "utf8").digest("hex");
|
|
264
|
+
let pathVariants = variantsByPath.get(file);
|
|
265
|
+
if (pathVariants === undefined) {
|
|
266
|
+
pathVariants = new Map();
|
|
267
|
+
variantsByPath.set(file, pathVariants);
|
|
274
268
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
269
|
+
pathVariants.set(branch, { branch, hash, digest: contentDigest });
|
|
270
|
+
let paths = occurrences.get(contentDigest);
|
|
271
|
+
if (paths === undefined) {
|
|
272
|
+
paths = new Set();
|
|
273
|
+
occurrences.set(contentDigest, paths);
|
|
274
|
+
}
|
|
275
|
+
paths.add(file);
|
|
276
|
+
if (!byDigest.has(contentDigest)) {
|
|
277
|
+
const plan = await buildPlanEntry({
|
|
278
|
+
memDir,
|
|
279
|
+
gitTimeoutMs,
|
|
280
|
+
branch,
|
|
281
|
+
file,
|
|
282
|
+
content,
|
|
283
|
+
hash,
|
|
284
|
+
date: safeLegacyDate(ctRaw),
|
|
285
|
+
subject: subject ?? "",
|
|
286
|
+
body: body ?? "",
|
|
287
|
+
warnings
|
|
284
288
|
});
|
|
289
|
+
byDigest.set(contentDigest, plan);
|
|
285
290
|
}
|
|
286
291
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
292
|
+
}
|
|
293
|
+
// conflicts: same path, different content
|
|
294
|
+
const conflicts = [];
|
|
295
|
+
for (const [path, variants] of variantsByPath) {
|
|
296
|
+
const digests = new Set([...variants.values()].map((v) => v.digest));
|
|
297
|
+
if (digests.size > 1) {
|
|
298
|
+
conflicts.push({
|
|
299
|
+
path,
|
|
300
|
+
variants: [...variants.values()].sort((a, b) => a.branch.localeCompare(b.branch))
|
|
301
|
+
});
|
|
295
302
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
303
|
+
}
|
|
304
|
+
// a content digest that occurs at a conflicted path is excluded from auto-migration
|
|
305
|
+
const conflictedPaths = new Set(conflicts.map((c) => c.path));
|
|
306
|
+
const conflictedDigests = new Set();
|
|
307
|
+
for (const [digest, paths] of occurrences) {
|
|
308
|
+
for (const path of paths) {
|
|
309
|
+
if (conflictedPaths.has(path))
|
|
310
|
+
conflictedDigests.add(digest);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// NOTE: byDigest is keyed by the raw content digest (sha256 of content);
|
|
314
|
+
// the plan's `digest` field is the new canonical engine digest — filter by key.
|
|
315
|
+
const entries = [...byDigest.entries()]
|
|
316
|
+
.filter(([contentDigest]) => !conflictedDigests.has(contentDigest))
|
|
317
|
+
.map(([, plan]) => plan);
|
|
318
|
+
const baseline = {
|
|
319
|
+
format: "dsh-gitmemo-migration-baseline",
|
|
320
|
+
version: 1,
|
|
321
|
+
root: resolve(root),
|
|
322
|
+
memDir,
|
|
323
|
+
scannedAt: new Date().toISOString(),
|
|
324
|
+
tips,
|
|
325
|
+
entries,
|
|
326
|
+
conflicts,
|
|
327
|
+
warnings
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
legacy: true,
|
|
331
|
+
branches,
|
|
332
|
+
tips,
|
|
333
|
+
oldEntryCount,
|
|
334
|
+
uniqueEntryCount: entries.length,
|
|
335
|
+
conflictCount: conflicts.length,
|
|
336
|
+
estimatedMainCommits: 1 + entries.length,
|
|
337
|
+
baseline,
|
|
338
|
+
warnings
|
|
339
|
+
};
|
|
324
340
|
}
|
|
325
341
|
// ---------------------------------------------------------------------------
|
|
326
342
|
// apply
|
|
@@ -370,8 +386,12 @@ async function excludeFromParent(root, gitTimeoutMs) {
|
|
|
370
386
|
* new canonical main in a temp repo, then swaps directories atomically.
|
|
371
387
|
*/
|
|
372
388
|
export async function migrateApply(root, baseline, options = {}) {
|
|
373
|
-
const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
|
|
374
389
|
const lockTimeoutMs = options.lockTimeoutMs ?? 30000;
|
|
390
|
+
return withLock(root, { lockTimeoutMs }, "migrate", () => migrateApplyUnlocked(root, baseline, options));
|
|
391
|
+
}
|
|
392
|
+
/** Unlocked core of {@link migrateApply}; the caller must hold the cross-process lock. */
|
|
393
|
+
async function migrateApplyUnlocked(root, baseline, options = {}) {
|
|
394
|
+
const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
|
|
375
395
|
if (baseline === null ||
|
|
376
396
|
typeof baseline !== "object" ||
|
|
377
397
|
baseline.format !== "dsh-gitmemo-migration-baseline" ||
|
|
@@ -395,172 +415,236 @@ export async function migrateApply(root, baseline, options = {}) {
|
|
|
395
415
|
if (resolve(baseline.memDir) !== memDir) {
|
|
396
416
|
throw new GitMemoError("gitmemo: migration baseline source path is inconsistent — re-run --dry-run");
|
|
397
417
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
418
|
+
await recoverMigrationSwap(root, gitTimeoutMs);
|
|
419
|
+
if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
|
|
420
|
+
return { migrated: false, alreadyMigrated: true, entries: 0, newMain: "", backupRefPrefix: "", oldBranches: [] };
|
|
421
|
+
}
|
|
422
|
+
// 1. re-verify the dry-run baseline refs are unchanged
|
|
423
|
+
const current = await listTips(memDir, gitTimeoutMs);
|
|
424
|
+
const baselineBranches = Object.keys(baseline.tips).sort();
|
|
425
|
+
if (baselineBranches.length === 0) {
|
|
426
|
+
throw new GitMemoError("gitmemo: baseline contains no branches — re-run `dsh-gitmemo migrate --dry-run`");
|
|
427
|
+
}
|
|
428
|
+
for (const branch of baselineBranches) {
|
|
429
|
+
if (current.tips[branch] !== baseline.tips[branch]) {
|
|
430
|
+
throw new GitMemoError(`gitmemo: branch "${branch}" changed since the dry-run baseline (${current.tips[branch] ?? "gone"} ≠ ${baseline.tips[branch]}) — re-run --dry-run`);
|
|
402
431
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
432
|
+
}
|
|
433
|
+
if (current.branches.length !== baselineBranches.length) {
|
|
434
|
+
throw new GitMemoError("gitmemo: branch set changed since the dry-run baseline — re-run --dry-run");
|
|
435
|
+
}
|
|
436
|
+
// 2. conflicts must be resolved by the user first
|
|
437
|
+
if (baseline.conflicts.length > 0) {
|
|
438
|
+
throw new GitMemoError(`gitmemo: migration blocked by ${baseline.conflicts.length} conflict(s) (same entry path, different content across branches):\n` +
|
|
439
|
+
conflictManifest(baseline.conflicts) +
|
|
440
|
+
"\nResolve the conflicts, then re-run --dry-run to produce a clean baseline.");
|
|
441
|
+
}
|
|
442
|
+
const legacyStatus = (await git(memDir, ["status", "--porcelain"], gitTimeoutMs)).trim();
|
|
443
|
+
if (legacyStatus.length > 0) {
|
|
444
|
+
throw new GitMemoError("gitmemo: legacy memory repository is not clean — migration refuses to discard uncommitted/staged content. " +
|
|
445
|
+
`Inspect with: git -C ${memDir} status`);
|
|
446
|
+
}
|
|
447
|
+
const legacyJournal = join(memDir, ".git", "gitmemo-transaction.json");
|
|
448
|
+
try {
|
|
449
|
+
await readFile(legacyJournal, "utf8");
|
|
450
|
+
throw new GitMemoError(`gitmemo: legacy repository contains an interrupted transaction journal (${legacyJournal}); repair it before migration`);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (error instanceof GitMemoError)
|
|
454
|
+
throw error;
|
|
455
|
+
if (error.code !== "ENOENT")
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
// 3. backup refs in the old repo (safety net; also fetched into the new repo)
|
|
459
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
460
|
+
for (const [branch, hash] of Object.entries(baseline.tips)) {
|
|
461
|
+
await git(memDir, ["update-ref", `refs/gitmemo/backup/${ts}/${branch}`, hash], gitTimeoutMs);
|
|
462
|
+
}
|
|
463
|
+
// 4. build the new canonical main in a sibling temp repo
|
|
464
|
+
const tmp = join(root, `.mem.gitmemo-tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
465
|
+
try {
|
|
466
|
+
await mkdir(join(tmp, ENTRY_DIR), { recursive: true });
|
|
467
|
+
await gitInitMain(tmp, gitTimeoutMs);
|
|
468
|
+
await writeFile(join(tmp, FORMAT_FILE), GITMEMO_FORMAT_VERSION + "\n", "utf8");
|
|
469
|
+
await writeFile(join(tmp, ENTRY_DIR, ".gitkeep"), "", "utf8");
|
|
470
|
+
await git(tmp, ["add", "-A", "--", FORMAT_FILE, ENTRY_DIR + "/.gitkeep"], gitTimeoutMs);
|
|
471
|
+
await commitWithIdentity(tmp, "init: initialize memory repo (gitmemo format 2)", gitTimeoutMs);
|
|
472
|
+
// fetch old objects + backup refs into the new repo so old hashes stay readable
|
|
473
|
+
await git(tmp, ["fetch", "-q", memDir, `+refs/heads/*:refs/gitmemo/backup/${ts}/*`], gitTimeoutMs);
|
|
474
|
+
// 5. one immutable ADD commit per unique entry, preserving old dates
|
|
475
|
+
const entries = [...baseline.entries].sort((a, b) => a.date.localeCompare(b.date) || a.sourcePath.localeCompare(b.sourcePath));
|
|
476
|
+
const digests = new Set(entries.map((e) => e.digest));
|
|
477
|
+
if (digests.size !== entries.length) {
|
|
478
|
+
throw new GitMemoError("gitmemo: migration plan contains duplicate digests — re-run --dry-run");
|
|
408
479
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
480
|
+
const created = [];
|
|
481
|
+
for (const entry of entries) {
|
|
482
|
+
const parsedMs = Date.parse(entry.date);
|
|
483
|
+
const epoch = Number.isFinite(parsedMs) ? Math.floor(parsedMs / 1000) : 0;
|
|
484
|
+
const env = { GIT_AUTHOR_DATE: "@" + epoch, GIT_COMMITTER_DATE: "@" + epoch };
|
|
485
|
+
const entryDate = safeLegacyDate(epoch);
|
|
486
|
+
const baseName = utcMs(entryDate) + "-" + entry.digest.slice(0, 8) + "-" + slugifyTitle(entry.title) + ".md";
|
|
487
|
+
const markdown = buildEntryMarkdown({
|
|
488
|
+
title: entry.title,
|
|
489
|
+
summary: entry.summary,
|
|
490
|
+
keywords: entry.keywords,
|
|
491
|
+
content: entry.content,
|
|
492
|
+
related_branches: entry.relatedBranches,
|
|
493
|
+
related_paths: entry.relatedPaths
|
|
494
|
+
}, { branch: entry.codeBranch, commit: entry.codeCommit, relatedBranches: entry.relatedBranches }, entry.digest, entryDate);
|
|
495
|
+
const file = await createEntryFile(tmp, baseName, markdown);
|
|
496
|
+
await git(tmp, ["add", "--", file], gitTimeoutMs);
|
|
497
|
+
const hash = await commitWithIdentity(tmp, buildAddMessage(entry.title, entry.summary, entry.keywords, entry.digest, true), gitTimeoutMs, { env });
|
|
498
|
+
created.push({ file, hash });
|
|
413
499
|
}
|
|
414
|
-
|
|
415
|
-
|
|
500
|
+
// 6. verify the build before touching the original
|
|
501
|
+
if (created.length !== entries.length) {
|
|
502
|
+
throw new GitMemoError("gitmemo: migration entry count mismatch — aborting");
|
|
416
503
|
}
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
throw new GitMemoError(
|
|
420
|
-
conflictManifest(baseline.conflicts) +
|
|
421
|
-
"\nResolve the conflicts, then re-run --dry-run to produce a clean baseline.");
|
|
504
|
+
const status = (await git(tmp, ["status", "--porcelain"], gitTimeoutMs)).trim();
|
|
505
|
+
if (status.length > 0) {
|
|
506
|
+
throw new GitMemoError("gitmemo: migration build tree is dirty: " + status);
|
|
422
507
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
508
|
+
for (const createdEntry of created) {
|
|
509
|
+
const added = await git(tmp, ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", createdEntry.hash, "--", ENTRY_DIR + "/"], gitTimeoutMs);
|
|
510
|
+
const lines = added.split("\n").filter((l) => /^A\tentries\/.+\.md$/.test(l));
|
|
511
|
+
if (lines.length !== 1 || !lines[0].endsWith(createdEntry.file)) {
|
|
512
|
+
throw new GitMemoError("gitmemo: commit/file mapping mismatch at " + createdEntry.hash + " — aborting");
|
|
513
|
+
}
|
|
427
514
|
}
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
await readFile(legacyJournal, "utf8");
|
|
431
|
-
throw new GitMemoError(`gitmemo: legacy repository contains an interrupted transaction journal (${legacyJournal}); repair it before migration`);
|
|
515
|
+
for (const hash of Object.values(baseline.tips)) {
|
|
516
|
+
await resolveCommit(tmp, hash, gitTimeoutMs); // old tip must be reachable (via backup refs)
|
|
432
517
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
518
|
+
// 7. crash-recoverable compare-and-swap. A sibling journal is written
|
|
519
|
+
// before either rename so search/write never mistake the gap for a fresh
|
|
520
|
+
// project and initialize an empty `.mem`.
|
|
521
|
+
const oldDir = join(root, `.mem.gitmemo-old-${ts}`);
|
|
522
|
+
const targetDir = join(root, ".mem");
|
|
523
|
+
if (memDir !== targetDir && await access(targetDir).then(() => true).catch(() => false)) {
|
|
524
|
+
throw new GitMemoError(`gitmemo: migration target ${targetDir} already exists; move it away before migrating from ${memDir}`);
|
|
438
525
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
526
|
+
const swapJournal = {
|
|
527
|
+
operationId: randomBytes(8).toString("hex"),
|
|
528
|
+
sourceDir: memDir,
|
|
529
|
+
targetDir,
|
|
530
|
+
oldDir,
|
|
531
|
+
tmpDir: tmp,
|
|
532
|
+
phase: "prepared",
|
|
533
|
+
createdAt: new Date().toISOString()
|
|
534
|
+
};
|
|
535
|
+
await writeMigrationSwapJournal(root, swapJournal);
|
|
536
|
+
if (memDir === targetDir) {
|
|
537
|
+
await rename(memDir, oldDir);
|
|
538
|
+
swapJournal.phase = "old-renamed";
|
|
539
|
+
await writeMigrationSwapJournal(root, swapJournal);
|
|
443
540
|
}
|
|
444
|
-
// 4. build the new canonical main in a sibling temp repo
|
|
445
|
-
const tmp = join(root, `.mem.gitmemo-tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
446
541
|
try {
|
|
447
|
-
await
|
|
448
|
-
|
|
449
|
-
await writeFile(join(tmp, FORMAT_FILE), GITMEMO_FORMAT_VERSION + "\n", "utf8");
|
|
450
|
-
await writeFile(join(tmp, ENTRY_DIR, ".gitkeep"), "", "utf8");
|
|
451
|
-
await git(tmp, ["add", "-A", "--", FORMAT_FILE, ENTRY_DIR + "/.gitkeep"], gitTimeoutMs);
|
|
452
|
-
await commitWithIdentity(tmp, "init: initialize memory repo (gitmemo format 2)", gitTimeoutMs);
|
|
453
|
-
// fetch old objects + backup refs into the new repo so old hashes stay readable
|
|
454
|
-
await git(tmp, ["fetch", "-q", memDir, `+refs/heads/*:refs/gitmemo/backup/${ts}/*`], gitTimeoutMs);
|
|
455
|
-
// 5. one immutable ADD commit per unique entry, preserving old dates
|
|
456
|
-
const entries = [...baseline.entries].sort((a, b) => a.date.localeCompare(b.date) || a.sourcePath.localeCompare(b.sourcePath));
|
|
457
|
-
const digests = new Set(entries.map((e) => e.digest));
|
|
458
|
-
if (digests.size !== entries.length) {
|
|
459
|
-
throw new GitMemoError("gitmemo: migration plan contains duplicate digests — re-run --dry-run");
|
|
460
|
-
}
|
|
461
|
-
const created = [];
|
|
462
|
-
for (const entry of entries) {
|
|
463
|
-
const parsedMs = Date.parse(entry.date);
|
|
464
|
-
const epoch = Number.isFinite(parsedMs) ? Math.floor(parsedMs / 1000) : 0;
|
|
465
|
-
const env = { GIT_AUTHOR_DATE: "@" + epoch, GIT_COMMITTER_DATE: "@" + epoch };
|
|
466
|
-
const entryDate = safeLegacyDate(epoch);
|
|
467
|
-
const baseName = utcMs(entryDate) + "-" + entry.digest.slice(0, 8) + "-" + slugifyTitle(entry.title) + ".md";
|
|
468
|
-
const markdown = buildEntryMarkdown({
|
|
469
|
-
title: entry.title,
|
|
470
|
-
summary: entry.summary,
|
|
471
|
-
keywords: entry.keywords,
|
|
472
|
-
content: entry.content,
|
|
473
|
-
related_branches: entry.relatedBranches,
|
|
474
|
-
related_paths: entry.relatedPaths
|
|
475
|
-
}, { branch: entry.codeBranch, commit: entry.codeCommit, relatedBranches: entry.relatedBranches }, entry.digest, entryDate);
|
|
476
|
-
const file = await createEntryFile(tmp, baseName, markdown);
|
|
477
|
-
await git(tmp, ["add", "--", file], gitTimeoutMs);
|
|
478
|
-
const hash = await commitWithIdentity(tmp, buildAddMessage(entry.title, entry.summary, entry.keywords, entry.digest, true), gitTimeoutMs, { env });
|
|
479
|
-
created.push({ file, hash });
|
|
480
|
-
}
|
|
481
|
-
// 6. verify the build before touching the original
|
|
482
|
-
if (created.length !== entries.length) {
|
|
483
|
-
throw new GitMemoError("gitmemo: migration entry count mismatch — aborting");
|
|
484
|
-
}
|
|
485
|
-
const status = (await git(tmp, ["status", "--porcelain"], gitTimeoutMs)).trim();
|
|
486
|
-
if (status.length > 0) {
|
|
487
|
-
throw new GitMemoError("gitmemo: migration build tree is dirty: " + status);
|
|
488
|
-
}
|
|
489
|
-
for (const createdEntry of created) {
|
|
490
|
-
const added = await git(tmp, ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", createdEntry.hash, "--", ENTRY_DIR + "/"], gitTimeoutMs);
|
|
491
|
-
const lines = added.split("\n").filter((l) => /^A\tentries\/.+\.md$/.test(l));
|
|
492
|
-
if (lines.length !== 1 || !lines[0].endsWith(createdEntry.file)) {
|
|
493
|
-
throw new GitMemoError("gitmemo: commit/file mapping mismatch at " + createdEntry.hash + " — aborting");
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
for (const hash of Object.values(baseline.tips)) {
|
|
497
|
-
await resolveCommit(tmp, hash, gitTimeoutMs); // old tip must be reachable (via backup refs)
|
|
498
|
-
}
|
|
499
|
-
// 7. crash-recoverable compare-and-swap. A sibling journal is written
|
|
500
|
-
// before either rename so search/write never mistake the gap for a fresh
|
|
501
|
-
// project and initialize an empty `.mem`.
|
|
502
|
-
const oldDir = join(root, `.mem.gitmemo-old-${ts}`);
|
|
503
|
-
const targetDir = join(root, ".mem");
|
|
504
|
-
if (memDir !== targetDir && await access(targetDir).then(() => true).catch(() => false)) {
|
|
505
|
-
throw new GitMemoError(`gitmemo: migration target ${targetDir} already exists; move it away before migrating from ${memDir}`);
|
|
506
|
-
}
|
|
507
|
-
const swapJournal = {
|
|
508
|
-
operationId: randomBytes(8).toString("hex"),
|
|
509
|
-
sourceDir: memDir,
|
|
510
|
-
targetDir,
|
|
511
|
-
oldDir,
|
|
512
|
-
tmpDir: tmp,
|
|
513
|
-
phase: "prepared",
|
|
514
|
-
createdAt: new Date().toISOString()
|
|
515
|
-
};
|
|
542
|
+
await rename(tmp, targetDir);
|
|
543
|
+
swapJournal.phase = "new-installed";
|
|
516
544
|
await writeMigrationSwapJournal(root, swapJournal);
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
swapJournal.phase = "old-renamed";
|
|
520
|
-
await writeMigrationSwapJournal(root, swapJournal);
|
|
521
|
-
}
|
|
522
|
-
try {
|
|
523
|
-
await rename(tmp, targetDir);
|
|
524
|
-
swapJournal.phase = "new-installed";
|
|
525
|
-
await writeMigrationSwapJournal(root, swapJournal);
|
|
526
|
-
}
|
|
527
|
-
catch (error) {
|
|
528
|
-
try {
|
|
529
|
-
await recoverMigrationSwap(root, gitTimeoutMs);
|
|
530
|
-
if ((await repoFormat(targetDir, gitTimeoutMs)) !== "new")
|
|
531
|
-
throw error;
|
|
532
|
-
}
|
|
533
|
-
catch (restoreError) {
|
|
534
|
-
throw new GitMemoError(`gitmemo: migration swap failed and automatic restore also failed. Old repository remains at ${oldDir}; ` +
|
|
535
|
-
`temporary repository is ${tmp}; recovery journal is ${join(root, MIGRATION_JOURNAL_NAME)}. ` +
|
|
536
|
-
`Original error: ${String(error)}; restore error: ${String(restoreError)}`);
|
|
537
|
-
}
|
|
538
|
-
throw error;
|
|
539
|
-
}
|
|
540
|
-
// objects now live in the new repo via the fetch; drop the old directory
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
541
547
|
try {
|
|
542
|
-
if (memDir === targetDir)
|
|
543
|
-
await rm(oldDir, { recursive: true, force: true });
|
|
544
|
-
await rm(join(root, MIGRATION_JOURNAL_NAME), { force: true });
|
|
545
|
-
}
|
|
546
|
-
catch {
|
|
547
|
-
// The canonical new repo is already installed. Re-run the idempotent
|
|
548
|
-
// recovery finalizer instead of reporting the migration itself failed.
|
|
549
548
|
await recoverMigrationSwap(root, gitTimeoutMs);
|
|
549
|
+
if ((await repoFormat(targetDir, gitTimeoutMs)) !== "new")
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
catch (restoreError) {
|
|
553
|
+
throw new GitMemoError(`gitmemo: migration swap failed and automatic restore also failed. Old repository remains at ${oldDir}; ` +
|
|
554
|
+
`temporary repository is ${tmp}; recovery journal is ${join(root, MIGRATION_JOURNAL_NAME)}. ` +
|
|
555
|
+
`Original error: ${String(error)}; restore error: ${String(restoreError)}`);
|
|
550
556
|
}
|
|
551
|
-
await excludeFromParent(root, gitTimeoutMs);
|
|
552
|
-
const newMain = (await git(targetDir, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
|
|
553
|
-
return {
|
|
554
|
-
migrated: true,
|
|
555
|
-
entries: created.length,
|
|
556
|
-
newMain,
|
|
557
|
-
backupRefPrefix: `refs/gitmemo/backup/${ts}/`,
|
|
558
|
-
oldBranches: baselineBranches
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
catch (error) {
|
|
562
|
-
await rm(tmp, { recursive: true, force: true }).catch(() => { });
|
|
563
557
|
throw error;
|
|
564
558
|
}
|
|
565
|
-
|
|
559
|
+
// objects now live in the new repo via the fetch; drop the old directory
|
|
560
|
+
try {
|
|
561
|
+
if (memDir === targetDir)
|
|
562
|
+
await rm(oldDir, { recursive: true, force: true });
|
|
563
|
+
await rm(join(root, MIGRATION_JOURNAL_NAME), { force: true });
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
// The canonical new repo is already installed. Re-run the idempotent
|
|
567
|
+
// recovery finalizer instead of reporting the migration itself failed.
|
|
568
|
+
await recoverMigrationSwap(root, gitTimeoutMs);
|
|
569
|
+
}
|
|
570
|
+
await excludeFromParent(root, gitTimeoutMs);
|
|
571
|
+
const newMain = (await git(targetDir, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
|
|
572
|
+
return {
|
|
573
|
+
migrated: true,
|
|
574
|
+
entries: created.length,
|
|
575
|
+
newMain,
|
|
576
|
+
backupRefPrefix: `refs/gitmemo/backup/${ts}/`,
|
|
577
|
+
oldBranches: baselineBranches
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => { });
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Fully automatic migration for engine use: dry-run + apply in one step,
|
|
587
|
+
* without taking the cross-process lock (the caller must already hold it).
|
|
588
|
+
*
|
|
589
|
+
* Never throws for user-resolvable blocking conditions — it returns
|
|
590
|
+
* `blocked` instead, so the caller can fall back to legacy read-only mode
|
|
591
|
+
* and retry automatically on a later operation.
|
|
592
|
+
*/
|
|
593
|
+
export async function migrateAutoUnlocked(root, options = {}) {
|
|
594
|
+
const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
|
|
595
|
+
const memDir = resolve(options.source ?? join(root, ".mem"));
|
|
596
|
+
await recoverMigrationSwap(root, gitTimeoutMs);
|
|
597
|
+
if (!(await isGitRepo(memDir, gitTimeoutMs))) {
|
|
598
|
+
return { migrated: false, blocked: { code: "no-repo", detail: "no .mem repository at " + memDir } };
|
|
599
|
+
}
|
|
600
|
+
if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
|
|
601
|
+
return { migrated: false, alreadyNewFormat: true };
|
|
602
|
+
}
|
|
603
|
+
let report;
|
|
604
|
+
try {
|
|
605
|
+
report = await migrateDryRunUnlocked(root, options);
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (error instanceof GitMemoError) {
|
|
609
|
+
const code = /no branches/.test(error.message) ? "no-branches" : "other";
|
|
610
|
+
return { migrated: false, blocked: { code, detail: error.message } };
|
|
611
|
+
}
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
if (!report.legacy)
|
|
615
|
+
return { migrated: false, alreadyNewFormat: true };
|
|
616
|
+
if (report.conflictCount > 0) {
|
|
617
|
+
return {
|
|
618
|
+
migrated: false,
|
|
619
|
+
blocked: {
|
|
620
|
+
code: "conflicts",
|
|
621
|
+
detail: `${report.conflictCount} conflict(s) (same entry path, different content across branches):\n` +
|
|
622
|
+
conflictManifest(report.baseline.conflicts)
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const applied = await migrateApplyUnlocked(root, report.baseline, options);
|
|
628
|
+
return {
|
|
629
|
+
migrated: applied.migrated,
|
|
630
|
+
...(applied.alreadyMigrated === true ? { alreadyNewFormat: true } : {}),
|
|
631
|
+
report: applied
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
if (error instanceof GitMemoError) {
|
|
636
|
+
const message = error.message;
|
|
637
|
+
let code = "other";
|
|
638
|
+
if (/blocked by \d+ conflict/.test(message))
|
|
639
|
+
code = "conflicts";
|
|
640
|
+
else if (/not clean.*refuses to discard/.test(message))
|
|
641
|
+
code = "dirty";
|
|
642
|
+
else if (/interrupted transaction journal/.test(message))
|
|
643
|
+
code = "legacy-journal";
|
|
644
|
+
else if (/baseline contains no branches/.test(message))
|
|
645
|
+
code = "no-branches";
|
|
646
|
+
return { migrated: false, blocked: { code, detail: message } };
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
566
650
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonlan/dsh-gitmemo",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo (single main branch, immutable entries, structured commit messages) with mem_search / mem_read / mem_write / mem_delete / mem_replace tools and
|
|
3
|
+
"version": "0.3.2",
|
|
4
|
+
"description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo (single main branch, immutable entries, structured commit messages) with mem_search / mem_read / mem_write / mem_delete / mem_replace tools and fully automatic legacy migration (no CLI needed; the CLI remains for explicit control), auto-managed by the root agent with zero manual steps.",
|
|
5
5
|
"author": "fonlan",
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
"release:major": "npm version major && git push && git push --tags"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@deepseek-ai/dsh-tools": "^0.1.
|
|
53
|
-
"@deepseek-ai/schemastery": "^3.18.
|
|
52
|
+
"@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
|
|
53
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
54
54
|
"@types/node": "^26.2.0",
|
|
55
55
|
"typescript": "^5.5.0"
|
|
56
56
|
},
|