@bcmyguest/openmem 0.0.0-stage → 0.6.3

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.
Files changed (191) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +136 -2
  3. package/bin/hindsight-project-index +530 -0
  4. package/cli/coding-agent/access-policy.mjs +38 -0
  5. package/cli/coding-agent/agents.mjs +34 -0
  6. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  7. package/cli/coding-agent/capabilities.mjs +23 -0
  8. package/cli/coding-agent/hook-harnesses.mjs +87 -0
  9. package/cli/coding-agent/hook-runner.mjs +260 -0
  10. package/cli/coding-agent/install-access.mjs +93 -0
  11. package/cli/coding-agent/install-shim.mjs +97 -0
  12. package/cli/coding-agent/opencode-config.mjs +91 -0
  13. package/cli/coding-agent/opencode-plugin.mjs +96 -0
  14. package/cli/coding-agent/runtime.mjs +39 -0
  15. package/cli/coding-agent/session-facade.mjs +62 -0
  16. package/cli/cognee-mcp.mjs +260 -0
  17. package/cli/feed-sync.mjs +11 -0
  18. package/cli/install-coding-agent.mjs +165 -0
  19. package/cli/install-memory-mcp.mjs +426 -0
  20. package/cli/is-main.mjs +18 -0
  21. package/cli/mcp/context.mjs +61 -0
  22. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  23. package/cli/mcp/export/cognee.mjs +119 -0
  24. package/cli/mcp/export/hindsight.mjs +48 -0
  25. package/cli/mcp/export/index.mjs +26 -0
  26. package/cli/mcp/export/mem0.mjs +31 -0
  27. package/cli/mcp/llm-synthesize.mjs +50 -0
  28. package/cli/mcp/mem0-scope-id.mjs +22 -0
  29. package/cli/mcp/memory-facade.mjs +455 -0
  30. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  31. package/cli/mcp/notebooklm-banks.mjs +21 -0
  32. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  33. package/cli/mcp/notebooklm-provider.mjs +225 -0
  34. package/cli/mcp/pmm-records.mjs +23 -0
  35. package/cli/mcp/portability.mjs +158 -0
  36. package/cli/mcp/stdio.mjs +65 -0
  37. package/cli/mcp/unified-mcp-install.mjs +65 -0
  38. package/cli/mem0-mcp.mjs +108 -0
  39. package/cli/notebooklm-mcp.mjs +236 -0
  40. package/cli/openmem-mcp.mjs +35 -0
  41. package/cli/openmem.mjs +205 -0
  42. package/cli/roots.mjs +85 -0
  43. package/cli/stack.mjs +436 -0
  44. package/cli/switch-memory-provider.mjs +358 -0
  45. package/cli/wiring-report.mjs +125 -0
  46. package/compose.bench.yaml +12 -0
  47. package/compose.yaml +164 -0
  48. package/config/coding-agent.json +13 -0
  49. package/config/hindsight.env.example +24 -0
  50. package/config/index.yml +54 -0
  51. package/config/litellm.lemond.yaml +40 -0
  52. package/config/litellm.ollama.yaml +33 -0
  53. package/config/notebooklm.yml +33 -0
  54. package/config/openmem.toml.example +62 -0
  55. package/docs/OPENMEM.md +23 -0
  56. package/docs/architecture.md +219 -0
  57. package/docs/assets/README.md +10 -0
  58. package/docs/assets/setup-demo.gif +0 -0
  59. package/docs/coding-agent-hooks.md +51 -0
  60. package/docs/coding-agent-scopes.md +22 -0
  61. package/docs/coding-agents.md +51 -0
  62. package/docs/cognee.md +37 -0
  63. package/docs/concepts.md +112 -0
  64. package/docs/hindsight.md +74 -0
  65. package/docs/index-sources-roadmap.md +38 -0
  66. package/docs/index-sources.md +39 -0
  67. package/docs/indexer-sinks.md +122 -0
  68. package/docs/indexing.md +57 -0
  69. package/docs/manual-stack-setup.md +74 -0
  70. package/docs/mcp-install-guide.md +4 -0
  71. package/docs/mcp-setup.md +101 -0
  72. package/docs/mem0.md +103 -0
  73. package/docs/memory-portability.md +73 -0
  74. package/docs/memory-providers.md +246 -0
  75. package/docs/model-configuration.md +84 -0
  76. package/docs/notebooklm.md +83 -0
  77. package/docs/npx-install.md +170 -0
  78. package/docs/openmem-config.md +194 -0
  79. package/docs/provider-wizard-hooks.md +222 -0
  80. package/docs/quickstart.md +101 -0
  81. package/docs/setup-wizard.md +116 -0
  82. package/docs/sources-feeds.md +52 -0
  83. package/docs/sources-readwise.md +23 -0
  84. package/docs/sources-zotero.md +22 -0
  85. package/glama.json +4 -0
  86. package/indexer/Dockerfile +38 -0
  87. package/indexer/code_outline.py +146 -0
  88. package/indexer/feed_sync.py +107 -0
  89. package/indexer/index_config.py +290 -0
  90. package/indexer/pytest.ini +5 -0
  91. package/indexer/sinks/__init__.py +148 -0
  92. package/indexer/sinks/_http.py +44 -0
  93. package/indexer/sinks/cognee.py +329 -0
  94. package/indexer/sinks/hindsight.py +107 -0
  95. package/indexer/sinks/mem0.py +247 -0
  96. package/indexer/watch.py +240 -0
  97. package/package.json +95 -4
  98. package/providers/_template/compose.yaml +52 -0
  99. package/providers/cognee/OPERATOR.md +82 -0
  100. package/providers/cognee/compose.yaml +89 -0
  101. package/providers/cognee/lib/clack.mjs +157 -0
  102. package/providers/cognee/lib/coding-agent.mjs +26 -0
  103. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  104. package/providers/cognee/lib/config.mjs +98 -0
  105. package/providers/cognee/lib/env-file.mjs +194 -0
  106. package/providers/cognee/lib/env-setup.mjs +83 -0
  107. package/providers/cognee/lib/llm-env.mjs +382 -0
  108. package/providers/cognee/lib/ports.mjs +25 -0
  109. package/providers/cognee/lib/smoke.mjs +246 -0
  110. package/providers/cognee/lib/stack-env.mjs +134 -0
  111. package/providers/cognee/lib/state.mjs +101 -0
  112. package/providers/cognee/lib/summary.mjs +56 -0
  113. package/providers/cognee/lib/validate.mjs +97 -0
  114. package/providers/cognee/manifest.json +56 -0
  115. package/providers/cognee/wizard.mjs +134 -0
  116. package/providers/graphiti/manifest.json +15 -0
  117. package/providers/hindsight/lib/clack.mjs +245 -0
  118. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  119. package/providers/hindsight/lib/config.mjs +73 -0
  120. package/providers/hindsight/lib/env-setup.mjs +71 -0
  121. package/providers/hindsight/lib/litellm-routes.mjs +187 -0
  122. package/providers/hindsight/lib/model-backend.mjs +214 -0
  123. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  124. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  125. package/providers/hindsight/lib/ports.mjs +30 -0
  126. package/providers/hindsight/lib/probe.mjs +33 -0
  127. package/providers/hindsight/lib/stack-env.mjs +132 -0
  128. package/providers/hindsight/lib/state.mjs +75 -0
  129. package/providers/hindsight/lib/summary.mjs +43 -0
  130. package/providers/hindsight/lib/validate.mjs +75 -0
  131. package/providers/hindsight/manifest.json +58 -0
  132. package/providers/hindsight/wizard.mjs +149 -0
  133. package/providers/letta/manifest.json +29 -0
  134. package/providers/mem0/OPERATOR.md +124 -0
  135. package/providers/mem0/compose.yaml +57 -0
  136. package/providers/mem0/lib/coding-agent.mjs +58 -0
  137. package/providers/mem0/lib/config.mjs +45 -0
  138. package/providers/mem0/lib/configure.mjs +135 -0
  139. package/providers/mem0/lib/dotenv.mjs +71 -0
  140. package/providers/mem0/lib/env-file.mjs +110 -0
  141. package/providers/mem0/lib/http.mjs +69 -0
  142. package/providers/mem0/lib/mcp-install.mjs +165 -0
  143. package/providers/mem0/lib/secret.mjs +30 -0
  144. package/providers/mem0/lib/smoke.mjs +179 -0
  145. package/providers/mem0/lib/stack-env.mjs +216 -0
  146. package/providers/mem0/lib/state.mjs +67 -0
  147. package/providers/mem0/lib/summary.mjs +60 -0
  148. package/providers/mem0/lib/url.mjs +106 -0
  149. package/providers/mem0/lib/validate.mjs +158 -0
  150. package/providers/mem0/manifest.json +47 -0
  151. package/providers/mem0/wizard.mjs +133 -0
  152. package/providers/zep/manifest.json +29 -0
  153. package/scripts/check-pack.mjs +218 -0
  154. package/scripts/gen-masks.mjs +83 -0
  155. package/scripts/gen-masks.sh +4 -0
  156. package/scripts/promotion-provider-smoke.mjs +34 -0
  157. package/scripts/run-setup.sh +30 -0
  158. package/scripts/setup.mjs +94 -0
  159. package/scripts/setup.sh +5 -0
  160. package/scripts/spin-up.sh +45 -0
  161. package/scripts/wizard.sh +5 -0
  162. package/server.json +34 -0
  163. package/skills/openmem/SKILL.md +29 -0
  164. package/sync/Dockerfile +27 -0
  165. package/sync/memory_backend.py +87 -0
  166. package/sync/notebooklm_sync.py +664 -0
  167. package/wizard/bin/pm.mjs +97 -0
  168. package/wizard/bin/setup.mjs +33 -0
  169. package/wizard/lib/compose-version.mjs +32 -0
  170. package/wizard/lib/compose.mjs +70 -0
  171. package/wizard/lib/curl.mjs +103 -0
  172. package/wizard/lib/docker-build.mjs +148 -0
  173. package/wizard/lib/exec.mjs +41 -0
  174. package/wizard/lib/indexer.mjs +6 -0
  175. package/wizard/lib/load-machine-env.mjs +4 -0
  176. package/wizard/lib/log.mjs +32 -0
  177. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  178. package/wizard/lib/notebooklm.mjs +214 -0
  179. package/wizard/lib/parse-args.mjs +87 -0
  180. package/wizard/lib/paths.mjs +39 -0
  181. package/wizard/lib/pm-config.mjs +163 -0
  182. package/wizard/lib/ports.mjs +94 -0
  183. package/wizard/lib/prompts.mjs +57 -0
  184. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  185. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  186. package/wizard/lib/providers/registry.mjs +262 -0
  187. package/wizard/lib/run-clack.mjs +108 -0
  188. package/wizard/lib/run-wizard.mjs +755 -0
  189. package/wizard/lib/validate-setup.mjs +62 -0
  190. package/wizard/lib/wizard-action.mjs +34 -0
  191. package/wizard/lib/wizard-conf.mjs +71 -0
