@bcmyguest/openmem 0.0.0-stage → 0.8.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.
Files changed (230) hide show
  1. package/.agents/skills/openmem/SKILL.md +34 -0
  2. package/LICENSE +190 -0
  3. package/README.md +140 -2
  4. package/bin/hindsight-project-index +530 -0
  5. package/cli/coding-agent/access-policy.mjs +38 -0
  6. package/cli/coding-agent/agents.mjs +34 -0
  7. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  8. package/cli/coding-agent/capabilities.mjs +34 -0
  9. package/cli/coding-agent/context-store.mjs +81 -0
  10. package/cli/coding-agent/context-trim.mjs +897 -0
  11. package/cli/coding-agent/file-io.mjs +72 -0
  12. package/cli/coding-agent/hook-harnesses.mjs +88 -0
  13. package/cli/coding-agent/hook-runner.mjs +284 -0
  14. package/cli/coding-agent/install-access.mjs +119 -0
  15. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  16. package/cli/coding-agent/install-rtk.mjs +60 -0
  17. package/cli/coding-agent/install-shim.mjs +201 -0
  18. package/cli/coding-agent/opencode-config.mjs +107 -0
  19. package/cli/coding-agent/opencode-plugin.mjs +132 -0
  20. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  21. package/cli/coding-agent/recall-frame.mjs +29 -0
  22. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  23. package/cli/coding-agent/runtime.mjs +40 -0
  24. package/cli/coding-agent/session-db.mjs +626 -0
  25. package/cli/coding-agent/session-facade.mjs +86 -0
  26. package/cli/coding-agent/toml-edit.mjs +161 -0
  27. package/cli/cognee-mcp.mjs +260 -0
  28. package/cli/context-management.mjs +254 -0
  29. package/cli/feed-sync.mjs +27 -0
  30. package/cli/install-coding-agent.mjs +298 -0
  31. package/cli/install-memory-mcp.mjs +426 -0
  32. package/cli/is-main.mjs +18 -0
  33. package/cli/mcp/context-sql-runner.mjs +58 -0
  34. package/cli/mcp/context-store.mjs +670 -0
  35. package/cli/mcp/context.mjs +61 -0
  36. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  37. package/cli/mcp/export/cognee.mjs +119 -0
  38. package/cli/mcp/export/hindsight.mjs +48 -0
  39. package/cli/mcp/export/index.mjs +26 -0
  40. package/cli/mcp/export/mem0.mjs +31 -0
  41. package/cli/mcp/knowledge.mjs +333 -0
  42. package/cli/mcp/llm-synthesize.mjs +50 -0
  43. package/cli/mcp/mem0-scope-id.mjs +22 -0
  44. package/cli/mcp/memory-facade.mjs +521 -0
  45. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  46. package/cli/mcp/notebooklm-banks.mjs +21 -0
  47. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  48. package/cli/mcp/notebooklm-provider.mjs +225 -0
  49. package/cli/mcp/pmm-records.mjs +23 -0
  50. package/cli/mcp/portability.mjs +158 -0
  51. package/cli/mcp/stdio.mjs +65 -0
  52. package/cli/mcp/unified-mcp-install.mjs +65 -0
  53. package/cli/mem0-mcp.mjs +108 -0
  54. package/cli/notebooklm-mcp.mjs +236 -0
  55. package/cli/openmem-mcp.mjs +38 -0
  56. package/cli/openmem.mjs +222 -0
  57. package/cli/roots.mjs +85 -0
  58. package/cli/schedule.mjs +176 -0
  59. package/cli/stack.mjs +438 -0
  60. package/cli/switch-memory-provider.mjs +358 -0
  61. package/cli/wiring-report.mjs +125 -0
  62. package/compose.bench.yaml +12 -0
  63. package/compose.yaml +227 -0
  64. package/config/coding-agent.json +13 -0
  65. package/config/hindsight.env.example +26 -0
  66. package/config/index.yml +54 -0
  67. package/config/litellm.lemond.yaml +40 -0
  68. package/config/litellm.ollama.yaml +33 -0
  69. package/config/notebooklm.yml +33 -0
  70. package/config/openmem.toml.example +124 -0
  71. package/docs/OPENMEM.md +23 -0
  72. package/docs/architecture.md +245 -0
  73. package/docs/assets/README.md +10 -0
  74. package/docs/assets/setup-demo.gif +0 -0
  75. package/docs/coding-agent-hooks.md +75 -0
  76. package/docs/coding-agent-scopes.md +22 -0
  77. package/docs/coding-agents.md +71 -0
  78. package/docs/cognee.md +37 -0
  79. package/docs/compatibility.md +80 -0
  80. package/docs/concepts.md +135 -0
  81. package/docs/context-management.md +255 -0
  82. package/docs/context-proxy.md +115 -0
  83. package/docs/context-research.md +215 -0
  84. package/docs/context-store.md +277 -0
  85. package/docs/hindsight.md +77 -0
  86. package/docs/index-sources-roadmap.md +38 -0
  87. package/docs/index-sources.md +39 -0
  88. package/docs/indexer-sinks.md +122 -0
  89. package/docs/indexing.md +57 -0
  90. package/docs/manual-stack-setup.md +74 -0
  91. package/docs/mcp-install-guide.md +4 -0
  92. package/docs/mcp-setup.md +104 -0
  93. package/docs/mem0.md +141 -0
  94. package/docs/memory-portability.md +73 -0
  95. package/docs/memory-providers.md +246 -0
  96. package/docs/meridian-litellm.md +198 -0
  97. package/docs/model-configuration.md +106 -0
  98. package/docs/notebooklm.md +83 -0
  99. package/docs/npx-install.md +172 -0
  100. package/docs/openmem-config.md +194 -0
  101. package/docs/provider-wizard-hooks.md +222 -0
  102. package/docs/quickstart.md +107 -0
  103. package/docs/setup-wizard.md +117 -0
  104. package/docs/sources-feeds.md +39 -0
  105. package/docs/sources-readwise.md +23 -0
  106. package/docs/sources-zotero.md +22 -0
  107. package/glama.json +4 -0
  108. package/indexer/Dockerfile +38 -0
  109. package/indexer/code_outline.py +146 -0
  110. package/indexer/feed_sync.py +107 -0
  111. package/indexer/index_config.py +290 -0
  112. package/indexer/pytest.ini +5 -0
  113. package/indexer/sinks/__init__.py +148 -0
  114. package/indexer/sinks/_http.py +44 -0
  115. package/indexer/sinks/cognee.py +329 -0
  116. package/indexer/sinks/hindsight.py +107 -0
  117. package/indexer/sinks/mem0.py +247 -0
  118. package/indexer/watch.py +240 -0
  119. package/package.json +96 -4
  120. package/providers/_template/compose.yaml +52 -0
  121. package/providers/cognee/OPERATOR.md +82 -0
  122. package/providers/cognee/compose.yaml +89 -0
  123. package/providers/cognee/lib/clack.mjs +157 -0
  124. package/providers/cognee/lib/coding-agent.mjs +26 -0
  125. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  126. package/providers/cognee/lib/config.mjs +98 -0
  127. package/providers/cognee/lib/env-file.mjs +194 -0
  128. package/providers/cognee/lib/env-setup.mjs +83 -0
  129. package/providers/cognee/lib/llm-env.mjs +382 -0
  130. package/providers/cognee/lib/ports.mjs +25 -0
  131. package/providers/cognee/lib/smoke.mjs +246 -0
  132. package/providers/cognee/lib/stack-env.mjs +134 -0
  133. package/providers/cognee/lib/state.mjs +101 -0
  134. package/providers/cognee/lib/summary.mjs +56 -0
  135. package/providers/cognee/lib/validate.mjs +97 -0
  136. package/providers/cognee/manifest.json +56 -0
  137. package/providers/cognee/wizard.mjs +134 -0
  138. package/providers/graphiti/manifest.json +15 -0
  139. package/providers/hindsight/lib/clack.mjs +347 -0
  140. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  141. package/providers/hindsight/lib/config.mjs +79 -0
  142. package/providers/hindsight/lib/env-setup.mjs +72 -0
  143. package/providers/hindsight/lib/litellm-routes.mjs +196 -0
  144. package/providers/hindsight/lib/meridian.mjs +307 -0
  145. package/providers/hindsight/lib/model-backend.mjs +243 -0
  146. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  147. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  148. package/providers/hindsight/lib/ports.mjs +30 -0
  149. package/providers/hindsight/lib/probe.mjs +54 -0
  150. package/providers/hindsight/lib/stack-env.mjs +125 -0
  151. package/providers/hindsight/lib/state.mjs +80 -0
  152. package/providers/hindsight/lib/summary.mjs +57 -0
  153. package/providers/hindsight/lib/validate.mjs +81 -0
  154. package/providers/hindsight/manifest.json +58 -0
  155. package/providers/hindsight/wizard.mjs +150 -0
  156. package/providers/letta/manifest.json +29 -0
  157. package/providers/mem0/OPERATOR.md +143 -0
  158. package/providers/mem0/compose.yaml +79 -0
  159. package/providers/mem0/lib/coding-agent.mjs +58 -0
  160. package/providers/mem0/lib/config.mjs +45 -0
  161. package/providers/mem0/lib/configure.mjs +201 -0
  162. package/providers/mem0/lib/dotenv.mjs +71 -0
  163. package/providers/mem0/lib/env-file.mjs +110 -0
  164. package/providers/mem0/lib/http.mjs +69 -0
  165. package/providers/mem0/lib/litellm.mjs +457 -0
  166. package/providers/mem0/lib/mcp-install.mjs +165 -0
  167. package/providers/mem0/lib/secret.mjs +30 -0
  168. package/providers/mem0/lib/smoke.mjs +179 -0
  169. package/providers/mem0/lib/stack-env.mjs +255 -0
  170. package/providers/mem0/lib/state.mjs +67 -0
  171. package/providers/mem0/lib/summary.mjs +88 -0
  172. package/providers/mem0/lib/url.mjs +106 -0
  173. package/providers/mem0/lib/validate.mjs +158 -0
  174. package/providers/mem0/manifest.json +47 -0
  175. package/providers/mem0/wizard.mjs +146 -0
  176. package/providers/zep/manifest.json +29 -0
  177. package/proxy/config.yaml +33 -0
  178. package/proxy/openmem_context/__init__.py +6 -0
  179. package/proxy/openmem_context/adapters.py +504 -0
  180. package/proxy/openmem_context/compress.py +53 -0
  181. package/proxy/openmem_context/core.py +606 -0
  182. package/proxy/openmem_context/embed.py +212 -0
  183. package/proxy/openmem_context/recall.py +49 -0
  184. package/proxy/openmem_context/session.py +70 -0
  185. package/proxy/openmem_context/sessiondb.py +276 -0
  186. package/proxy/openmem_context/settings.py +134 -0
  187. package/proxy/openmem_context/store.py +129 -0
  188. package/proxy/openmem_context/tokens.py +123 -0
  189. package/proxy/openmem_context/ttl.py +78 -0
  190. package/proxy/openmem_context_hook.py +34 -0
  191. package/scripts/check-pack.mjs +219 -0
  192. package/scripts/gen-masks.mjs +83 -0
  193. package/scripts/gen-masks.sh +4 -0
  194. package/scripts/promotion-provider-smoke.mjs +34 -0
  195. package/scripts/run-setup.sh +30 -0
  196. package/scripts/setup.mjs +107 -0
  197. package/scripts/setup.sh +5 -0
  198. package/scripts/spin-up.sh +45 -0
  199. package/scripts/wizard.sh +5 -0
  200. package/server.json +34 -0
  201. package/sync/Dockerfile +27 -0
  202. package/sync/memory_backend.py +87 -0
  203. package/sync/notebooklm_sync.py +664 -0
  204. package/wizard/bin/pm.mjs +97 -0
  205. package/wizard/bin/setup.mjs +33 -0
  206. package/wizard/lib/compose-version.mjs +32 -0
  207. package/wizard/lib/compose.mjs +70 -0
  208. package/wizard/lib/context-step.mjs +140 -0
  209. package/wizard/lib/curl.mjs +103 -0
  210. package/wizard/lib/docker-build.mjs +148 -0
  211. package/wizard/lib/exec.mjs +41 -0
  212. package/wizard/lib/indexer.mjs +6 -0
  213. package/wizard/lib/litellm-env.mjs +55 -0
  214. package/wizard/lib/load-machine-env.mjs +4 -0
  215. package/wizard/lib/log.mjs +32 -0
  216. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  217. package/wizard/lib/notebooklm.mjs +214 -0
  218. package/wizard/lib/parse-args.mjs +87 -0
  219. package/wizard/lib/paths.mjs +39 -0
  220. package/wizard/lib/pm-config.mjs +371 -0
  221. package/wizard/lib/ports.mjs +94 -0
  222. package/wizard/lib/prompts.mjs +85 -0
  223. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  224. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  225. package/wizard/lib/providers/registry.mjs +262 -0
  226. package/wizard/lib/run-clack.mjs +108 -0
  227. package/wizard/lib/run-wizard.mjs +750 -0
  228. package/wizard/lib/validate-setup.mjs +62 -0
  229. package/wizard/lib/wizard-action.mjs +34 -0
  230. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /** Install a per-user, hourly feed-sync job using the host scheduler. */
