@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
@@ -0,0 +1,140 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseDocument } from "yaml";
6
+
7
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
8
+
9
+ function validGitEntry(root) {
10
+ const entry = path.join(root, ".git");
11
+ try {
12
+ const stat = fs.statSync(entry);
13
+ if (stat.isDirectory()) return fs.existsSync(path.join(entry, "HEAD")) && fs.existsSync(path.join(entry, "config"));
14
+ if (!stat.isFile()) return false;
15
+ const match = /^gitdir:\s*(.+)\s*$/m.exec(fs.readFileSync(entry, "utf8"));
16
+ if (!match) return false;
17
+ const gitDir = path.resolve(root, match[1]);
18
+ return fs.statSync(gitDir).isDirectory()
19
+ && (fs.existsSync(path.join(gitDir, "HEAD")) || fs.existsSync(path.join(gitDir, "commondir")));
20
+ } catch { return false; }
21
+ }
22
+
23
+ function projectRoot(cwd) {
24
+ try {
25
+ if (!fs.statSync(cwd).isDirectory()) return cwd;
26
+ } catch { return cwd; }
27
+ let candidate = cwd;
28
+ while (true) {
29
+ if (validGitEntry(candidate)) return candidate;
30
+ const parent = path.dirname(candidate);
31
+ if (parent === candidate) return cwd;
32
+ candidate = parent;
33
+ }
34
+ }
35
+
36
+ function projectName(root) {
37
+ try {
38
+ const gitEntry = path.join(root, ".git");
39
+ const stat = fs.statSync(gitEntry);
40
+ let gitDir = gitEntry;
41
+ if (stat.isFile()) {
42
+ const match = /^gitdir:\s*(.+)\s*$/m.exec(fs.readFileSync(gitEntry, "utf8"));
43
+ if (match) gitDir = path.resolve(root, match[1]);
44
+ }
45
+ const commonFile = path.join(gitDir, "commondir");
46
+ const commonPath = fs.existsSync(commonFile)
47
+ ? path.resolve(gitDir, fs.readFileSync(commonFile, "utf8").trim())
48
+ : gitDir;
49
+ // Only linked worktrees use a shared project's name. Submodules retain their own repository name.
50
+ if (path.basename(path.dirname(gitDir)) === "worktrees" && path.basename(commonPath) === ".git") {
51
+ return path.basename(path.dirname(commonPath));
52
+ }
53
+ } catch { /* non-git workspace */ }
54
+ return path.basename(root);
55
+ }
56
+
57
+ function expandPrefix(prefix, home) {
58
+ if (typeof prefix !== "string" || !prefix.trim()) return null;
59
+ let expanded = prefix;
60
+ if (prefix === "~" || prefix.startsWith("~/")) {
61
+ expanded = prefix === "~" ? home : path.join(home, prefix.slice(2));
62
+ } else if (prefix.startsWith("~")) {
63
+ const slash = prefix.indexOf(path.sep);
64
+ const username = slash < 0 ? prefix.slice(1) : prefix.slice(1, slash);
65
+ if (!username) return null;
66
+ let userHome = null;
67
+ try {
68
+ userHome = fs.readFileSync("/etc/passwd", "utf8").split(/\r?\n/)
69
+ .map((line) => line.split(":"))
70
+ .find((fields) => fields[0] === username)?.[5] || null;
71
+ } catch { /* passwd is not available on every platform */ }
72
+ if (!userHome && username === os.userInfo().username) userHome = home;
73
+ if (!userHome) return null;
74
+ expanded = slash < 0 ? userHome : path.join(userHome, prefix.slice(slash + 1));
75
+ }
76
+ if (!path.isAbsolute(expanded)) return null;
77
+ return path.resolve(expanded);
78
+ }
79
+
80
+ function matchingBank(target, mappings, home) {
81
+ if (!mappings || typeof mappings !== "object" || Array.isArray(mappings)) return null;
82
+ return Object.entries(mappings)
83
+ .filter(([prefix, bank]) => typeof bank === "string" && bank.trim() && expandPrefix(prefix, home))
84
+ .map(([prefix, bank]) => [expandPrefix(prefix, home), bank.trim()])
85
+ .filter(([prefix]) => target === prefix || target.startsWith(`${prefix}${path.sep}`))
86
+ .sort(([a], [b]) => b.length - a.length)[0]?.[1] || null;
87
+ }
88
+
89
+ function readJson(file) {
90
+ try {
91
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
92
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
93
+ } catch { return {}; }
94
+ }
95
+
96
+ function yamlBanks(file, home) {
97
+ const source = fs.readFileSync(file, "utf8");
98
+ const document = parseDocument(source, { uniqueKeys: true, version: "1.1" });
99
+ if (document.errors.length) throw new Error(`${file}: ${document.errors[0].message}`);
100
+ const data = document.toJS() ?? {};
101
+ if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${file}: expected a YAML mapping`);
102
+ const banks = Object.hasOwn(data, "banks")
103
+ ? data.banks
104
+ : { "~/Documents/sample-project": "coding-agent::documents-sample-project" };
105
+ if (!banks || typeof banks !== "object" || Array.isArray(banks)) throw new Error(`${file}: banks must be a mapping`);
106
+ const result = {};
107
+ for (const [key, value] of Object.entries(banks)) {
108
+ if (typeof key !== "string" || !key.trim()) throw new Error(`${file}: banks key must be a non-empty string`);
109
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${file}: banks[${key}] must be a non-empty bank id string`);
110
+ const prefix = expandPrefix(key, home);
111
+ if (!prefix) throw new Error(`${file}: banks key must be an absolute path: ${key}`);
112
+ result[prefix] = value;
113
+ }
114
+ return result;
115
+ }
116
+
117
+ function configuredBanks(home, env) {
118
+ const override = env.HINDSIGHT_INDEX_CONFIG;
119
+ const user = path.join(home, ".config", "hindsight", "index.yml");
120
+ const configFile = override ? (expandPrefix(override, home) || path.resolve(override))
121
+ : fs.existsSync(user) ? user : path.join(PKG_ROOT, "config", "index.yml");
122
+ return yamlBanks(configFile, home);
123
+ }
124
+
125
+ /** Map a workspace cwd to the indexer's project bank. */
126
+ export function bankFromCwd(cwd, options = {}) {
127
+ const dir = path.resolve(String(cwd || process.cwd()));
128
+ const root = projectRoot(dir);
129
+ const home = options.home || process.env.HOME || os.homedir();
130
+ const env = options.env || process.env;
131
+ const indexBanks = options.banks || configuredBanks(home, env);
132
+ const personalConfig = readJson(path.join(home, ".config", "personal-memory", "coding-agent.json"));
133
+ const legacyConfig = readJson(path.join(home, ".hindsight", "coding-agent.json"));
134
+ const mappingTarget = root === dir ? dir : root;
135
+ for (const mapping of [indexBanks, options.mapPathToBank || personalConfig.mapPathToBank, legacyConfig.mapPathToBank]) {
136
+ const bank = matchingBank(mappingTarget, mapping, home);
137
+ if (bank) return bank;
138
+ }
139
+ return `coding-agent::${projectName(root) || "workspace"}`;
140
+ }
@@ -0,0 +1,23 @@
1
+ /** Session-memory hook parity vs Hindsight plugin (non-Hindsight uses MCP shim). */
2
+
3
+ import { CODING_AGENTS, DEFAULT_AGENT_IDS } from "./agents.mjs";
4
+
5
+ export const AGENT_IDS = Object.freeze(CODING_AGENTS.map((a) => a.id));
6
+ export { DEFAULT_AGENT_IDS };
7
+
8
+ /** @typedef {"retain" | "recall" | "reflect" | "consolidate" | "bank_scope"} Feature */
9
+
10
+ /** @type {Record<string, Partial<Record<Feature, boolean>>>} */
11
+ export const PROVIDER_CAPABILITIES = {
12
+ hindsight: { retain: true, recall: true, reflect: true, consolidate: true, bank_scope: true },
13
+ mem0: { retain: true, recall: true, reflect: true, consolidate: false, bank_scope: true },
14
+ cognee: { retain: true, recall: true, reflect: true, consolidate: true, bank_scope: true },
15
+ };
16
+
17
+ /**
18
+ * @param {string} provider
19
+ * @param {Feature} feature
20
+ */
21
+ export function providerSupports(provider, feature) {
22
+ return Boolean(PROVIDER_CAPABILITIES[provider]?.[feature]);
23
+ }
@@ -0,0 +1,87 @@
1
+ /** Hook event names and stdout shapes per coding-agent host. */
2
+
3
+ export const SHIM_MARKER = "coding-agent/hook-runner.mjs";
4
+
5
+ export const SHIM_HARNESSES = {
6
+ "claude-code": {
7
+ configStyle: "nested",
8
+ settingsPath: (home) => `${home}/.claude/settings.json`,
9
+ rootKey: "hooks",
10
+ events: {
11
+ sessionStart: { hook: "SessionStart", timeout: 30 },
12
+ prompt: { hook: "UserPromptSubmit", timeout: 30 },
13
+ stop: { hook: "Stop", timeout: 60 },
14
+ },
15
+ parse: {
16
+ sessionStart: (ev) => ({ cwd: ev.cwd, sessionId: ev.session_id }),
17
+ prompt: (ev) => ({ cwd: ev.cwd, sessionId: ev.session_id, prompt: ev.prompt }),
18
+ stop: (ev) => ({ cwd: ev.cwd, sessionId: ev.session_id, transcriptPath: ev.transcript_path }),
19
+ },
20
+ emit: {
21
+ sessionStart: (ctx) => ({
22
+ hookSpecificOutput: { hookEventName: "SessionStart", ...(ctx ? { additionalContext: ctx } : {}) },
23
+ }),
24
+ prompt: (ctx) => ({
25
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", ...(ctx ? { additionalContext: ctx } : {}) },
26
+ }),
27
+ stop: () => ({}),
28
+ },
29
+ },
30
+ codex: {
31
+ configStyle: "nested",
32
+ settingsPath: (home) => `${home}/.codex/hooks.json`,
33
+ rootKey: "hooks",
34
+ events: {
35
+ sessionStart: { hook: "SessionStart", timeout: 30 },
36
+ prompt: { hook: "UserPromptSubmit", timeout: 30 },
37
+ stop: { hook: "Stop", timeout: 60 },
38
+ },
39
+ parse: {
40
+ sessionStart: (ev) => ({ cwd: ev.cwd, sessionId: ev.session_id }),
41
+ prompt: (ev) => ({ cwd: ev.cwd, sessionId: ev.session_id, prompt: ev.prompt ?? ev.user_prompt }),
42
+ stop: (ev) => ({ cwd: ev.cwd, sessionId: ev.session_id, transcriptPath: ev.transcript_path }),
43
+ },
44
+ emit: {
45
+ sessionStart: (ctx) => ({
46
+ hookSpecificOutput: { hookEventName: "SessionStart", ...(ctx ? { additionalContext: ctx } : {}) },
47
+ }),
48
+ prompt: (ctx) => ({
49
+ hookSpecificOutput: { hookEventName: "UserPromptSubmit", ...(ctx ? { additionalContext: ctx } : {}) },
50
+ }),
51
+ stop: () => ({}),
52
+ },
53
+ },
54
+ "cursor-cli": {
55
+ configStyle: "flat",
56
+ settingsPath: (home) => `${home}/.cursor/hooks.json`,
57
+ rootKey: null,
58
+ events: {
59
+ sessionStart: { hook: "sessionStart", timeout: 30 },
60
+ prompt: { hook: "beforeSubmitPrompt", timeout: 30 },
61
+ stop: { hook: "stop", timeout: 30 },
62
+ },
63
+ parse: {
64
+ sessionStart: (ev) => ({
65
+ cwd: ev.cwd ?? ev.workspace_root ?? ev.workspace_roots?.[0],
66
+ sessionId: ev.conversation_id ?? ev.session_id,
67
+ }),
68
+ prompt: (ev) => ({
69
+ cwd: ev.cwd ?? ev.workspace_root ?? ev.workspace_roots?.[0],
70
+ sessionId: ev.conversation_id ?? ev.session_id,
71
+ prompt: ev.prompt ?? ev.user_prompt,
72
+ }),
73
+ stop: (ev) => ({
74
+ cwd: ev.cwd ?? ev.workspace_root ?? ev.workspace_roots?.[0],
75
+ sessionId: ev.conversation_id ?? ev.session_id,
76
+ transcriptPath: ev.transcript_path,
77
+ }),
78
+ },
79
+ emit: {
80
+ sessionStart: (ctx) => (ctx ? { additional_context: ctx } : {}),
81
+ prompt: (ctx) => ({ continue: true, ...(ctx ? { additional_context: ctx } : {}) }),
82
+ stop: () => ({}),
83
+ },
84
+ },
85
+ };
86
+
87
+ export const SHIM_AGENT_IDS = Object.freeze(Object.keys(SHIM_HARNESSES));
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session hook entry: node hook-runner.mjs <harness> <sessionStart|prompt|stop>
4
+ * stdin: host hook JSON. Uses unified memory facade (active MEMORY_PROVIDER).
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { bankFromCwd } from "./bank-from-cwd.mjs";
11
+ import { accessAllowed, readAccessConfig } from "./access-policy.mjs";
12
+ import { SHIM_HARNESSES } from "./hook-harnesses.mjs";
13
+ import { sessionRecall, sessionRetain } from "./session-facade.mjs";
14
+ import { resolveMcpContext } from "../mcp/context.mjs";
15
+ import { resolveStackRoot } from "../stack.mjs";
16
+
17
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
18
+ function resolveAccess(cwd, config, home = process.env.HOME || os.homedir()) {
19
+ if (!accessAllowed(cwd, config)) return { allowed: false };
20
+ return { allowed: true, bank: bankFromCwd(cwd, { home, mapPathToBank: config.mapPathToBank }) };
21
+ }
22
+
23
+ async function readStdinJson() {
24
+ const chunks = [];
25
+ for await (const c of process.stdin) chunks.push(c);
26
+ const raw = Buffer.concat(chunks).toString("utf8").trim();
27
+ if (!raw) return {};
28
+ return JSON.parse(raw);
29
+ }
30
+
31
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
32
+ const TRANSCRIPT_MAX_CHARS = 12000;
33
+
34
+ function blockText(content, acceptedTypes) {
35
+ if (typeof content === "string") return content;
36
+ if (!Array.isArray(content)) return "";
37
+ return content
38
+ .filter((part) => part && acceptedTypes.includes(part.type) && typeof part.text === "string")
39
+ .map((part) => part.text)
40
+ .join("");
41
+ }
42
+
43
+ function safeAction(label, input) {
44
+ let parsed = input;
45
+ if (typeof input === "string") {
46
+ try { parsed = JSON.parse(input); } catch { /* retain only valid structured arguments */ }
47
+ }
48
+ const summary = parsed && typeof parsed === "object" ? JSON.stringify(parsed) : "";
49
+ return `[action: ${label}${summary ? ` ${summary.slice(0, 800)}` : ""}]`;
50
+ }
51
+
52
+ function parseTranscriptRecord(harnessId, record) {
53
+ if (!record || typeof record !== "object") return [];
54
+ const out = [];
55
+ if (harnessId === "codex") {
56
+ // Best-effort adapter for observed rollout response_item rows. Codex says
57
+ // transcript_path is provided for convenience, but its transcript schema is unstable.
58
+ const payload = record.type === "response_item" ? record.payload : null;
59
+ if (!payload || typeof payload !== "object") return out;
60
+ if (payload.type === "message" && ["user", "assistant"].includes(payload.role)) {
61
+ const accepted = payload.role === "user" ? ["input_text"] : ["output_text"];
62
+ const text = blockText(payload.content, accepted);
63
+ if (text.trim()) out.push(`${payload.role}: ${text.trim()}`);
64
+ } else if (payload.type === "function_call" || payload.type === "custom_tool_call") {
65
+ const label = payload.name || payload.type;
66
+ out.push(safeAction(label, payload.arguments ?? payload.input));
67
+ }
68
+ return out;
69
+ }
70
+ if (harnessId === "claude-code") {
71
+ // Claude transcript message rows use user/assistant roles. Keep only ordinary
72
+ // text and explicit tool-use blocks; thinking and tool-result blocks are omitted.
73
+ const message = record.message;
74
+ if (!["user", "assistant"].includes(message?.role)) return out;
75
+ const text = blockText(message.content, ["text"]);
76
+ if (text.trim()) out.push(`${message.role}: ${text.trim()}`);
77
+ if (message.role === "assistant" && Array.isArray(message.content)) {
78
+ for (const part of message.content) {
79
+ if (part?.type === "tool_use") out.push(safeAction(part.name || "tool", part.input));
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+ if (harnessId === "cursor-cli") {
85
+ // Observed Cursor agent-transcripts rows have a top-level role and a
86
+ // message.content array. This adapter is based on the local schema shape;
87
+ // live Hook transcript_path linkage remains unverified.
88
+ const role = record.role;
89
+ const message = record.message;
90
+ if (!["user", "assistant"].includes(role) || !Array.isArray(message?.content)) return out;
91
+ const text = blockText(message.content, ["text"]);
92
+ if (text.trim()) out.push(`${role}: ${text.trim()}`);
93
+ if (role === "assistant") {
94
+ for (const part of message.content) {
95
+ if (part?.type === "tool_use") out.push(safeAction(part.name || "tool", part.input));
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function cursorFile(home) {
104
+ return path.join(home, ".config", "personal-memory", "transcript-cursors.json");
105
+ }
106
+
107
+ function loadTranscriptCursors(home) {
108
+ try {
109
+ const value = JSON.parse(fs.readFileSync(cursorFile(home), "utf8"));
110
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
111
+ } catch { return {}; }
112
+ }
113
+
114
+ function saveTranscriptCursors(home, cursors) {
115
+ const file = cursorFile(home);
116
+ fs.mkdirSync(path.dirname(file), { recursive: true });
117
+ const temporary = `${file}.${process.pid}.tmp`;
118
+ fs.writeFileSync(temporary, JSON.stringify(cursors), { mode: 0o600 });
119
+ fs.renameSync(temporary, file);
120
+ }
121
+
122
+ function readNewTranscript(harnessId, transcriptPath, home, sessionId) {
123
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
124
+ try {
125
+ const stat = fs.statSync(transcriptPath);
126
+ if (!stat.isFile()) return null;
127
+ const key = `${harnessId}:${sessionId || path.resolve(transcriptPath)}`;
128
+ const cursors = loadTranscriptCursors(home);
129
+ const saved = cursors[key];
130
+ const oldPosition = Number.isSafeInteger(saved) ? saved
131
+ : Number.isSafeInteger(saved?.position) ? saved.position : 0;
132
+ const position = oldPosition <= stat.size ? oldPosition : 0;
133
+ let validPosition = position;
134
+ if (position && typeof saved === "object" && saved.anchor) {
135
+ const anchorLength = Math.min(256, position);
136
+ const anchorBuffer = Buffer.alloc(anchorLength);
137
+ const anchorFd = fs.openSync(transcriptPath, "r");
138
+ try { fs.readSync(anchorFd, anchorBuffer, 0, anchorLength, position - anchorLength); }
139
+ finally { fs.closeSync(anchorFd); }
140
+ if (anchorBuffer.toString("base64") !== saved.anchor) validPosition = 0;
141
+ }
142
+ const start = Math.max(validPosition, stat.size - TRANSCRIPT_TAIL_BYTES);
143
+ const length = stat.size - start;
144
+ const fd = fs.openSync(transcriptPath, "r");
145
+ const buffer = Buffer.alloc(length);
146
+ try { fs.readSync(fd, buffer, 0, length, start); } finally { fs.closeSync(fd); }
147
+ const text = buffer.toString("utf8");
148
+ const completeText = text.endsWith("\n") ? text : text.slice(0, text.lastIndexOf("\n") + 1);
149
+ const completeLines = completeText.split("\n");
150
+ if (completeLines.at(-1) === "") completeLines.pop();
151
+ if (harnessId === "cursor-cli") {
152
+ // Do not create a cursor for unknown or unverified row shapes. A single
153
+ // structurally recognized message row enables the bounded adapter.
154
+ const recognized = completeLines.some((line) => {
155
+ try {
156
+ const record = JSON.parse(line);
157
+ return ["user", "assistant"].includes(record?.role) && Array.isArray(record?.message?.content);
158
+ } catch { return false; }
159
+ });
160
+ if (!recognized) return null;
161
+ }
162
+ const extracted = [];
163
+ for (const line of completeLines) {
164
+ if (!line.trim()) continue;
165
+ try { extracted.push(...parseTranscriptRecord(harnessId, JSON.parse(line))); } catch { /* ignore malformed rows */ }
166
+ }
167
+ const content = extracted.join("\n").slice(-TRANSCRIPT_MAX_CHARS).trim();
168
+ // A bounded tail can begin mid-row. If it contains no newline, do not
169
+ // checkpoint that interior offset; retry from the last known boundary.
170
+ const nextPosition = completeText.length
171
+ ? start + Buffer.byteLength(completeText)
172
+ : validPosition;
173
+ const anchorLength = Math.min(256, nextPosition);
174
+ const anchorBuffer = Buffer.alloc(anchorLength);
175
+ const anchorFd = fs.openSync(transcriptPath, "r");
176
+ try {
177
+ if (anchorLength) fs.readSync(anchorFd, anchorBuffer, 0, anchorLength, nextPosition - anchorLength);
178
+ } finally { fs.closeSync(anchorFd); }
179
+ return {
180
+ key, cursors, position: { position: nextPosition, anchor: anchorBuffer.toString("base64") }, content,
181
+ };
182
+ } catch { return null; }
183
+ }
184
+
185
+ export async function runHook(harnessId, phase, { home, env, repoRoot, ev, fetchFn = fetch }) {
186
+ const spec = SHIM_HARNESSES[harnessId];
187
+ if (!spec) throw new Error(`unknown harness ${harnessId}`);
188
+ const parsed = spec.parse[phase](ev);
189
+ const config = readAccessConfig(home);
190
+ const access = config ? resolveAccess(parsed.cwd, config, home) : { allowed: false };
191
+ if (!access.allowed) return spec.emit[phase](undefined);
192
+ const bank = access.bank;
193
+ let runtimeConfig = {};
194
+ try {
195
+ runtimeConfig = JSON.parse(fs.readFileSync(path.join(repoRoot, "agent-runtime.json"), "utf8"));
196
+ } catch {
197
+ // Checkout and direct test calls use ordinary stack resolution.
198
+ }
199
+ const runtimeEnv = { ...env, PM_STACK_ROOT: env.PM_STACK_ROOT || runtimeConfig.stackRoot };
200
+ const stack = resolveStackRoot({ pkgRoot: repoRoot, env: runtimeEnv, home });
201
+ const ctx = resolveMcpContext({ home, stackRoot: stack.root, repoRoot, env: runtimeEnv });
202
+ let context = "";
203
+
204
+ if (phase === "sessionStart") {
205
+ context = await sessionRecall(
206
+ ctx,
207
+ { query: "What should I know about this project and recent decisions?", bank, limit: 8,
208
+ budget: "low", maxTokens: 1200 },
209
+ fetchFn,
210
+ );
211
+ } else if (phase === "prompt") {
212
+ const q = String(parsed.prompt || "").trim();
213
+ if (q) {
214
+ context = await sessionRecall(ctx, { query: q, bank, limit: 8 }, fetchFn);
215
+ }
216
+ } else if (phase === "stop") {
217
+ const transcript = readNewTranscript(harnessId, parsed.transcriptPath, home, parsed.sessionId);
218
+ if (transcript) {
219
+ if (transcript.content) {
220
+ await sessionRetain(
221
+ ctx,
222
+ {
223
+ bank,
224
+ content: `Session turn (${harnessId} ${parsed.sessionId || "unknown"}):\n${transcript.content}`,
225
+ },
226
+ fetchFn,
227
+ );
228
+ }
229
+ transcript.cursors[transcript.key] = transcript.position;
230
+ saveTranscriptCursors(home, transcript.cursors);
231
+ }
232
+ return spec.emit.stop();
233
+ }
234
+
235
+ return spec.emit[phase](context?.trim() || undefined);
236
+ }
237
+
238
+ async function main() {
239
+ const [harnessId, phase] = process.argv.slice(2);
240
+ if (!harnessId || !phase || !SHIM_HARNESSES[harnessId]?.parse[phase]) {
241
+ console.error("usage: hook-runner.mjs <claude-code|codex|cursor-cli> <sessionStart|prompt|stop>");
242
+ process.exit(2);
243
+ }
244
+ try {
245
+ const ev = await readStdinJson();
246
+ const out = await runHook(harnessId, phase, {
247
+ home: process.env.HOME || os.homedir(),
248
+ env: process.env,
249
+ repoRoot: PKG_ROOT,
250
+ ev,
251
+ });
252
+ if (out && Object.keys(out).length) process.stdout.write(`${JSON.stringify(out)}\n`);
253
+ } catch (err) {
254
+ console.error(`openmem hook: ${err instanceof Error ? err.message : err}`);
255
+ process.exit(1);
256
+ }
257
+ }
258
+
259
+ const self = fileURLToPath(import.meta.url);
260
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(self)) main();
@@ -0,0 +1,93 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { mergeOpencodeConfig, opencodeConfigPath } from "./opencode-config.mjs";
4
+
5
+ const SKILL_TARGETS = {
6
+ "claude-code": ".claude/skills/openmem",
7
+ codex: ".agents/skills/openmem",
8
+ "cursor-cli": ".cursor/skills/openmem",
9
+ opencode: ".config/opencode/skills/openmem",
10
+ };
11
+
12
+ function readJson(file) {
13
+ return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : {};
14
+ }
15
+
16
+ function writeJson(file, value) {
17
+ fs.mkdirSync(path.dirname(file), { recursive: true });
18
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
19
+ }
20
+
21
+ function updateCodexToml(text, scriptPath, stackRoot) {
22
+ const blocks = text.split(/(?=^[ \t]*\[)/m);
23
+ const featureIndex = blocks.findIndex((block) => /^[ \t]*\[features\][ \t]*(?:#.*)?(?:\r?\n|$)/.test(block));
24
+ if (featureIndex < 0) blocks.push(`${text && !text.endsWith("\n") ? "\n" : ""}[features]\nhooks = true\n`);
25
+ else if (!/^hooks\s*=/m.test(blocks[featureIndex])) blocks[featureIndex] = `${blocks[featureIndex].trimEnd()}\nhooks = true\n`;
26
+ text = blocks.join("");
27
+ const section = [
28
+ "[mcp_servers.openmem]",
29
+ 'command = "node"',
30
+ `args = [${JSON.stringify(scriptPath)}]`,
31
+ "[mcp_servers.openmem.env]",
32
+ `PM_STACK_ROOT = ${JSON.stringify(stackRoot)}`,
33
+ ].join("\n");
34
+ // Drop current openmem blocks and the pre-rebrand personal-memory name.
35
+ const oldServer =
36
+ /^[ \t]*\[mcp_servers\.(?:openmem|"openmem"|'openmem'|personal-memory|"personal-memory"|'personal-memory')(?:\.(?:env|"env"|'env'))?[ \t]*\]/;
37
+ const retained = text.split(/(?=^[ \t]*\[)/m).filter((block) => !oldServer.test(block));
38
+ return `${retained.join("").trimEnd()}\n\n${section}\n`;
39
+ }
40
+
41
+ /** Install one shared skill and the unified MCP in each selected agent's global config. */
42
+ export function installAgentAccess({ home, runtimeRoot, stackRoot, agents, dry = false, h }) {
43
+ const scriptPath = path.join(runtimeRoot, "cli", "openmem-mcp.mjs");
44
+ const skillSource = path.join(runtimeRoot, "skills", "openmem", "SKILL.md");
45
+ for (const agent of agents) {
46
+ const skillRel = SKILL_TARGETS[agent];
47
+ if (!skillRel) continue;
48
+ const skillPath = path.join(home, skillRel, "SKILL.md");
49
+ if (dry) h?.info(`[dry-run] install ${agent} openmem skill → ${skillPath}`);
50
+ else {
51
+ fs.mkdirSync(path.dirname(skillPath), { recursive: true });
52
+ fs.copyFileSync(skillSource, skillPath);
53
+ h?.ok(`${agent} skill → ${skillPath}`);
54
+ }
55
+ if (agent === "cursor-cli") continue; // install-mcp owns ~/.cursor/mcp.json
56
+ if (agent === "codex") {
57
+ const file = path.join(home, ".codex", "config.toml");
58
+ if (dry) h?.info(`[dry-run] merge openmem MCP into ${file}`);
59
+ else {
60
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ fs.writeFileSync(file, updateCodexToml(current, scriptPath, stackRoot));
63
+ h?.ok(`codex MCP → ${file}`);
64
+ }
65
+ continue;
66
+ }
67
+ const file = agent === "claude-code" ? path.join(home, ".claude.json") : opencodeConfigPath(home);
68
+ if (dry) {
69
+ h?.info(`[dry-run] merge openmem MCP into ${file}`);
70
+ continue;
71
+ }
72
+ if (agent === "claude-code") {
73
+ const config = readJson(file);
74
+ const servers = { ...(config.mcpServers || {}) };
75
+ delete servers["personal-memory"];
76
+ servers.openmem = {
77
+ type: "stdio", command: "node", args: [scriptPath], env: { PM_STACK_ROOT: stackRoot },
78
+ };
79
+ config.mcpServers = servers;
80
+ writeJson(file, config);
81
+ } else {
82
+ mergeOpencodeConfig(home, "mcp", (existing) => {
83
+ const next = { ...existing, openmem: {
84
+ type: "local", command: ["node", scriptPath], enabled: true,
85
+ environment: { PM_STACK_ROOT: stackRoot },
86
+ } };
87
+ delete next["personal-memory"];
88
+ return next;
89
+ });
90
+ }
91
+ h?.ok(`${agent} MCP → ${file}`);
92
+ }
93
+ }