@fonlan/dsh-gitmemo 0.1.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/mem.js ADDED
@@ -0,0 +1,562 @@
1
+ /**
2
+ * GitMemo core engine — git-backed long-term memory for coding agents.
3
+ *
4
+ * A faithful TypeScript port of gitmemo's `scripts/mem.sh`:
5
+ * https://github.com/fonlan/gitmemo
6
+ *
7
+ * Memory lives in a local `.mem` Git repository at the project root.
8
+ * Entries are markdown files under `.mem/entries/`, every action is a
9
+ * commit (auditable/traceable), and the `.mem` branch is kept aligned with
10
+ * the project's current branch on writes. Git is the only dependency.
11
+ *
12
+ * @module dsh-gitmemo/mem
13
+ */
14
+ import { execFile, execFileSync } from "node:child_process";
15
+ import { mkdir, writeFile, rm, copyFile } from "node:fs/promises";
16
+ import { dirname, basename, join, resolve, sep, isAbsolute } from "node:path";
17
+ const COMMIT_RE = /^[0-9a-f]{40}$/;
18
+ function git(dir, args, timeoutMs) {
19
+ return new Promise((resolvePromise, reject) => {
20
+ execFile("git", ["-C", dir, ...args], { maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs }, (error, stdout, stderr) => {
21
+ if (error !== null) {
22
+ const code = error.code;
23
+ if (code === "ENOENT") {
24
+ reject(new Error("gitmemo: git not found on PATH — gitmemo requires the git CLI"));
25
+ }
26
+ else {
27
+ const message = (stderr ?? "").trim() || (error.message ?? String(error));
28
+ reject(new Error("gitmemo: git " + (args[0] ?? "") + " failed: " + message));
29
+ }
30
+ return;
31
+ }
32
+ resolvePromise(stdout);
33
+ });
34
+ });
35
+ }
36
+ /** Synchronous git runner for the session-start injection path (must not race the first prompt assembly). */
37
+ function gitSync(dir, args, timeoutMs) {
38
+ try {
39
+ return execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs });
40
+ }
41
+ catch (error) {
42
+ const code = error.code;
43
+ if (code === "ENOENT") {
44
+ throw new Error("gitmemo: git not found on PATH — gitmemo requires the git CLI");
45
+ }
46
+ const stderr = error.stderr?.toString().trim();
47
+ throw new Error("gitmemo: git " + (args[0] ?? "") + " failed: " + (stderr || String(error)));
48
+ }
49
+ }
50
+ /** Parse one `git log --format=%H\t%s\t%cd --name-only` batch into records. */
51
+ function parseLogBatch(output) {
52
+ const records = [];
53
+ let currentHash = "";
54
+ let currentSubject = "";
55
+ let currentDate = "";
56
+ let currentFile = "";
57
+ for (const rawLine of output.split("\n")) {
58
+ const line = rawLine.replace(/\r$/, "");
59
+ if (line.length === 0)
60
+ continue;
61
+ const fields = line.split("\t");
62
+ if (COMMIT_RE.test(fields[0] ?? "")) {
63
+ if (currentHash.length > 0) {
64
+ records.push({ hash: currentHash, title: currentSubject, date: currentDate, file: currentFile });
65
+ }
66
+ currentHash = fields[0];
67
+ currentSubject = fields[1] ?? "";
68
+ currentDate = fields.slice(2).join(" ").trim();
69
+ currentFile = "";
70
+ continue;
71
+ }
72
+ if (currentFile.length === 0 && line.startsWith("entries/") && line.endsWith(".md")) {
73
+ currentFile = line;
74
+ }
75
+ }
76
+ if (currentHash.length > 0) {
77
+ records.push({ hash: currentHash, title: currentSubject, date: currentDate, file: currentFile });
78
+ }
79
+ return records;
80
+ }
81
+ /** True when a path points inside (or at) the memory entries directory. */
82
+ function isEntriesPath(memRoot, full) {
83
+ const memEntries = join(memRoot, "entries") + sep;
84
+ return full === join(memRoot, "entries") || full.startsWith(memEntries);
85
+ }
86
+ /**
87
+ * Convert a user-supplied path to an entries-relative path when possible.
88
+ * Handles `.mem/entries/...`, `entries/...`, and real files whose
89
+ * resolved path lives under <memDir>/entries (resolved against baseDir).
90
+ */
91
+ function entryFileFromPath(path, memDir, baseDir) {
92
+ if (path.length === 0)
93
+ return undefined;
94
+ const normalized = path.replaceAll("\\", "/");
95
+ if (normalized.startsWith(".mem/entries/"))
96
+ return normalized.slice(".mem/".length);
97
+ if (normalized.startsWith("entries/"))
98
+ return normalized;
99
+ // A real file whose resolved path lives under <memDir>/entries.
100
+ const abs = isAbsolute(path) ? path : resolve(baseDir, path);
101
+ if (isEntriesPath(memDir, abs)) {
102
+ return abs.slice(memDir.length + 1).split(sep).join("/");
103
+ }
104
+ return undefined;
105
+ }
106
+ /** Normalize an entry file argument to the shape accepted by git add. */
107
+ function normalizeEntryFile(file, memDir, baseDir) {
108
+ if (file.length === 0)
109
+ return "";
110
+ const direct = entryFileFromPath(file, memDir, baseDir);
111
+ if (direct !== undefined)
112
+ return direct;
113
+ const normalized = file.replaceAll("\\", "/").replace(/^\.\//, "");
114
+ if (normalized.startsWith("entries/") || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
115
+ return normalized;
116
+ }
117
+ return "entries/" + normalized;
118
+ }
119
+ /** Reject paths that could escape the memory repo. */
120
+ function isSafeEntryPath(file) {
121
+ if (file.startsWith("/") || file.includes("../") || file.endsWith("/..") || file.includes("\\") || file.includes(":")) {
122
+ return false;
123
+ }
124
+ return true;
125
+ }
126
+ /** Lowercase title -> kebab slug; falls back to "memory-entry". */
127
+ function slugifyTitle(title) {
128
+ const slug = title
129
+ .toLowerCase()
130
+ .replace(/[^a-z0-9]+/g, "-")
131
+ .replace(/^-+/, "")
132
+ .replace(/-+$/, "");
133
+ return slug.length > 0 ? slug : "memory-entry";
134
+ }
135
+ /** Current UTC timestamp in the filename format YYYYMMDDTHHMMSSZ. */
136
+ function utcTimestamp() {
137
+ const now = new Date();
138
+ const pad = (n) => String(n).padStart(2, "0");
139
+ return (String(now.getUTCFullYear()) + pad(now.getUTCMonth() + 1) + pad(now.getUTCDate()) +
140
+ "T" + pad(now.getUTCHours()) + pad(now.getUTCMinutes()) + pad(now.getUTCSeconds()) + "Z");
141
+ }
142
+ /**
143
+ * Git-backed long-term memory engine operating on one project root.
144
+ */
145
+ export class GitMemo {
146
+ root;
147
+ memDir;
148
+ config;
149
+ constructor(root, config = {}) {
150
+ this.root = root;
151
+ this.config = {
152
+ memDirName: config.memDirName ?? ".mem",
153
+ searchLimit: config.searchLimit ?? 20,
154
+ branchAlign: config.branchAlign ?? true,
155
+ gitTimeoutMs: config.gitTimeoutMs ?? 60000,
156
+ maxSearchBatches: config.maxSearchBatches ?? 200
157
+ };
158
+ this.memDir = join(this.root, this.config.memDirName);
159
+ }
160
+ /** The current branch of a git work tree, "main" when detached/unavailable. */
161
+ async currentBranch(dir) {
162
+ try {
163
+ const branch = (await git(dir, ["rev-parse", "--abbrev-ref", "HEAD"], this.config.gitTimeoutMs)).trim();
164
+ if (branch.length === 0 || branch === "HEAD")
165
+ return "main";
166
+ return branch;
167
+ }
168
+ catch {
169
+ // Unborn HEAD (repo without commits yet): resolve the symbolic ref directly.
170
+ try {
171
+ const sym = (await git(dir, ["symbolic-ref", "--short", "HEAD"], this.config.gitTimeoutMs)).trim();
172
+ if (sym.length > 0 && sym !== "HEAD")
173
+ return sym;
174
+ }
175
+ catch {
176
+ // not a git repo at all
177
+ }
178
+ return "main";
179
+ }
180
+ }
181
+ async projectBranch() {
182
+ return this.currentBranch(this.root);
183
+ }
184
+ async memBranch() {
185
+ return this.currentBranch(this.memDir);
186
+ }
187
+ /**
188
+ * Initialize the memory repo when missing (git init + .gitkeep commit).
189
+ * Idempotent; every other operation auto-initializes.
190
+ */
191
+ async init() {
192
+ await ensureInit(this.memDir, this.config.gitTimeoutMs);
193
+ return this.memDir;
194
+ }
195
+ /** Align the memory repo branch with the project branch (create if needed). */
196
+ async syncBranch() {
197
+ if (!this.config.branchAlign)
198
+ return this.memBranch();
199
+ const repoBranch = await this.projectBranch();
200
+ const memBranch = await this.memBranch();
201
+ if (memBranch !== repoBranch) {
202
+ try {
203
+ await git(this.memDir, ["show-ref", "--verify", "--quiet", "refs/heads/" + repoBranch], this.config.gitTimeoutMs);
204
+ await git(this.memDir, ["checkout", "-q", repoBranch], this.config.gitTimeoutMs);
205
+ }
206
+ catch {
207
+ await git(this.memDir, ["checkout", "-q", "-b", repoBranch], this.config.gitTimeoutMs);
208
+ }
209
+ }
210
+ return repoBranch;
211
+ }
212
+ /**
213
+ * Search past memories.
214
+ * @param keywords - comma-separated keywords.
215
+ * @param skip - pagination offset (default 0).
216
+ * @param mode - "and" (strict), "or" (broad), or "auto" (AND first, OR fallback).
217
+ */
218
+ async search(keywords, skip = 0, mode = "auto") {
219
+ const limit = this.config.searchLimit;
220
+ await ensureInit(this.memDir, this.config.gitTimeoutMs);
221
+ const keywordsList = keywords
222
+ .split(",")
223
+ .map((kw) => kw.trim())
224
+ .filter((kw) => kw.length > 0);
225
+ if (keywordsList.length === 0)
226
+ throw new Error("gitmemo: search requires at least one non-empty keyword");
227
+ if (!Number.isInteger(skip) || skip < 0)
228
+ throw new Error("gitmemo: skip must be a non-negative integer");
229
+ const normalizedMode = mode.toLowerCase();
230
+ if (!["and", "or", "auto"].includes(normalizedMode)) {
231
+ throw new Error("gitmemo: mode must be one of: and, or, auto");
232
+ }
233
+ const run = async (searchMode) => this.runSearchMode(keywordsList, searchMode, skip, limit);
234
+ if (normalizedMode === "auto") {
235
+ const andResults = await run("and");
236
+ if (andResults.length >= 3)
237
+ return { mode: "and", results: andResults };
238
+ return { mode: "or", results: await run("or") };
239
+ }
240
+ return { mode: normalizedMode, results: await run(normalizedMode) };
241
+ }
242
+ async runSearchMode(keywords, mode, skip, limit) {
243
+ const grepArgs = [];
244
+ for (const kw of keywords)
245
+ grepArgs.push("--grep=" + kw);
246
+ if (mode === "and")
247
+ grepArgs.push("--all-match");
248
+ return this.scanLog(grepArgs, skip, limit);
249
+ }
250
+ /**
251
+ * List the most recent memory entries across all branches, newest first.
252
+ * Used to seed a new session's system prompt with a compact overview.
253
+ * @param limit - maximum number of entries to return (0 returns none).
254
+ */
255
+ async recent(limit) {
256
+ if (!Number.isInteger(limit) || limit < 0)
257
+ throw new Error("gitmemo: recent limit must be a non-negative integer");
258
+ if (limit === 0)
259
+ return [];
260
+ // Read-only: never creates the .mem repo (session-start injection must not touch the workspace).
261
+ return this.scanLog([], 0, limit);
262
+ }
263
+ /** Active (non-deleted) entry files at HEAD. */
264
+ async activeEntries() {
265
+ try {
266
+ const output = await git(this.memDir, ["ls-tree", "-r", "--name-only", "HEAD", "--", "entries/"], this.config.gitTimeoutMs);
267
+ return new Set(output.split("\n").filter((line) => line.length > 0));
268
+ }
269
+ catch {
270
+ return new Set();
271
+ }
272
+ }
273
+ /** Shared git-log scan: keyword-grep optional, active-entry filtering, skip/limit. */
274
+ async scanLog(grepArgs, skip, limit) {
275
+ const active = await this.activeEntries();
276
+ const results = [];
277
+ let rawSkip = 0;
278
+ let remainingSkip = skip;
279
+ const batchSize = 200;
280
+ for (let batch = 0; batch < this.config.maxSearchBatches && results.length < limit; batch += 1) {
281
+ const args = [
282
+ "log",
283
+ ...grepArgs,
284
+ "-i",
285
+ "--skip=" + rawSkip,
286
+ "--max-count=" + batchSize,
287
+ "--format=%H\t%s\t%cd",
288
+ "--date=iso",
289
+ "--name-only",
290
+ "--all",
291
+ "--",
292
+ "entries/"
293
+ ];
294
+ let output = "";
295
+ try {
296
+ output = await git(this.memDir, args, this.config.gitTimeoutMs);
297
+ }
298
+ catch {
299
+ return results; // no history yet (or log failure) — nothing to search
300
+ }
301
+ if (output.trim().length === 0)
302
+ break;
303
+ const records = parseLogBatch(output);
304
+ for (const record of records) {
305
+ if (!this.acceptRecord(record.hash, record.title, record.file, active))
306
+ continue;
307
+ if (remainingSkip > 0) {
308
+ remainingSkip -= 1;
309
+ }
310
+ else {
311
+ results.push({ hash: record.hash, title: record.title, date: record.date });
312
+ if (results.length >= limit)
313
+ break;
314
+ }
315
+ }
316
+ if (results.length >= limit)
317
+ break;
318
+ if (records.length < batchSize)
319
+ break;
320
+ rawSkip += batchSize;
321
+ }
322
+ return results;
323
+ }
324
+ /**
325
+ * Synchronous variant of {@link recent} for the session-start injection
326
+ * path: blocks until the memory repo is scanned so the system-prompt
327
+ * context can be registered before the first request assembles.
328
+ */
329
+ recentSync(limit) {
330
+ if (!Number.isInteger(limit) || limit < 0)
331
+ throw new Error("gitmemo: recent limit must be a non-negative integer");
332
+ if (limit === 0)
333
+ return [];
334
+ let active = new Set();
335
+ try {
336
+ const output = gitSync(this.memDir, ["ls-tree", "-r", "--name-only", "HEAD", "--", "entries/"], this.config.gitTimeoutMs);
337
+ active = new Set(output.split("\n").filter((line) => line.length > 0));
338
+ }
339
+ catch {
340
+ return []; // no memory repo yet
341
+ }
342
+ const results = [];
343
+ const batchSize = 200;
344
+ let records = [];
345
+ for (let rawSkip = 0, batch = 0; batch < this.config.maxSearchBatches && results.length < limit; rawSkip += batchSize, batch += 1) {
346
+ const args = [
347
+ "log",
348
+ "--skip=" + rawSkip,
349
+ "--max-count=" + batchSize,
350
+ "--format=%H\t%s\t%cd",
351
+ "--date=iso",
352
+ "--name-only",
353
+ "--all",
354
+ "--",
355
+ "entries/"
356
+ ];
357
+ let output;
358
+ try {
359
+ output = gitSync(this.memDir, args, this.config.gitTimeoutMs);
360
+ }
361
+ catch {
362
+ return results;
363
+ }
364
+ if (output.trim().length === 0)
365
+ break;
366
+ records = parseLogBatch(output);
367
+ for (const record of records) {
368
+ if (!this.acceptRecord(record.hash, record.title, record.file, active))
369
+ continue;
370
+ results.push({ hash: record.hash, title: record.title, date: record.date });
371
+ if (results.length >= limit)
372
+ break;
373
+ }
374
+ if (results.length >= limit || records.length < batchSize)
375
+ break;
376
+ }
377
+ return results;
378
+ }
379
+ acceptRecord(hash, subject, file, active) {
380
+ void hash;
381
+ if (subject.startsWith("delete: remove"))
382
+ return false;
383
+ if (file.length === 0 || !active.has(file))
384
+ return false;
385
+ return true;
386
+ }
387
+ /** Resolve the entry file touched by a commit (first file under entries/). */
388
+ async entryFileFromCommit(commitHash) {
389
+ let output = "";
390
+ try {
391
+ output = await git(this.memDir, ["diff-tree", "--no-commit-id", "--name-only", "-r", commitHash, "--", "entries/"], this.config.gitTimeoutMs);
392
+ }
393
+ catch {
394
+ return undefined;
395
+ }
396
+ let file = output.split("\n").find((line) => line.startsWith("entries/") && line.endsWith(".md"));
397
+ if (file === undefined) {
398
+ try {
399
+ output = await git(this.memDir, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", commitHash, "--", "entries/"], this.config.gitTimeoutMs);
400
+ file = output.split("\n").find((line) => line.startsWith("entries/") && line.endsWith(".md"));
401
+ }
402
+ catch {
403
+ return undefined;
404
+ }
405
+ }
406
+ return file;
407
+ }
408
+ /** Read one memory entry by commit hash. */
409
+ async read(commitHash) {
410
+ if (!/^[0-9a-f]{7,}$/.test(commitHash))
411
+ throw new Error("gitmemo: read requires a commit hash");
412
+ await ensureInit(this.memDir, this.config.gitTimeoutMs);
413
+ const file = await this.entryFileFromCommit(commitHash);
414
+ if (file === undefined)
415
+ throw new Error("gitmemo: no entry file found in commit " + commitHash);
416
+ const content = await git(this.memDir, ["show", commitHash + ":" + file], this.config.gitTimeoutMs);
417
+ return { file, content };
418
+ }
419
+ /**
420
+ * Write a memory entry.
421
+ * Mirrors mem.sh write: commit a markdown entry under entries/ with an
422
+ * optional body, aligning the .mem branch with the project branch first.
423
+ */
424
+ async write(options) {
425
+ await ensureInit(this.memDir, this.config.gitTimeoutMs);
426
+ const { title, content, contentFile, file, body, bodyFile } = options;
427
+ if (title === undefined || title.trim().length === 0) {
428
+ throw new Error("gitmemo: write requires --title");
429
+ }
430
+ if (body !== undefined && bodyFile !== undefined)
431
+ throw new Error("gitmemo: use only one of body or bodyFile");
432
+ if (content !== undefined && contentFile !== undefined)
433
+ throw new Error("gitmemo: use only one of content or contentFile");
434
+ // Resolve optional payload paths against the engine root when relative.
435
+ const resolvePayload = (p) => p === undefined ? undefined : (isAbsolute(p) ? p : resolve(this.root, p));
436
+ const fullContentFile = resolvePayload(contentFile);
437
+ const fullBodyFile = resolvePayload(bodyFile);
438
+ if (fullContentFile !== undefined) {
439
+ await fsAccess(fullContentFile).catch(() => {
440
+ throw new Error("gitmemo: content file not found: " + fullContentFile);
441
+ });
442
+ }
443
+ let resolvedBody = body;
444
+ if (fullBodyFile !== undefined) {
445
+ resolvedBody = await readFileSafe(fullBodyFile, "gitmemo: body file not found");
446
+ }
447
+ const directContentFile = fullContentFile !== undefined ? entryFileFromPath(fullContentFile, this.memDir, this.root) : undefined;
448
+ let entryFile;
449
+ if (file !== undefined) {
450
+ entryFile = normalizeEntryFile(file, this.memDir, this.root);
451
+ if (directContentFile !== undefined && entryFile !== directContentFile) {
452
+ throw new Error("gitmemo: --file must match the existing .mem/entries path referenced by --content-file");
453
+ }
454
+ }
455
+ else if (directContentFile !== undefined) {
456
+ entryFile = directContentFile;
457
+ }
458
+ else if (content !== undefined || fullContentFile !== undefined) {
459
+ entryFile = "entries/" + utcTimestamp() + "-" + slugifyTitle(title) + ".md";
460
+ }
461
+ else {
462
+ throw new Error("gitmemo: missing content — use content, contentFile, or a pre-written .mem/entries file via file");
463
+ }
464
+ if (!isSafeEntryPath(entryFile))
465
+ throw new Error("gitmemo: invalid file path: " + entryFile);
466
+ const finalFile = entryFile.endsWith(".md") ? entryFile : entryFile + ".md";
467
+ await this.syncBranch();
468
+ const fullPath = join(this.memDir, finalFile);
469
+ let useExisting = false;
470
+ let deleteContentFile = false;
471
+ if (directContentFile !== undefined) {
472
+ useExisting = true;
473
+ }
474
+ else if (content === undefined && fullContentFile === undefined) {
475
+ // Commit a pre-written entry file in place.
476
+ useExisting = true;
477
+ }
478
+ else if (fullContentFile !== undefined) {
479
+ deleteContentFile = true;
480
+ }
481
+ if (useExisting) {
482
+ // The file must exist at its final path (after branch sync).
483
+ try {
484
+ await fsAccess(fullPath);
485
+ }
486
+ catch {
487
+ throw new Error("gitmemo: existing entry file not found after branch sync: " + fullPath);
488
+ }
489
+ }
490
+ else {
491
+ await mkdir(dirname(fullPath), { recursive: true });
492
+ if (fullContentFile !== undefined) {
493
+ await copyFile(fullContentFile, fullPath);
494
+ }
495
+ else {
496
+ await writeFile(fullPath, content ?? "", "utf8");
497
+ }
498
+ }
499
+ await git(this.memDir, ["add", "--", finalFile], this.config.gitTimeoutMs);
500
+ if (resolvedBody !== undefined && resolvedBody.trim().length > 0) {
501
+ await git(this.memDir, ["commit", "-q", "-m", title, "-m", resolvedBody], this.config.gitTimeoutMs);
502
+ }
503
+ else {
504
+ await git(this.memDir, ["commit", "-q", "-m", title], this.config.gitTimeoutMs);
505
+ }
506
+ const hash = (await git(this.memDir, ["rev-parse", "HEAD"], this.config.gitTimeoutMs)).trim();
507
+ if (deleteContentFile && fullContentFile !== undefined) {
508
+ await rm(fullContentFile, { force: true }).catch(() => {
509
+ // Write succeeded; temp cleanup failure is only a warning.
510
+ });
511
+ }
512
+ return { hash, file: finalFile };
513
+ }
514
+ /** Delete a memory entry by commit hash. */
515
+ async delete(commitHash) {
516
+ if (!/^[0-9a-f]{7,}$/.test(commitHash))
517
+ throw new Error("gitmemo: delete requires a commit hash");
518
+ await ensureInit(this.memDir, this.config.gitTimeoutMs);
519
+ const file = await this.entryFileFromCommit(commitHash);
520
+ if (file === undefined)
521
+ throw new Error("gitmemo: no entry file found in commit " + commitHash);
522
+ try {
523
+ await fsAccess(join(this.memDir, file));
524
+ }
525
+ catch {
526
+ throw new Error("gitmemo: file already deleted: " + file);
527
+ }
528
+ await git(this.memDir, ["rm", "-q", "--", file], this.config.gitTimeoutMs);
529
+ await git(this.memDir, ["commit", "-q", "-m", "delete: remove " + basename(file, ".md")], this.config.gitTimeoutMs);
530
+ return file;
531
+ }
532
+ }
533
+ /** Initialize the memory repo when missing. */
534
+ export async function ensureInit(memDir, gitTimeoutMs = 60000) {
535
+ let initialized = false;
536
+ try {
537
+ await fsAccess(join(memDir, ".git"));
538
+ initialized = true;
539
+ }
540
+ catch {
541
+ initialized = false;
542
+ }
543
+ if (initialized)
544
+ return;
545
+ await mkdir(join(memDir, "entries"), { recursive: true });
546
+ await git(memDir, ["init", "-q"], gitTimeoutMs);
547
+ await writeFile(join(memDir, "entries", ".gitkeep"), "", "utf8");
548
+ await git(memDir, ["add", "entries/.gitkeep"], gitTimeoutMs);
549
+ await git(memDir, ["commit", "-q", "-m", "init: initialize memory repo"], gitTimeoutMs);
550
+ }
551
+ async function fsAccess(path) {
552
+ await import("node:fs/promises").then((fs) => fs.access(path));
553
+ }
554
+ async function readFileSafe(path, errorPrefix) {
555
+ try {
556
+ return await import("node:fs/promises").then((fs) => fs.readFile(path, "utf8"));
557
+ }
558
+ catch {
559
+ throw new Error(errorPrefix + ": " + path);
560
+ }
561
+ }
562
+ export { isEntriesPath };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@fonlan/dsh-gitmemo",
3
+ "version": "0.1.0",
4
+ "description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo with mem_search / mem_read / mem_write / mem_delete tools, auto-managed by the agent with zero manual steps.",
5
+ "author": "fonlan",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/fonlan/dsh-gitmemo.git"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./mem": {
22
+ "types": "./lib/mem.d.ts",
23
+ "default": "./lib/mem.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib",
30
+ "cordis.patch.yml",
31
+ "README.md",
32
+ "README.zh.md"
33
+ ],
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "typecheck": "tsc --noEmit -p tsconfig.json",
37
+ "test": "npm run build && node --test"
38
+ },
39
+ "dependencies": {
40
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
41
+ "@deepseek-ai/schemastery": "^3.18.1"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^26.2.0",
45
+ "typescript": "^5.5.0"
46
+ },
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ }
51
+ },
52
+ "keywords": [
53
+ "deepseek-harness",
54
+ "dsh",
55
+ "cordis",
56
+ "plugin",
57
+ "memory",
58
+ "git",
59
+ "long-term-memory"
60
+ ],
61
+ "license": "MIT"
62
+ }