@wjm200509/dsh-memory 0.0.0-stage → 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/state.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Durable dreaming state under `<memory root>/.state/`:
3
+ * state.json — pending queue, per-session watermarks, pause flag,
4
+ * cross-project bookkeeping
5
+ * log.jsonl — run log, last 200 entries
6
+ *
7
+ * A session leaves the queue only through `complete()` after a successful
8
+ * run, so a host shutdown mid-run keeps the batch queued. Writes are chained
9
+ * so concurrent callers never interleave a read-modify-write.
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { renameWithRetry } from "./fsutil.js";
15
+
16
+ const LOG_LIMIT = 200;
17
+
18
+ /**
19
+ * @typedef {{ sessionId: string, cwd: string, endedAt: number, seq: number }} Job
20
+ * @typedef {{ at: number, cwd?: string, scope?: "cross", sessionIds: string[], ok: boolean, trigger: "auto" | "manual",
21
+ * changes?: { created: number, modified: number, deleted: number, paths?: object }, issues?: string[], summary?: string,
22
+ * error?: string, dreamSessionId?: string }} LogEntry
23
+ * A cross-project run has `scope: "cross"` and no `cwd`; `issues` lists what an ok run left unresolved.
24
+ */
25
+
26
+ async function readIfExists(file) {
27
+ try {
28
+ return await readFile(file, "utf8");
29
+ } catch (error) {
30
+ if (error.code === "ENOENT") return null;
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ async function writeAtomic(file, content) {
36
+ const temp = `${file}.${randomUUID()}.tmp`;
37
+ try {
38
+ await writeFile(temp, content, "utf8");
39
+ await renameWithRetry(temp, file);
40
+ } catch (error) {
41
+ await rm(temp, { force: true }).catch(() => {});
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ export class DreamState {
47
+ /**
48
+ * @param {string} root - memory root.
49
+ * @param {{ now?: () => number }} [options]
50
+ */
51
+ constructor(root, { now = Date.now } = {}) {
52
+ this.now = now;
53
+ this.dir = join(root, ".state");
54
+ this.stateFile = join(this.dir, "state.json");
55
+ this.logFile = join(this.dir, "log.jsonl");
56
+ /** @type {{ queue: Job[], watermarks: Record<string, number>, paused: { since: number, reason: string } | null, cross?: { at?: number, projects: string[] } }} */
57
+ this.data = { queue: [], watermarks: {}, paused: null };
58
+ /** @type {LogEntry[]} */
59
+ this.log = [];
60
+ /** True when `load()` found an unreadable state.json and started fresh. */
61
+ this.recovered = false;
62
+ this.chain = Promise.resolve();
63
+ }
64
+
65
+ /** Read persisted state; a missing directory means a fresh install. */
66
+ async load() {
67
+ const raw = await readIfExists(this.stateFile);
68
+ if (raw !== null) {
69
+ try {
70
+ const parsed = JSON.parse(raw);
71
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("state.json is not an object");
72
+ const data = { queue: [], watermarks: {}, paused: null, ...parsed };
73
+ if (!Array.isArray(data.queue) || typeof data.watermarks !== "object" || data.watermarks === null) throw new Error("state.json has a bad shape");
74
+ this.data = data;
75
+ } catch {
76
+ /* A corrupt state file must not stop the plugin: keep it aside for inspection and start fresh. */
77
+ await rename(this.stateFile, `${this.stateFile}.corrupt-${this.now()}`).catch(() => {});
78
+ this.recovered = true;
79
+ }
80
+ }
81
+ /* First load marks the install time: sessions created earlier are never backfilled. */
82
+ if (typeof this.data.installedAt !== "number") {
83
+ const installedAt = this.now();
84
+ await this.persist(() => {
85
+ this.data.installedAt = installedAt;
86
+ });
87
+ }
88
+ const log = await readIfExists(this.logFile);
89
+ /* An append torn by a crash leaves a partial last line; skip unparsable lines rather than refuse to load. */
90
+ this.log = log === null ? [] : log.split("\n").flatMap((line) => {
91
+ try {
92
+ return line.trim() === "" ? [] : [JSON.parse(line)];
93
+ } catch {
94
+ return [];
95
+ }
96
+ }).slice(-LOG_LIMIT);
97
+ }
98
+
99
+ /** Run one mutation after every earlier one, then persist. */
100
+ persist(mutate) {
101
+ const next = this.chain.then(async () => {
102
+ mutate();
103
+ await mkdir(this.dir, { recursive: true });
104
+ await writeAtomic(this.stateFile, JSON.stringify(this.data));
105
+ });
106
+ this.chain = next.catch(() => {});
107
+ return next;
108
+ }
109
+
110
+ /** Queue one ended session; a re-queued session keeps only its latest position. */
111
+ enqueue(job) {
112
+ return this.persist(() => {
113
+ this.data.queue = this.data.queue.filter((queued) => queued.sessionId !== job.sessionId);
114
+ this.data.queue.push({ sessionId: job.sessionId, cwd: job.cwd, endedAt: job.endedAt, seq: job.seq });
115
+ });
116
+ }
117
+
118
+ /** @returns {Job[]} queued sessions, oldest ending first, optionally for one project. */
119
+ pending(cwd) {
120
+ return this.data.queue.filter((job) => cwd === undefined || job.cwd === cwd).sort((a, b) => a.endedAt - b.endedAt).map((job) => ({ ...job }));
121
+ }
122
+
123
+ /**
124
+ * Advance watermarks to the seqs a run actually covered, and dequeue those
125
+ * sessions unless they were re-queued with newer events meanwhile.
126
+ * @param {Array<{ sessionId: string, seq: number }>} covered - the batch as captured when the run started.
127
+ */
128
+ complete(covered) {
129
+ const seqs = new Map(covered.map((job) => [job.sessionId, job.seq]));
130
+ return this.persist(() => {
131
+ for (const [sessionId, seq] of seqs) this.data.watermarks[sessionId] = Math.max(seq, this.data.watermarks[sessionId] ?? 0);
132
+ for (const [sessionId, seq] of seqs) {
133
+ const queued = this.data.queue.find((job) => job.sessionId === sessionId);
134
+ if (queued === undefined || queued.seq <= seq) delete this.data.attempts?.[sessionId];
135
+ }
136
+ this.data.queue = this.data.queue.filter((job) => !seqs.has(job.sessionId) || job.seq > seqs.get(job.sessionId));
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Count one failed run against each session of its batch.
142
+ * @returns {Promise<Record<string, number>>} the new counts.
143
+ */
144
+ noteFailure(sessionIds) {
145
+ const counts = {};
146
+ return this.persist(() => {
147
+ this.data.attempts ??= {};
148
+ for (const id of sessionIds) counts[id] = this.data.attempts[id] = (this.data.attempts[id] ?? 0) + 1;
149
+ }).then(() => counts);
150
+ }
151
+
152
+ /** A session made progress: its earlier failures no longer count against it. */
153
+ clearAttempts(sessionIds) {
154
+ if (!sessionIds.some((id) => this.data.attempts?.[id] !== undefined)) return Promise.resolve();
155
+ return this.persist(() => {
156
+ for (const id of sessionIds) delete this.data.attempts?.[id];
157
+ });
158
+ }
159
+
160
+ /** @returns {number} failed runs counted against a session since it last completed. */
161
+ attempts(sessionId) {
162
+ return this.data.attempts?.[sessionId] ?? 0;
163
+ }
164
+
165
+ /** Forget watermarks and failure counts of sessions that no longer exist (queued ones are kept). */
166
+ pruneSessions(existingIds) {
167
+ const keep = new Set(existingIds);
168
+ const stale = (id) => !keep.has(id) && !this.data.queue.some((job) => job.sessionId === id);
169
+ const ids = [...Object.keys(this.data.watermarks), ...Object.keys(this.data.attempts ?? {})];
170
+ if (!ids.some(stale)) return Promise.resolve();
171
+ return this.persist(() => {
172
+ for (const id of Object.keys(this.data.watermarks)) if (stale(id)) delete this.data.watermarks[id];
173
+ for (const id of Object.keys(this.data.attempts ?? {})) if (stale(id)) delete this.data.attempts[id];
174
+ });
175
+ }
176
+
177
+ /** @returns {string[]} projects consolidated since the last cross-project run. */
178
+ crossProjects() {
179
+ return [...(this.data.cross?.projects ?? [])];
180
+ }
181
+
182
+ /** @returns {number | undefined} epoch ms of the last cross-project run. */
183
+ get crossAt() {
184
+ return this.data.cross?.at;
185
+ }
186
+
187
+ /** Note a successful project run, so a cross-project run becomes due. */
188
+ noteProjectRun(cwd) {
189
+ return this.persist(() => {
190
+ const projects = this.data.cross?.projects ?? [];
191
+ /* The first project run starts the day: a cross-project run is never due right after it. */
192
+ this.data.cross = { ...this.data.cross, at: this.data.cross?.at ?? this.now(), projects: projects.includes(cwd) ? projects : [...projects, cwd] };
193
+ });
194
+ }
195
+
196
+ /** Mark a cross-project run done. */
197
+ completeCross(at) {
198
+ return this.persist(() => {
199
+ this.data.cross = { at, projects: [] };
200
+ });
201
+ }
202
+
203
+ /** @returns {number} last consolidated event seq for a session, 0 if never. */
204
+ watermark(sessionId) {
205
+ return this.data.watermarks[sessionId] ?? 0;
206
+ }
207
+
208
+ /** @returns {number} epoch ms when this memory root was first used by the plugin. */
209
+ get installedAt() {
210
+ return this.data.installedAt;
211
+ }
212
+
213
+ get paused() {
214
+ return this.data.paused;
215
+ }
216
+
217
+ /** @param {{ since: number, reason: string, kind?: "auth" | "repeated" | "manual" } | null} value */
218
+ setPaused(value) {
219
+ return this.persist(() => {
220
+ this.data.paused = value;
221
+ });
222
+ }
223
+
224
+ /** Append one run to the log, rewriting the file when it exceeds the limit. */
225
+ appendLog(entry) {
226
+ const next = this.chain.then(async () => {
227
+ this.log.push(entry);
228
+ await mkdir(this.dir, { recursive: true });
229
+ if (this.log.length > LOG_LIMIT) {
230
+ this.log = this.log.slice(-LOG_LIMIT);
231
+ await writeAtomic(this.logFile, this.log.map((item) => JSON.stringify(item)).join("\n") + "\n");
232
+ } else await appendFile(this.logFile, JSON.stringify(entry) + "\n", "utf8");
233
+ });
234
+ this.chain = next.catch(() => {});
235
+ return next;
236
+ }
237
+
238
+ /** @returns {LogEntry[]} the last `n` runs, oldest first. */
239
+ recentLog(n) {
240
+ return this.log.slice(-n);
241
+ }
242
+ }
package/lib/status.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Publishes the runner status into the plugin's own `status` settings field
3
+ * (the host→client channel). The settings service only writes entries whose
4
+ * plugin is already active, so a write attempted while the plugin is still
5
+ * loading fails; a failed write is retried and is never remembered as
6
+ * published, so the next identical status is not skipped.
7
+ */
8
+
9
+ const RETRY_MS = 2000;
10
+
11
+ /**
12
+ * @param {{ name: string, root: string, warn: (message: string) => void,
13
+ * setTimer?: (fn: () => void, ms: number) => unknown, clearTimer?: (handle: unknown) => void, retryMs?: number }} options
14
+ */
15
+ export function createStatusPublisher({ name, root, warn, setTimer = setTimeout, clearTimer = clearTimeout, retryMs = RETRY_MS }) {
16
+ let settings;
17
+ let latest;
18
+ let published;
19
+ let retry;
20
+
21
+ const flush = () => {
22
+ if (settings === undefined || latest === undefined || latest === published) return;
23
+ const next = latest;
24
+ const target = settings;
25
+ target.update(name, { status: JSON.parse(next) }).then(() => {
26
+ published = next;
27
+ }, (error) => {
28
+ if (retry !== undefined || settings !== target) return;
29
+ warn(`memory: could not publish status (will retry): ${error?.message}`);
30
+ retry = setTimer(() => {
31
+ retry = undefined;
32
+ flush();
33
+ }, retryMs);
34
+ });
35
+ };
36
+
37
+ return {
38
+ /** Start writing through the settings service. */
39
+ attach(service) {
40
+ settings = service;
41
+ flush();
42
+ },
43
+ /** Stop writing (settings service gone or plugin unloading). */
44
+ detach() {
45
+ settings = undefined;
46
+ if (retry !== undefined) clearTimer(retry);
47
+ retry = undefined;
48
+ },
49
+ /** Record the newest status and write it when possible. */
50
+ publish(status) {
51
+ latest = JSON.stringify({ ...status, root });
52
+ flush();
53
+ }
54
+ };
55
+ }
package/lib/store.js ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Memory note storage with optimistic concurrency.
3
+ *
4
+ * Sessions and dreaming can write the same note. Every read returns a
5
+ * `version` (content hash); a write or delete must present the version it
6
+ * read, or `null` to create a note that must not exist yet. A mismatch throws
7
+ * `MemoryConflictError` and leaves the file untouched, so the caller re-reads
8
+ * and rewrites instead of silently overwriting. The version is a content hash
9
+ * rather than mtime/size, which two quick same-length writes could collide on.
10
+ */
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ import { readFileSync } from "node:fs";
13
+ import { mkdir, readFile, readdir, rm, rmdir, stat, writeFile, unlink } from "node:fs/promises";
14
+ import { dirname, join, relative } from "node:path";
15
+ import { partitionDir, resolveEntry } from "./paths.js";
16
+ import { renameWithRetry } from "./fsutil.js";
17
+
18
+ /** Dreaming runs whose pre-change note copies are kept. */
19
+ export const HISTORY_KEEP = 20;
20
+ /** Leftover `.tmp` files older than this are swept. */
21
+ const STALE_TMP_MS = 3_600_000;
22
+ const SEARCH_HITS = 50;
23
+
24
+ /** Error for a write or delete whose expected version no longer matches. */
25
+ export class MemoryConflictError extends Error {
26
+ code = "MEMORY_CONFLICT";
27
+ /**
28
+ * @param {string} path - the note path.
29
+ * @param {string | null} currentVersion - version on disk, or null when the note is gone.
30
+ */
31
+ constructor(path, currentVersion) {
32
+ super(`memory note ${path} changed since it was read`);
33
+ this.name = "MemoryConflictError";
34
+ this.currentVersion = currentVersion;
35
+ }
36
+ }
37
+
38
+ const versionOf = (content) => createHash("sha256").update(content).digest("hex").slice(0, 16);
39
+
40
+ async function readIfExists(file) {
41
+ try {
42
+ return await readFile(file, "utf8");
43
+ } catch (error) {
44
+ if (error.code === "ENOENT") return null;
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Copies a note's content aside before a dreaming run first changes it, to
51
+ * `<root>/.state/history/<runId>/<path relative to the root>`, so a run's
52
+ * changes can be undone by copying the files back.
53
+ * @param {string} root - memory root.
54
+ * @param {string} runId - directory name for this run.
55
+ * @returns {(file: string, content: string) => Promise<void>}
56
+ */
57
+ export function createBackup(root, runId) {
58
+ const done = new Set();
59
+ return async (file, content) => {
60
+ const rel = relative(root, file);
61
+ if (done.has(rel.toLowerCase())) return;
62
+ done.add(rel.toLowerCase());
63
+ const dest = join(root, ".state", "history", runId, rel);
64
+ await mkdir(dirname(dest), { recursive: true });
65
+ await writeFile(dest, content, "utf8");
66
+ };
67
+ }
68
+
69
+ /** Keep only the newest `keep` run snapshots under `<root>/.state/history/`. */
70
+ export async function pruneHistory(root, keep = HISTORY_KEEP) {
71
+ const dir = join(root, ".state", "history");
72
+ let names;
73
+ try {
74
+ names = await readdir(dir);
75
+ } catch {
76
+ return;
77
+ }
78
+ const runs = [];
79
+ for (const name of names) {
80
+ const info = await stat(join(dir, name)).catch(() => null);
81
+ if (info?.isDirectory()) runs.push({ name, at: info.mtimeMs });
82
+ }
83
+ runs.sort((a, b) => b.at - a.at);
84
+ for (const { name } of runs.slice(keep)) await rm(join(dir, name), { recursive: true, force: true });
85
+ }
86
+
87
+ /**
88
+ * Records the notes one run changed, for the dreaming log. A note counts
89
+ * once, by its state before and after the run: written three times is one
90
+ * modification; created and then deleted is no change.
91
+ * @param {{ backup?: (file: string, content: string) => Promise<void> }} [options] - copies a note aside before its first change.
92
+ */
93
+ export function createChangeTracker({ backup } = {}) {
94
+ /** path → whether it existed before the run, and whether it exists now. */
95
+ const notes = new Map();
96
+ return {
97
+ backup,
98
+ /** @param {"created" | "modified" | "deleted"} kind @param {string} path - note label, e.g. "user/INDEX.md". */
99
+ record(kind, path) {
100
+ const note = notes.get(path) ?? { before: kind !== "created" };
101
+ note.after = kind !== "deleted";
102
+ notes.set(path, note);
103
+ },
104
+ /** @returns {{ created: number, modified: number, deleted: number, paths: { created: string[], modified: string[], deleted: string[] } }} */
105
+ summary() {
106
+ const paths = { created: [], modified: [], deleted: [] };
107
+ for (const [path, { before, after }] of notes) {
108
+ if (!before && after) paths.created.push(path);
109
+ else if (before && after) paths.modified.push(path);
110
+ else if (before && !after) paths.deleted.push(path);
111
+ }
112
+ return { created: paths.created.length, modified: paths.modified.length, deleted: paths.deleted.length, paths };
113
+ }
114
+ };
115
+ }
116
+
117
+ export class MemoryStore {
118
+ /** @param {string} root - memory root (see `resolveMemoryRoot`). */
119
+ constructor(root) {
120
+ this.root = root;
121
+ /** Per-file tail of the in-process write queue; sessions and dreaming share one process. */
122
+ this.locks = new Map();
123
+ }
124
+
125
+ /** Run `fn` after every earlier mutation of the same file, so check-then-rename is atomic in-process. */
126
+ async exclusive(file, fn) {
127
+ /* Case-folded: on Windows and macOS `Note.md` and `note.md` are one file. */
128
+ const key = file.toLowerCase();
129
+ const previous = this.locks.get(key) ?? Promise.resolve();
130
+ let release;
131
+ const current = new Promise((resolve) => { release = resolve; });
132
+ const tail = previous.then(() => current);
133
+ this.locks.set(key, tail);
134
+ await previous;
135
+ try {
136
+ return await fn();
137
+ } finally {
138
+ release();
139
+ if (this.locks.get(key) === tail) this.locks.delete(key);
140
+ }
141
+ }
142
+
143
+ /** @returns {Promise<Array<{ path: string, bytes: number, version: string }>>} every note in the partition. */
144
+ async list(scope, cwd) {
145
+ const dir = partitionDir(this.root, scope, cwd);
146
+ const out = [];
147
+ const walk = async (abs, rel) => {
148
+ let entries;
149
+ try {
150
+ entries = await readdir(abs, { withFileTypes: true });
151
+ } catch (error) {
152
+ if (error.code === "ENOENT") return;
153
+ throw error;
154
+ }
155
+ for (const entry of entries) {
156
+ if (entry.name.startsWith(".")) continue;
157
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
158
+ if (entry.isDirectory()) await walk(join(abs, entry.name), relPath);
159
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
160
+ const content = await readFile(join(abs, entry.name), "utf8");
161
+ out.push({ path: relPath, bytes: Buffer.byteLength(content), version: versionOf(content) });
162
+ }
163
+ }
164
+ };
165
+ await walk(dir, "");
166
+ return out;
167
+ }
168
+
169
+ /** @returns {Promise<{ content: string, version: string } | null>} the note, or null when absent. */
170
+ async read(scope, cwd, path) {
171
+ const content = await readIfExists(resolveEntry(partitionDir(this.root, scope, cwd), path));
172
+ return content === null ? null : { content, version: versionOf(content) };
173
+ }
174
+
175
+ /**
176
+ * Create or replace a note.
177
+ * @param {string | null} expectedVersion - version read before editing, or null to create.
178
+ * @param {ReturnType<typeof createChangeTracker>} [tracker]
179
+ * @returns {Promise<{ version: string }>}
180
+ */
181
+ async write(scope, cwd, path, content, expectedVersion, tracker) {
182
+ const file = resolveEntry(partitionDir(this.root, scope, cwd), path);
183
+ return this.exclusive(file, async () => {
184
+ const current = await readIfExists(file);
185
+ const currentVersion = current === null ? null : versionOf(current);
186
+ if (currentVersion !== expectedVersion) throw new MemoryConflictError(path, currentVersion);
187
+ await mkdir(dirname(file), { recursive: true });
188
+ if (current !== null) await tracker?.backup?.(file, current);
189
+ const temp = join(dirname(file), `.${randomUUID()}.tmp`);
190
+ try {
191
+ await writeFile(temp, content, "utf8");
192
+ await renameWithRetry(temp, file);
193
+ } catch (error) {
194
+ await rm(temp, { force: true });
195
+ throw error;
196
+ }
197
+ tracker?.record(current === null ? "created" : "modified", path);
198
+ return { version: versionOf(content) };
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Delete a note.
204
+ * @param {string} expectedVersion - version read before deleting.
205
+ * @param {ReturnType<typeof createChangeTracker>} [tracker]
206
+ */
207
+ async remove(scope, cwd, path, expectedVersion, tracker) {
208
+ const file = resolveEntry(partitionDir(this.root, scope, cwd), path);
209
+ await this.exclusive(file, async () => {
210
+ const current = await readIfExists(file);
211
+ const currentVersion = current === null ? null : versionOf(current);
212
+ if (currentVersion === null || currentVersion !== expectedVersion) throw new MemoryConflictError(path, currentVersion);
213
+ await tracker?.backup?.(file, current);
214
+ await unlink(file);
215
+ tracker?.record("deleted", path);
216
+ });
217
+ await this.pruneEmptyDirs(partitionDir(this.root, scope, cwd), dirname(file));
218
+ }
219
+
220
+ /**
221
+ * Move a note, possibly into another partition, in one rename: the note is
222
+ * never missing and never duplicated. The target must not exist.
223
+ * @param {{ scope: "project" | "user", cwd?: string, path: string }} from
224
+ * @param {{ scope: "project" | "user", cwd?: string, path: string }} to
225
+ * @param {string} expectedVersion - version of the source read before moving.
226
+ * @param {{ backup?: Function }} [tracker] - only `backup` is used; the caller records the change under its own labels.
227
+ * @returns {Promise<{ version: string }>}
228
+ */
229
+ async move(from, to, expectedVersion, tracker) {
230
+ const source = resolveEntry(partitionDir(this.root, from.scope, from.cwd), from.path);
231
+ const target = resolveEntry(partitionDir(this.root, to.scope, to.cwd), to.path);
232
+ if (source.toLowerCase() === target.toLowerCase()) throw new Error("source and target are the same note");
233
+ const [first, second] = source.toLowerCase() < target.toLowerCase() ? [source, target] : [target, source];
234
+ const result = await this.exclusive(first, () => this.exclusive(second, async () => {
235
+ const current = await readIfExists(source);
236
+ const currentVersion = current === null ? null : versionOf(current);
237
+ if (currentVersion === null || currentVersion !== expectedVersion) throw new MemoryConflictError(from.path, currentVersion);
238
+ if ((await readIfExists(target)) !== null) throw new Error(`${to.path} already exists in the target partition; pick another path, or merge into it with memory_write and delete the source.`);
239
+ await tracker?.backup?.(source, current);
240
+ await mkdir(dirname(target), { recursive: true });
241
+ await renameWithRetry(source, target);
242
+ return { version: versionOf(current) };
243
+ }));
244
+ await this.pruneEmptyDirs(partitionDir(this.root, from.scope, from.cwd), dirname(source));
245
+ return result;
246
+ }
247
+
248
+ /** Remove now-empty note directories up to (not including) the partition directory. */
249
+ async pruneEmptyDirs(partition, dir) {
250
+ for (;;) {
251
+ const rel = relative(partition, dir);
252
+ if (rel === "" || rel.startsWith("..") || rel === dir) return;
253
+ try {
254
+ await rmdir(dir);
255
+ } catch {
256
+ return;
257
+ }
258
+ dir = dirname(dir);
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Case-insensitive literal search across one partition's notes.
264
+ * @returns {Promise<string[]>} `path:line: text` hits, at most 50.
265
+ */
266
+ async search(scope, cwd, query) {
267
+ const needle = query.toLowerCase();
268
+ const hits = [];
269
+ for (const { path } of await this.list(scope, cwd)) {
270
+ const note = await this.read(scope, cwd, path);
271
+ for (const [index, line] of (note?.content ?? "").split("\n").entries()) {
272
+ if (!line.toLowerCase().includes(needle)) continue;
273
+ hits.push(`${path}:${index + 1}: ${line.trim().slice(0, 200)}`);
274
+ if (hits.length >= SEARCH_HITS) return hits;
275
+ }
276
+ }
277
+ return hits;
278
+ }
279
+
280
+ /** Delete `.tmp` files that a crash left in the partitions. */
281
+ async sweep(now = Date.now()) {
282
+ const walk = async (dir) => {
283
+ let entries;
284
+ try {
285
+ entries = await readdir(dir, { withFileTypes: true });
286
+ } catch {
287
+ return;
288
+ }
289
+ for (const entry of entries) {
290
+ const abs = join(dir, entry.name);
291
+ if (entry.isDirectory()) await walk(abs);
292
+ else if (entry.isFile() && entry.name.endsWith(".tmp")) {
293
+ const info = await stat(abs).catch(() => null);
294
+ if (info !== null && now - info.mtimeMs > STALE_TMP_MS) await rm(abs, { force: true });
295
+ }
296
+ }
297
+ };
298
+ await walk(join(this.root, "user"));
299
+ await walk(join(this.root, "projects"));
300
+ }
301
+
302
+ /** @returns {Promise<string | null>} the partition's INDEX.md content, or null. */
303
+ async readIndex(scope, cwd) {
304
+ return (await this.read(scope, cwd, "INDEX.md"))?.content ?? null;
305
+ }
306
+
307
+ /**
308
+ * Synchronous INDEX read for system-prompt section callbacks, which DSH
309
+ * evaluates synchronously; called once per session snapshot.
310
+ * @returns {string | null} the partition's INDEX.md content, or null.
311
+ */
312
+ readIndexSync(scope, cwd) {
313
+ try {
314
+ return readFileSync(resolveEntry(partitionDir(this.root, scope, cwd), "INDEX.md"), "utf8");
315
+ } catch {
316
+ /* A missing or unreadable INDEX (EACCES, EISDIR) must not break the session's prompt assembly: act as if empty. */
317
+ return null;
318
+ }
319
+ }
320
+ }