3
+ import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { parseArgs } from "node:util";
8
+ import { fileURLToPath } from "node:url";
9
+ import { pythonCandidates } from "./feed-sync.mjs";
10
+ import { isMainModule } from "./is-main.mjs";
11
+
12
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const PACKAGE_NAME = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8")).name;
14
+ const JOB_NAME = "openmem-feed-sync";
15
+ const LAUNCHD_LABEL = "io.github.bcmyguest.openmem.feed-sync";
16
+ const WINDOWS_TASK = "OpenMemFeedSync";
17
+
18
+ function escapeXml(value) {
19
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
20
+ }
21
+
22
+ function systemdArg(value) {
23
+ const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("%", "%%");
24
+ return `"${escaped}"`;
25
+ }
26
+
27
+ export function schedulePaths(platform, home, env = process.env) {
28
+ if (platform === "linux") {
29
+ const config = path.isAbsolute(env.XDG_CONFIG_HOME || "") ? env.XDG_CONFIG_HOME : path.join(home, ".config");
30
+ return {
31
+ runner: path.join(config, "openmem", "feed-sync-runner.mjs"),
32
+ service: path.join(config, "systemd", "user", `${JOB_NAME}.service`),
33
+ timer: path.join(config, "systemd", "user", `${JOB_NAME}.timer`),
34
+ };
35
+ }
36
+ if (platform === "darwin") {
37
+ return {
38
+ runner: path.join(home, "Library", "Application Support", "OpenMem", "feed-sync-runner.mjs"),
39
+ plist: path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`),
40
+ };
41
+ }
42
+ if (platform === "win32") {
43
+ const local = path.win32.isAbsolute(env.LOCALAPPDATA || "") ? env.LOCALAPPDATA : path.win32.join(home, "AppData", "Local");
44
+ return { runner: path.win32.join(local, "OpenMem", "feed-sync-runner.mjs") };
45
+ }
46
+ throw new Error(`native scheduling is unavailable on ${platform}; run ${PACKAGE_NAME} feed-sync with your system scheduler`);
47
+ }
48
+
49
+ export function renderRunner(nodePath, npmCliPath, pythonPath) {
50
+ return `// Generated by OpenMem schedule install. Reinstall after changing Node.js installations.\n` +
51
+ `import { spawnSync } from "node:child_process";\n` +
52
+ `import path from "node:path";\n` +
53
+ `const node = ${JSON.stringify(nodePath)};\n` +
54
+ `const npm = ${JSON.stringify(npmCliPath)};\n` +
55
+ `const python = ${JSON.stringify(pythonPath)};\n` +
56
+ `const env = { ...process.env };\n` +
57
+ `const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") || "PATH";\n` +
58
+ `env[pathKey] = [path.dirname(node), path.dirname(python), env[pathKey] || ""].join(path.delimiter);\n` +
59
+ `env.PYTHON = python;\n` +
60
+ `const result = spawnSync(node, [npm, "exec", "--yes", "--package=${PACKAGE_NAME}", "--", "openmem", "feed-sync"], { env, stdio: "inherit" });\n` +
61
+ `if (result.error) console.error(result.error.message);\n` +
62
+ `process.exit(result.status ?? 1);\n`;
63
+ }
64
+
65
+ export function renderSystemdUnits(nodePath, runnerPath) {
66
+ return {
67
+ service: `[Unit]\nDescription=Refresh OpenMem feed cache\n\n[Service]\nType=oneshot\nExecStart=${systemdArg(nodePath)} ${systemdArg(runnerPath)}\n`,
68
+ timer: `[Unit]\nDescription=Refresh OpenMem feed cache hourly\n\n[Timer]\nOnCalendar=hourly\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n`,
69
+ };
70
+ }
71
+
72
+ export function renderLaunchAgent(nodePath, runnerPath) {
73
+ return `<?xml version="1.0" encoding="UTF-8"?>\n` +
74
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n` +
75
+ `<plist version="1.0"><dict>\n` +
76
+ ` <key>Label</key><string>${LAUNCHD_LABEL}</string>\n` +
77
+ ` <key>ProgramArguments</key><array><string>${escapeXml(nodePath)}</string><string>${escapeXml(runnerPath)}</string></array>\n` +
78
+ ` <key>StartInterval</key><integer>3600</integer>\n` +
79
+ `</dict></plist>\n`;
80
+ }
81
+
82
+ export function windowsTaskArgs(nodePath, runnerPath) {
83
+ return ["/create", "/sc", "hourly", "/mo", "1", "/tn", WINDOWS_TASK, "/tr", `"${nodePath}" "${runnerPath}"`, "/f"];
84
+ }
85
+
86
+ function run(command, args, env, allowMissing = false) {
87
+ const result = spawnSync(command, args, { encoding: "utf8", env });
88
+ if (result.error) throw result.error;
89
+ if (result.status !== 0 && !allowMissing) throw new Error(`${command} ${args.join(" ")}: ${(result.stderr || result.stdout).trim()}`);
90
+ return result.status === 0;
91
+ }
92
+
93
+ function writeFile(file, content) {
94
+ fs.mkdirSync(path.dirname(file), { recursive: true });
95
+ fs.writeFileSync(file, content, { mode: 0o600 });
96
+ }
97
+
98
+ function resolvePython(platform, env) {
99
+ for (const [command, prefix] of pythonCandidates(platform, env)) {
100
+ const result = spawnSync(command, [...prefix, "-c", "import sys, yaml; print(sys.executable)"], { encoding: "utf8", env });
101
+ if (result.status === 0) return result.stdout.trim();
102
+ }
103
+ throw new Error("Python 3 with PyYAML is required for feed sync; install PyYAML or set PYTHON to a working interpreter");
104
+ }
105
+
106
+ function install(platform, home, env, dryRun) {
107
+ const files = schedulePaths(platform, home, env);
108
+ const npmCli = env.npm_execpath;
109
+ if (!npmCli || !fs.existsSync(npmCli)) throw new Error("run schedule install through npx so OpenMem can locate npm");
110
+ const nodePath = process.execPath;
111
+ if (dryRun) {
112
+ console.log(`Would write ${files.runner} and install an hourly ${JOB_NAME} job using ${nodePath} and ${npmCli}`);
113
+ return;
114
+ }
115
+ const runner = renderRunner(nodePath, npmCli, resolvePython(platform, env));
116
+ writeFile(files.runner, runner);
117
+ if (platform === "linux") {
118
+ const units = renderSystemdUnits(nodePath, files.runner);
119
+ writeFile(files.service, units.service);
120
+ writeFile(files.timer, units.timer);
121
+ run("systemctl", ["--user", "daemon-reload"], env);
122
+ run("systemctl", ["--user", "enable", "--now", `${JOB_NAME}.timer`], env);
123
+ } else if (platform === "darwin") {
124
+ const target = `gui/${process.getuid()}`;
125
+ if (fs.existsSync(files.plist)) run("launchctl", ["bootout", target, files.plist], env, true);
126
+ writeFile(files.plist, renderLaunchAgent(nodePath, files.runner));
127
+ run("launchctl", ["bootstrap", target, files.plist], env);
128
+ } else {
129
+ run("schtasks", windowsTaskArgs(nodePath, files.runner), env);
130
+ }
131
+ console.log(`Installed hourly ${JOB_NAME} job`);
132
+ }
133
+
134
+ function remove(platform, home, env, dryRun) {
135
+ const files = schedulePaths(platform, home, env);
136
+ if (dryRun) {
137
+ console.log(`Would remove ${JOB_NAME} job and ${files.runner}`);
138
+ return;
139
+ }
140
+ if (platform === "linux") {
141
+ run("systemctl", ["--user", "disable", "--now", `${JOB_NAME}.timer`], env, true);
142
+ fs.rmSync(files.service, { force: true });
143
+ fs.rmSync(files.timer, { force: true });
144
+ run("systemctl", ["--user", "daemon-reload"], env);
145
+ } else if (platform === "darwin") {
146
+ if (fs.existsSync(files.plist)) run("launchctl", ["bootout", `gui/${process.getuid()}`, files.plist], env, true);
147
+ fs.rmSync(files.plist, { force: true });
148
+ } else {
149
+ if (run("schtasks", ["/query", "/tn", WINDOWS_TASK], env, true)) {
150
+ run("schtasks", ["/delete", "/tn", WINDOWS_TASK, "/f"], env);
151
+ }
152
+ }
153
+ fs.rmSync(files.runner, { force: true });
154
+ console.log(`Removed ${JOB_NAME} job`);
155
+ }
156
+
157
+ export function main(argv = process.argv.slice(2), platform = process.platform, home = os.homedir(), env = process.env) {
158
+ const { values, positionals } = parseArgs({ args: argv, options: { "dry-run": { type: "boolean" }, help: { type: "boolean" } }, allowPositionals: true });
159
+ if (values.help || positionals.length === 0) {
160
+ console.log(`Usage: npx ${PACKAGE_NAME} schedule <install|remove> [--dry-run]\nInstalls an hourly per-user feed sync with the native OS scheduler.`);
161
+ return;
162
+ }
163
+ if (positionals.length !== 1) throw new Error("schedule accepts one action: install or remove");
164
+ if (positionals[0] === "install") install(platform, home, env, values["dry-run"]);
165
+ else if (positionals[0] === "remove") remove(platform, home, env, values["dry-run"]);
166
+ else throw new Error(`unknown schedule action: ${positionals[0]}`);
167
+ }
168
+
169
+ if (isMainModule(import.meta.url)) {
170
+ try {
171
+ main();
172
+ } catch (error) {
173
+ console.error(`openmem schedule: ${error.message}`);
174
+ process.exitCode = 1;
175
+ }
176
+ }
package/cli/stack.mjs ADDED
@@ -0,0 +1,438 @@
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
+ // context-proxy mounts ./proxy (LiteLLM config and hook).
51
+ { tree: "proxy" },
52
+ // Per-provider compose fragments (hooks and manifests stay in the read-only package).
53
+ { tree: "providers", include: /^providers\/[^/]+\/compose[^/]*\.ya?ml$/ },
54
+ // Deliberately absent: scripts/ and wizard/. They are code, so they run from the
55
+ // read-only package (PM_PACKAGE_ROOT) and write into the stack (PM_STACK_ROOT).
56
+ ];
57
+
58
+ /**
59
+ * @param {string} p
60
+ * @returns {string}
61
+ */
62
+ function toPosix(p) {
63
+ return p.split(path.sep).join("/");
64
+ }
65
+
66
+ /**
67
+ * @param {string} root
68
+ * @param {string} rel posix relative dir
69
+ * @param {string[]} out
70
+ */
71
+ function walk(root, rel, out) {
72
+ const abs = path.join(root, rel);
73
+ let entries;
74
+ try {
75
+ entries = fs.readdirSync(abs, { withFileTypes: true });
76
+ } catch {
77
+ return;
78
+ }
79
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
80
+ if (SKIP_SEGMENTS.has(e.name)) continue;
81
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
82
+ if (e.isDirectory()) walk(root, childRel, out);
83
+ else if (e.isFile() && !e.name.endsWith(".pyc")) out.push(childRel);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Sorted posix paths (relative to pkgRoot) of every file the stack should contain.
89
+ * Symlinks are never shipped (npm drops them too).
90
+ *
91
+ * @param {string} pkgRoot
92
+ * @param {AssetRule[]} [rules]
93
+ * @returns {string[]}
94
+ */
95
+ export function listShippedFiles(pkgRoot, rules = STACK_ASSETS) {
96
+ const out = new Set();
97
+ for (const rule of rules) {
98
+ if ("files" in rule) {
99
+ for (const rel of rule.files) {
100
+ try {
101
+ if (fs.lstatSync(path.join(pkgRoot, rel)).isFile()) out.add(rel);
102
+ } catch {
103
+ // Optional asset absent from this checkout.
104
+ }
105
+ }
106
+ } else {
107
+ const found = [];
108
+ walk(pkgRoot, rule.tree, found);
109
+ for (const rel of found) {
110
+ if (!rule.include || rule.include.test(rel)) out.add(rel);
111
+ }
112
+ }
113
+ }
114
+ return [...out].sort();
115
+ }
116
+
117
+ /**
118
+ * @param {Buffer|string} data
119
+ * @returns {string}
120
+ */
121
+ export function hashBytes(data) {
122
+ return crypto.createHash("sha256").update(data).digest("hex");
123
+ }
124
+
125
+ /**
126
+ * Is the package directory a git checkout (or forced to behave like one)?
127
+ *
128
+ * @param {string} pkgRoot
129
+ * @param {Record<string, string|undefined>} [env]
130
+ */
131
+ export function isCheckout(pkgRoot, env = process.env) {
132
+ return env.PM_USE_CHECKOUT === "1" || fs.existsSync(path.join(pkgRoot, ".git"));
133
+ }
134
+
135
+ /**
136
+ * Pick the stack root.
137
+ *
138
+ * Order: PM_USE_CHECKOUT=1 (the package root itself), PM_STACK_ROOT, a git checkout (the
139
+ * package root itself), then ${XDG_DATA_HOME:-~/.local/share}/personal-memory/stack.
140
+ * `copy` is false when the stack root is the package root: nothing is materialized.
141
+ *
142
+ * @param {{ pkgRoot: string, env?: Record<string, string|undefined>, home?: string }} opts
143
+ * @returns {{ root: string, mode: "checkout"|"stack", source: string, copy: boolean }}
144
+ */
145
+ export function resolveStackRoot({ pkgRoot, env = process.env, home }) {
146
+ const homeDir = home || env.HOME || os.homedir();
147
+ const pkg = path.resolve(pkgRoot);
148
+ if (env.PM_USE_CHECKOUT === "1") {
149
+ return { root: pkg, mode: "checkout", source: "PM_USE_CHECKOUT", copy: false };
150
+ }
151
+ if (env.PM_STACK_ROOT) {
152
+ let p = env.PM_STACK_ROOT;
153
+ if (p === "~") p = homeDir;
154
+ else if (p.startsWith("~/")) p = path.join(homeDir, p.slice(2));
155
+ const root = path.resolve(p);
156
+ const same = root === pkg;
157
+ return {
158
+ root,
159
+ mode: same ? "checkout" : "stack",
160
+ source: "PM_STACK_ROOT",
161
+ copy: !same,
162
+ };
163
+ }
164
+ if (fs.existsSync(path.join(pkg, ".git"))) {
165
+ return { root: pkg, mode: "checkout", source: "git checkout", copy: false };
166
+ }
167
+ const xdg = env.XDG_DATA_HOME && path.isAbsolute(env.XDG_DATA_HOME);
168
+ const dataHome = xdg ? /** @type {string} */ (env.XDG_DATA_HOME) : path.join(homeDir, ".local/share");
169
+ return {
170
+ root: path.join(dataHome, "personal-memory", "stack"),
171
+ mode: "stack",
172
+ source: xdg ? "XDG_DATA_HOME" : "default",
173
+ copy: true,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * @typedef {"create"|"restore"|"unchanged"|"update"|"conflict"|"conflict-untracked"} SyncAction
179
+ */
180
+
181
+ /**
182
+ * Pure decision for one shipped file.
183
+ *
184
+ * @param {{
185
+ * shippedHash: string,
186
+ * disk: null | "symlink" | "not-file" | string, // null = missing, else sha256
187
+ * recordedHash?: string, // hash written by a previous sync
188
+ * }} s
189
+ * @returns {SyncAction}
190
+ */
191
+ export function planEntry({ shippedHash, disk, recordedHash }) {
192
+ if (disk === null) return recordedHash ? "restore" : "create";
193
+ if (disk === "symlink" || disk === "not-file") return "conflict-untracked";
194
+ if (disk === shippedHash) return "unchanged";
195
+ if (recordedHash === undefined) return "conflict-untracked";
196
+ if (disk === recordedHash) return "update";
197
+ return "conflict";
198
+ }
199
+
200
+ /**
201
+ * @param {string} dest
202
+ * @param {Buffer} data
203
+ * @param {number} mode
204
+ */
205
+ export function writeAtomic(dest, data, mode) {
206
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
207
+ const tmp = `${dest}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
208
+ try {
209
+ fs.writeFileSync(tmp, data, { mode });
210
+ fs.chmodSync(tmp, mode);
211
+ fs.renameSync(tmp, dest);
212
+ } catch (e) {
213
+ try {
214
+ fs.unlinkSync(tmp);
215
+ } catch {
216
+ // Nothing to clean up.
217
+ }
218
+ throw e;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * @param {string} stackRoot
224
+ * @returns {{ schema: number, name?: string, version?: string, files: Record<string, string> }}
225
+ */
226
+ export function readManifest(stackRoot) {
227
+ const file = path.join(stackRoot, MANIFEST_NAME);
228
+ let raw;
229
+ try {
230
+ raw = fs.readFileSync(file, "utf8");
231
+ } catch {
232
+ return { schema: MANIFEST_SCHEMA, files: {} };
233
+ }
234
+ let m;
235
+ try {
236
+ m = JSON.parse(raw);
237
+ } catch {
238
+ throw new Error(
239
+ `${file} is not valid JSON; fix or delete it (shipped files will then be treated as untracked)`,
240
+ );
241
+ }
242
+ if (!m || typeof m !== "object" || typeof m.files !== "object" || m.files === null) {
243
+ throw new Error(`${file} has an unexpected shape; fix or delete it`);
244
+ }
245
+ if (typeof m.schema === "number" && m.schema > MANIFEST_SCHEMA) {
246
+ throw new Error(
247
+ `${file} was written by a newer openmem (schema ${m.schema}); upgrade the CLI`,
248
+ );
249
+ }
250
+ return m;
251
+ }
252
+
253
+ /**
254
+ * @param {string} abs
255
+ * @returns {null | "symlink" | "not-file" | { hash: string, data: Buffer }}
256
+ */
257
+ function inspectDisk(abs) {
258
+ let st;
259
+ try {
260
+ st = fs.lstatSync(abs);
261
+ } catch (e) {
262
+ if (/** @type {NodeJS.ErrnoException} */ (e).code === "ENOENT") return null;
263
+ throw e;
264
+ }
265
+ if (st.isSymbolicLink()) return "symlink";
266
+ if (!st.isFile()) return "not-file";
267
+ const data = fs.readFileSync(abs);
268
+ return { hash: hashBytes(data), data };
269
+ }
270
+
271
+ /**
272
+ * @typedef {object} SyncReport
273
+ * @property {string} stackRoot
274
+ * @property {string} version
275
+ * @property {string[]} created
276
+ * @property {string[]} restored
277
+ * @property {string[]} updated
278
+ * @property {number} unchanged
279
+ * @property {{ path: string, reason: string }[]} conflicts kept as-is; shipped version differs
280
+ * @property {string[]} forced overwritten under force (user copy saved next to it)
281
+ * @property {string[]} orphans recorded earlier, no longer shipped; left in place
282
+ * @property {boolean} dryRun
283
+ */
284
+
285
+ /**
286
+ * Copy the shipped files from pkgRoot into stackRoot without clobbering user changes.
287
+ * Never deletes anything and never touches files that are not in the shipped set.
288
+ *
289
+ * @param {{
290
+ * pkgRoot: string,
291
+ * stackRoot: string,
292
+ * name?: string,
293
+ * version?: string,
294
+ * force?: boolean,
295
+ * dryRun?: boolean,
296
+ * rules?: AssetRule[],
297
+ * }} opts
298
+ * @returns {SyncReport}
299
+ */
300
+ export function syncStack({
301
+ pkgRoot,
302
+ stackRoot,
303
+ name = "openmem",
304
+ version = "0.0.0",
305
+ force = false,
306
+ dryRun = false,
307
+ rules = STACK_ASSETS,
308
+ }) {
309
+ const pkg = path.resolve(pkgRoot);
310
+ const stack = path.resolve(stackRoot);
311
+ if (pkg === stack) throw new Error("stack root equals the package root; nothing to materialize");
312
+
313
+ if (!dryRun) fs.mkdirSync(stack, { recursive: true });
314
+ const manifest = readManifest(stack);
315
+ const recorded = manifest.files;
316
+
317
+ /** @type {SyncReport} */
318
+ const report = {
319
+ stackRoot: stack,
320
+ version,
321
+ created: [],
322
+ restored: [],
323
+ updated: [],
324
+ unchanged: 0,
325
+ conflicts: [],
326
+ forced: [],
327
+ orphans: [],
328
+ dryRun,
329
+ };
330
+
331
+ const shipped = listShippedFiles(pkg, rules);
332
+ /** @type {Record<string, string>} */
333
+ const nextFiles = {};
334
+ let manifestDirty = manifest.version !== version || manifest.name !== name;
335
+
336
+ for (const rel of shipped) {
337
+ const src = path.join(pkg, rel);
338
+ const data = fs.readFileSync(src);
339
+ const shippedHash = hashBytes(data);
340
+ const mode = fs.statSync(src).mode & 0o111 ? 0o755 : 0o644;
341
+ const dest = path.join(stack, ...rel.split("/"));
342
+ const diskInfo = inspectDisk(dest);
343
+ const disk = diskInfo === null || typeof diskInfo === "string" ? diskInfo : diskInfo.hash;
344
+ const action = planEntry({ shippedHash, disk, recordedHash: recorded[rel] });
345
+
346
+ switch (action) {
347
+ case "create":
348
+ case "restore":
349
+ case "update":
350
+ if (!dryRun) writeAtomic(dest, data, mode);
351
+ (action === "create" ? report.created : action === "restore" ? report.restored : report.updated).push(rel);
352
+ nextFiles[rel] = shippedHash;
353
+ break;
354
+ case "unchanged":
355
+ report.unchanged++;
356
+ // Adopt a byte-identical pre-existing file so later upgrades can update it.
357
+ nextFiles[rel] = shippedHash;
358
+ break;
359
+ case "conflict":
360
+ case "conflict-untracked": {
361
+ const reason =
362
+ disk === "symlink"
363
+ ? "destination is a symlink"
364
+ : disk === "not-file"
365
+ ? "destination is not a regular file"
366
+ : action === "conflict"
367
+ ? "modified locally; the new shipped version was not applied"
368
+ : "exists but is not tracked as shipped; left untouched";
369
+ if (force && typeof diskInfo === "object" && diskInfo !== null) {
370
+ if (!dryRun) {
371
+ const backup = `${dest}.user-${Date.now()}.bak`;
372
+ writeAtomic(backup, diskInfo.data, 0o600);
373
+ writeAtomic(dest, data, mode);
374
+ }
375
+ report.forced.push(rel);
376
+ nextFiles[rel] = shippedHash;
377
+ } else {
378
+ report.conflicts.push({ path: rel, reason });
379
+ // Keep the old record so the file stays "modified" next time instead of "untracked".
380
+ if (recorded[rel] !== undefined) nextFiles[rel] = recorded[rel];
381
+ }
382
+ break;
383
+ }
384
+ default:
385
+ throw new Error(`unreachable sync action: ${action}`);
386
+ }
387
+ if (nextFiles[rel] !== recorded[rel]) manifestDirty = true;
388
+ }
389
+
390
+ for (const rel of Object.keys(recorded)) {
391
+ if (!shipped.includes(rel)) {
392
+ report.orphans.push(rel);
393
+ manifestDirty = true;
394
+ }
395
+ }
396
+
397
+ if (!dryRun && manifestDirty) {
398
+ const body = `${JSON.stringify(
399
+ { schema: MANIFEST_SCHEMA, name, version, files: nextFiles },
400
+ null,
401
+ 2,
402
+ )}\n`;
403
+ writeAtomic(path.join(stack, MANIFEST_NAME), Buffer.from(body), 0o644);
404
+ }
405
+ return report;
406
+ }
407
+
408
+ /**
409
+ * Number of files a sync changed on disk.
410
+ *
411
+ * @param {SyncReport} r
412
+ */
413
+ export function changedCount(r) {
414
+ return r.created.length + r.restored.length + r.updated.length + r.forced.length;
415
+ }
416
+
417
+ /**
418
+ * Human-readable summary lines.
419
+ *
420
+ * @param {SyncReport} r
421
+ * @returns {string[]}
422
+ */
423
+ export function formatReport(r) {
424
+ const lines = [];
425
+ const verb = r.dryRun ? "would be" : "were";
426
+ lines.push(`stack root: ${r.stackRoot} (openmem ${r.version})`);
427
+ if (r.created.length) lines.push(` ${r.created.length} file(s) ${verb} created`);
428
+ if (r.updated.length) lines.push(` ${r.updated.length} file(s) ${verb} updated: ${r.updated.join(", ")}`);
429
+ if (r.restored.length) lines.push(` ${r.restored.length} missing file(s) ${verb} restored: ${r.restored.join(", ")}`);
430
+ if (r.forced.length) lines.push(` ${r.forced.length} locally modified file(s) ${verb} overwritten (backup next to each): ${r.forced.join(", ")}`);
431
+ if (!changedCount(r)) lines.push(` ${r.unchanged} shipped file(s) up to date`);
432
+ for (const c of r.conflicts) lines.push(` CONFLICT ${c.path}: ${c.reason}`);
433
+ if (r.conflicts.length) {
434
+ lines.push(" Resolve by merging by hand, or run: openmem init --force (keeps a .bak copy)");
435
+ }
436
+ for (const o of r.orphans) lines.push(` note: ${o} is no longer shipped; left in place`);
437
+ return lines;
438
+ }