package/cli/stack.mjs ADDED
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Stack root: the stable, writable directory the wizard and docker compose run from.
3
+ *
4
+ * Under npx the package directory is a read-only, garbage-collected cache, but the wizard
5
+ * writes .env, .build-hash and compose.override.yaml and uses compose files and the docker
6
+ * build context relative to its repo root. The mutable assets (compose files, indexer/,
7
+ * bin/, sync/, config templates, ...) are therefore copied from the package into the stack
8
+ * root. A manifest of the shipped-file hashes is kept in the stack root so an upgrade
9
+ * overwrites only shipped files the user did not modify, and never touches anything else.
10
+ *
11
+ * Everything here uses node builtins only.
12
+ */
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ export const MANIFEST_NAME = ".pm-stack-manifest.json";
19
+ export const MANIFEST_SCHEMA = 1;
20
+
21
+ /** Path segments that are never shipped into the stack. */
22
+ const SKIP_SEGMENTS = new Set([
23
+ "__pycache__",
24
+ "tests",
25
+ "node_modules",
26
+ ".pytest_cache",
27
+ ".codegraph",
28
+ ".git",
29
+ ]);
30
+
31
+ /**
32
+ * Which package files are materialized into the stack root. Paths are posix-style and
33
+ * relative to the package root. Keep package.json "files" a superset of this list
34
+ * (test_cli_package.mjs and scripts/check-pack.mjs enforce it).
35
+ *
36
+ * @typedef {{ files: string[] } | { tree: string, include?: RegExp }} AssetRule
37
+ * @type {AssetRule[]}
38
+ */
39
+ export const STACK_ASSETS = [
40
+ { files: ["compose.yaml", "compose.bench.yaml"] },
41
+ // Docker build contexts: the Dockerfiles COPY from indexer/, bin/, sync/ and config/.
42
+ { tree: "indexer" },
43
+ { tree: "bin" },
44
+ { tree: "sync" },
45
+ {
46
+ tree: "config",
47
+ include:
48
+ /^config\/(?:[^/]+\.example|index\.yml|notebooklm\.yml|litellm\.[^/]+\.yaml|coding-agent\.json)$/,
49
+ },
50
+ // Per-provider compose fragments (hooks and manifests stay in the read-only package).
51
+ { tree: "providers", include: /^providers\/[^/]+\/compose[^/]*\.ya?ml$/ },
52
+ // Deliberately absent: scripts/ and wizard/. They are code, so they run from the
53
+ // read-only package (PM_PACKAGE_ROOT) and write into the stack (PM_STACK_ROOT).
54
+ ];
55
+
56
+ /**
57
+ * @param {string} p
58
+ * @returns {string}
59
+ */
60
+ function toPosix(p) {
61
+ return p.split(path.sep).join("/");
62
+ }
63
+
64
+ /**
65
+ * @param {string} root
66
+ * @param {string} rel posix relative dir
67
+ * @param {string[]} out
68
+ */
69
+ function walk(root, rel, out) {
70
+ const abs = path.join(root, rel);
71
+ let entries;
72
+ try {
73
+ entries = fs.readdirSync(abs, { withFileTypes: true });
74
+ } catch {
75
+ return;
76
+ }
77
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
78
+ if (SKIP_SEGMENTS.has(e.name)) continue;
79
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
80
+ if (e.isDirectory()) walk(root, childRel, out);
81
+ else if (e.isFile() && !e.name.endsWith(".pyc")) out.push(childRel);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Sorted posix paths (relative to pkgRoot) of every file the stack should contain.
87
+ * Symlinks are never shipped (npm drops them too).
88
+ *
89
+ * @param {string} pkgRoot
90
+ * @param {AssetRule[]} [rules]
91
+ * @returns {string[]}
92
+ */
93
+ export function listShippedFiles(pkgRoot, rules = STACK_ASSETS) {
94
+ const out = new Set();
95
+ for (const rule of rules) {
96
+ if ("files" in rule) {
97
+ for (const rel of rule.files) {
98
+ try {
99
+ if (fs.lstatSync(path.join(pkgRoot, rel)).isFile()) out.add(rel);
100
+ } catch {
101
+ // Optional asset absent from this checkout.
102
+ }
103
+ }
104
+ } else {
105
+ const found = [];
106
+ walk(pkgRoot, rule.tree, found);
107
+ for (const rel of found) {
108
+ if (!rule.include || rule.include.test(rel)) out.add(rel);
109
+ }
110
+ }
111
+ }
112
+ return [...out].sort();
113
+ }
114
+
115
+ /**
116
+ * @param {Buffer|string} data
117
+ * @returns {string}
118
+ */
119
+ export function hashBytes(data) {
120
+ return crypto.createHash("sha256").update(data).digest("hex");
121
+ }
122
+
123
+ /**
124
+ * Is the package directory a git checkout (or forced to behave like one)?
125
+ *
126
+ * @param {string} pkgRoot
127
+ * @param {Record<string, string|undefined>} [env]
128
+ */
129
+ export function isCheckout(pkgRoot, env = process.env) {
130
+ return env.PM_USE_CHECKOUT === "1" || fs.existsSync(path.join(pkgRoot, ".git"));
131
+ }
132
+
133
+ /**
134
+ * Pick the stack root.
135
+ *
136
+ * Order: PM_USE_CHECKOUT=1 (the package root itself), PM_STACK_ROOT, a git checkout (the
137
+ * package root itself), then ${XDG_DATA_HOME:-~/.local/share}/personal-memory/stack.
138
+ * `copy` is false when the stack root is the package root: nothing is materialized.
139
+ *
140
+ * @param {{ pkgRoot: string, env?: Record<string, string|undefined>, home?: string }} opts
141
+ * @returns {{ root: string, mode: "checkout"|"stack", source: string, copy: boolean }}
142
+ */
143
+ export function resolveStackRoot({ pkgRoot, env = process.env, home }) {
144
+ const homeDir = home || env.HOME || os.homedir();
145
+ const pkg = path.resolve(pkgRoot);
146
+ if (env.PM_USE_CHECKOUT === "1") {
147
+ return { root: pkg, mode: "checkout", source: "PM_USE_CHECKOUT", copy: false };
148
+ }
149
+ if (env.PM_STACK_ROOT) {
150
+ let p = env.PM_STACK_ROOT;
151
+ if (p === "~") p = homeDir;
152
+ else if (p.startsWith("~/")) p = path.join(homeDir, p.slice(2));
153
+ const root = path.resolve(p);
154
+ const same = root === pkg;
155
+ return {
156
+ root,
157
+ mode: same ? "checkout" : "stack",
158
+ source: "PM_STACK_ROOT",
159
+ copy: !same,
160
+ };
161
+ }
162
+ if (fs.existsSync(path.join(pkg, ".git"))) {
163
+ return { root: pkg, mode: "checkout", source: "git checkout", copy: false };
164
+ }
165
+ const xdg = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME);
166
+ const dataHome = xdg ? /** @type {string} */ (env.XDG_DATA_HOME) : path.join(homeDir, ".local/share");
167
+ return {
168
+ root: path.join(dataHome, "personal-memory", "stack"),
169
+ mode: "stack",
170
+ source: xdg ? "XDG_DATA_HOME" : "default",
171
+ copy: true,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * @typedef {"create"|"restore"|"unchanged"|"update"|"conflict"|"conflict-untracked"} SyncAction
177
+ */
178
+
179
+ /**
180
+ * Pure decision for one shipped file.
181
+ *
182
+ * @param {{
183
+ * shippedHash: string,
184
+ * disk: null | "symlink" | "not-file" | string, // null = missing, else sha256
185
+ * recordedHash?: string, // hash written by a previous sync
186
+ * }} s
187
+ * @returns {SyncAction}
188
+ */
189
+ export function planEntry({ shippedHash, disk, recordedHash }) {
190
+ if (disk === null) return recordedHash ? "restore" : "create";
191
+ if (disk === "symlink" || disk === "not-file") return "conflict-untracked";
192
+ if (disk === shippedHash) return "unchanged";
193
+ if (recordedHash === undefined) return "conflict-untracked";
194
+ if (disk === recordedHash) return "update";
195
+ return "conflict";
196
+ }
197
+
198
+ /**
199
+ * @param {string} dest
200
+ * @param {Buffer} data
201
+ * @param {number} mode
202
+ */
203
+ export function writeAtomic(dest, data, mode) {
204
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
205
+ const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
206
+ try {
207
+ fs.writeFileSync(tmp, data, { mode });
208
+ fs.chmodSync(tmp, mode);
209
+ fs.renameSync(tmp, dest);
210
+ } catch (e) {
211
+ try {
212
+ fs.unlinkSync(tmp);
213
+ } catch {
214
+ // Nothing to clean up.
215
+ }
216
+ throw e;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * @param {string} stackRoot
222
+ * @returns {{ schema: number, name?: string, version?: string, files: Record<string, string> }}
223
+ */
224
+ export function readManifest(stackRoot) {
225
+ const file = path.join(stackRoot, MANIFEST_NAME);
226
+ let raw;
227
+ try {
228
+ raw = fs.readFileSync(file, "utf8");
229
+ } catch {
230
+ return { schema: MANIFEST_SCHEMA, files: {} };
231
+ }
232
+ let m;
233
+ try {
234
+ m = JSON.parse(raw);
235
+ } catch {
236
+ throw new Error(
237
+ `${file} is not valid JSON; fix or delete it (shipped files will then be treated as untracked)`,
238
+ );
239
+ }
240
+ if (!m || typeof m !== "object" || typeof m.files !== "object" || m.files === null) {
241
+ throw new Error(`${file} has an unexpected shape; fix or delete it`);
242
+ }
243
+ if (typeof m.schema === "number" && m.schema > MANIFEST_SCHEMA) {
244
+ throw new Error(
245
+ `${file} was written by a newer openmem (schema ${m.schema}); upgrade the CLI`,
246
+ );
247
+ }
248
+ return m;
249
+ }
250
+
251
+ /**
252
+ * @param {string} abs
253
+ * @returns {null | "symlink" | "not-file" | { hash: string, data: Buffer }}
254
+ */
255
+ function inspectDisk(abs) {
256
+ let st;
257
+ try {
258
+ st = fs.lstatSync(abs);
259
+ } catch (e) {
260
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === "ENOENT") return null;
261
+ throw e;
262
+ }
263
+ if (st.isSymbolicLink()) return "symlink";
264
+ if (!st.isFile()) return "not-file";
265
+ const data = fs.readFileSync(abs);
266
+ return { hash: hashBytes(data), data };
267
+ }
268
+
269
+ /**
270
+ * @typedef {object} SyncReport
271
+ * @property {string} stackRoot
272
+ * @property {string} version
273
+ * @property {string[]} created
274
+ * @property {string[]} restored
275
+ * @property {string[]} updated
276
+ * @property {number} unchanged
277
+ * @property {{ path: string, reason: string }[]} conflicts kept as-is; shipped version differs
278
+ * @property {string[]} forced overwritten under force (user copy saved next to it)
279
+ * @property {string[]} orphans recorded earlier, no longer shipped; left in place
280
+ * @property {boolean} dryRun
281
+ */
282
+
283
+ /**
284
+ * Copy the shipped files from pkgRoot into stackRoot without clobbering user changes.
285
+ * Never deletes anything and never touches files that are not in the shipped set.
286
+ *
287
+ * @param {{
288
+ * pkgRoot: string,
289
+ * stackRoot: string,
290
+ * name?: string,
291
+ * version?: string,
292
+ * force?: boolean,
293
+ * dryRun?: boolean,
294
+ * rules?: AssetRule[],
295
+ * }} opts
296
+ * @returns {SyncReport}
297
+ */
298
+ export function syncStack({
299
+ pkgRoot,
300
+ stackRoot,
301
+ name = "openmem",
302
+ version = "0.0.0",
303
+ force = false,
304
+ dryRun = false,
305
+ rules = STACK_ASSETS,
306
+ }) {
307
+ const pkg = path.resolve(pkgRoot);
308
+ const stack = path.resolve(stackRoot);
309
+ if (pkg === stack) throw new Error("stack root equals the package root; nothing to materialize");
310
+
311
+ if (!dryRun) fs.mkdirSync(stack, { recursive: true });
312
+ const manifest = readManifest(stack);
313
+ const recorded = manifest.files;
314
+
315
+ /** @type {SyncReport} */
316
+ const report = {
317
+ stackRoot: stack,
318
+ version,
319
+ created: [],
320
+ restored: [],
321
+ updated: [],
322
+ unchanged: 0,
323
+ conflicts: [],
324
+ forced: [],
325
+ orphans: [],
326
+ dryRun,
327
+ };
328
+
329
+ const shipped = listShippedFiles(pkg, rules);
330
+ /** @type {Record<string, string>} */
331
+ const nextFiles = {};
332
+ let manifestDirty = manifest.version !== version || manifest.name !== name;
333
+
334
+ for (const rel of shipped) {
335
+ const src = path.join(pkg, rel);
336
+ const data = fs.readFileSync(src);
337
+ const shippedHash = hashBytes(data);
338
+ const mode = fs.statSync(src).mode & 0o111 ? 0o755 : 0o644;
339
+ const dest = path.join(stack, ...rel.split("/"));
340
+ const diskInfo = inspectDisk(dest);
341
+ const disk = diskInfo === null || typeof diskInfo === "string" ? diskInfo : diskInfo.hash;
342
+ const action = planEntry({ shippedHash, disk, recordedHash: recorded[rel] });
343
+
344
+ switch (action) {
345
+ case "create":
346
+ case "restore":
347
+ case "update":
348
+ if (!dryRun) writeAtomic(dest, data, mode);
349
+ (action === "create" ? report.created : action === "restore" ? report.restored : report.updated).push(rel);
350
+ nextFiles[rel] = shippedHash;
351
+ break;
352
+ case "unchanged":
353
+ report.unchanged++;
354
+ // Adopt a byte-identical pre-existing file so later upgrades can update it.
355
+ nextFiles[rel] = shippedHash;
356
+ break;
357
+ case "conflict":
358
+ case "conflict-untracked": {
359
+ const reason =
360
+ disk === "symlink"
361
+ ? "destination is a symlink"
362
+ : disk === "not-file"
363
+ ? "destination is not a regular file"
364
+ : action === "conflict"
365
+ ? "modified locally; the new shipped version was not applied"
366
+ : "exists but is not tracked as shipped; left untouched";
367
+ if (force && typeof diskInfo === "object" && diskInfo !== null) {
368
+ if (!dryRun) {
369
+ const backup = `${dest}.user-${Date.now()}.bak`;
370
+ writeAtomic(backup, diskInfo.data, 0o600);
371
+ writeAtomic(dest, data, mode);
372
+ }
373
+ report.forced.push(rel);
374
+ nextFiles[rel] = shippedHash;
375
+ } else {
376
+ report.conflicts.push({ path: rel, reason });
377
+ // Keep the old record so the file stays "modified" next time instead of "untracked".
378
+ if (recorded[rel] !== undefined) nextFiles[rel] = recorded[rel];
379
+ }
380
+ break;
381
+ }
382
+ default:
383
+ throw new Error(`unreachable sync action: ${action}`);
384
+ }
385
+ if (nextFiles[rel] !== recorded[rel]) manifestDirty = true;
386
+ }
387
+
388
+ for (const rel of Object.keys(recorded)) {
389
+ if (!shipped.includes(rel)) {
390
+ report.orphans.push(rel);
391
+ manifestDirty = true;
392
+ }
393
+ }
394
+
395
+ if (!dryRun && manifestDirty) {
396
+ const body = `${JSON.stringify(
397
+ { schema: MANIFEST_SCHEMA, name, version, files: nextFiles },
398
+ null,
399
+ 2,
400
+ )}\n`;
401
+ writeAtomic(path.join(stack, MANIFEST_NAME), Buffer.from(body), 0o644);
402
+ }
403
+ return report;
404
+ }
405
+
406
+ /**
407
+ * Number of files a sync changed on disk.
408
+ *
409
+ * @param {SyncReport} r
410
+ */
411
+ export function changedCount(r) {
412
+ return r.created.length + r.restored.length + r.updated.length + r.forced.length;
413
+ }
414
+
415
+ /**
416
+ * Human-readable summary lines.
417
+ *
418
+ * @param {SyncReport} r
419
+ * @returns {string[]}
420
+ */
421
+ export function formatReport(r) {
422
+ const lines = [];
423
+ const verb = r.dryRun ? "would be" : "were";
424
+ lines.push(`stack root: ${r.stackRoot} (openmem ${r.version})`);
425
+ if (r.created.length) lines.push(` ${r.created.length} file(s) ${verb} created`);
426
+ if (r.updated.length) lines.push(` ${r.updated.length} file(s) ${verb} updated: ${r.updated.join(", ")}`);
427
+ if (r.restored.length) lines.push(` ${r.restored.length} missing file(s) ${verb} restored: ${r.restored.join(", ")}`);
428
+ if (r.forced.length) lines.push(` ${r.forced.length} locally modified file(s) ${verb} overwritten (backup next to each): ${r.forced.join(", ")}`);
429
+ if (!changedCount(r)) lines.push(` ${r.unchanged} shipped file(s) up to date`);
430
+ for (const c of r.conflicts) lines.push(` CONFLICT ${c.path}: ${c.reason}`);
431
+ if (r.conflicts.length) {
432
+ lines.push(" Resolve by merging by hand, or run: openmem init --force (keeps a .bak copy)");
433
+ }
434
+ for (const o of r.orphans) lines.push(` note: ${o} is no longer shipped; left in place`);
435
+ return lines;
436
+ }