@bcmyguest/openmem 0.6.3 → 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 (102) hide show
  1. package/{skills → .agents/skills}/openmem/SKILL.md +5 -0
  2. package/README.md +80 -76
  3. package/cli/coding-agent/capabilities.mjs +14 -3
  4. package/cli/coding-agent/context-store.mjs +81 -0
  5. package/cli/coding-agent/context-trim.mjs +897 -0
  6. package/cli/coding-agent/file-io.mjs +72 -0
  7. package/cli/coding-agent/hook-harnesses.mjs +2 -1
  8. package/cli/coding-agent/hook-runner.mjs +28 -4
  9. package/cli/coding-agent/install-access.mjs +43 -17
  10. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  11. package/cli/coding-agent/install-rtk.mjs +60 -0
  12. package/cli/coding-agent/install-shim.mjs +119 -15
  13. package/cli/coding-agent/opencode-config.mjs +21 -5
  14. package/cli/coding-agent/opencode-plugin.mjs +39 -3
  15. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  16. package/cli/coding-agent/recall-frame.mjs +29 -0
  17. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  18. package/cli/coding-agent/runtime.mjs +2 -1
  19. package/cli/coding-agent/session-db.mjs +626 -0
  20. package/cli/coding-agent/session-facade.mjs +30 -6
  21. package/cli/coding-agent/toml-edit.mjs +161 -0
  22. package/cli/context-management.mjs +254 -0
  23. package/cli/feed-sync.mjs +18 -2
  24. package/cli/install-coding-agent.mjs +173 -40
  25. package/cli/mcp/context-sql-runner.mjs +58 -0
  26. package/cli/mcp/context-store.mjs +670 -0
  27. package/cli/mcp/knowledge.mjs +333 -0
  28. package/cli/mcp/memory-facade.mjs +99 -33
  29. package/cli/openmem-mcp.mjs +3 -0
  30. package/cli/openmem.mjs +18 -1
  31. package/cli/schedule.mjs +176 -0
  32. package/cli/stack.mjs +2 -0
  33. package/cli/switch-memory-provider.mjs +1 -1
  34. package/compose.yaml +67 -4
  35. package/config/hindsight.env.example +2 -0
  36. package/config/openmem.toml.example +64 -2
  37. package/docs/architecture.md +35 -9
  38. package/docs/coding-agent-hooks.md +26 -2
  39. package/docs/coding-agents.md +27 -7
  40. package/docs/compatibility.md +80 -0
  41. package/docs/concepts.md +30 -7
  42. package/docs/context-management.md +255 -0
  43. package/docs/context-proxy.md +115 -0
  44. package/docs/context-research.md +215 -0
  45. package/docs/context-store.md +277 -0
  46. package/docs/hindsight.md +5 -2
  47. package/docs/mcp-setup.md +5 -2
  48. package/docs/mem0.md +39 -1
  49. package/docs/memory-providers.md +2 -2
  50. package/docs/meridian-litellm.md +198 -0
  51. package/docs/model-configuration.md +22 -0
  52. package/docs/npx-install.md +6 -4
  53. package/docs/openmem-config.md +2 -2
  54. package/docs/quickstart.md +12 -6
  55. package/docs/setup-wizard.md +5 -4
  56. package/docs/sources-feeds.md +9 -22
  57. package/package.json +5 -4
  58. package/providers/cognee/OPERATOR.md +2 -2
  59. package/providers/hindsight/lib/clack.mjs +122 -20
  60. package/providers/hindsight/lib/coding-agent.mjs +1 -1
  61. package/providers/hindsight/lib/config.mjs +6 -0
  62. package/providers/hindsight/lib/env-setup.mjs +2 -1
  63. package/providers/hindsight/lib/litellm-routes.mjs +21 -12
  64. package/providers/hindsight/lib/meridian.mjs +307 -0
  65. package/providers/hindsight/lib/model-backend.mjs +38 -9
  66. package/providers/hindsight/lib/probe.mjs +21 -0
  67. package/providers/hindsight/lib/stack-env.mjs +5 -12
  68. package/providers/hindsight/lib/state.mjs +6 -1
  69. package/providers/hindsight/lib/summary.mjs +16 -2
  70. package/providers/hindsight/lib/validate.mjs +6 -0
  71. package/providers/hindsight/wizard.mjs +3 -2
  72. package/providers/mem0/OPERATOR.md +21 -2
  73. package/providers/mem0/compose.yaml +24 -2
  74. package/providers/mem0/lib/coding-agent.mjs +1 -1
  75. package/providers/mem0/lib/configure.mjs +66 -0
  76. package/providers/mem0/lib/litellm.mjs +457 -0
  77. package/providers/mem0/lib/stack-env.mjs +40 -1
  78. package/providers/mem0/lib/summary.mjs +30 -2
  79. package/providers/mem0/manifest.json +1 -1
  80. package/providers/mem0/wizard.mjs +15 -2
  81. package/proxy/config.yaml +33 -0
  82. package/proxy/openmem_context/__init__.py +6 -0
  83. package/proxy/openmem_context/adapters.py +504 -0
  84. package/proxy/openmem_context/compress.py +53 -0
  85. package/proxy/openmem_context/core.py +606 -0
  86. package/proxy/openmem_context/embed.py +212 -0
  87. package/proxy/openmem_context/recall.py +49 -0
  88. package/proxy/openmem_context/session.py +70 -0
  89. package/proxy/openmem_context/sessiondb.py +276 -0
  90. package/proxy/openmem_context/settings.py +134 -0
  91. package/proxy/openmem_context/store.py +129 -0
  92. package/proxy/openmem_context/tokens.py +123 -0
  93. package/proxy/openmem_context/ttl.py +78 -0
  94. package/proxy/openmem_context_hook.py +34 -0
  95. package/scripts/check-pack.mjs +2 -1
  96. package/scripts/setup.mjs +14 -1
  97. package/server.json +2 -2
  98. package/wizard/lib/context-step.mjs +140 -0
  99. package/wizard/lib/litellm-env.mjs +55 -0
  100. package/wizard/lib/pm-config.mjs +212 -4
  101. package/wizard/lib/prompts.mjs +28 -0
  102. package/wizard/lib/run-wizard.mjs +26 -31
@@ -1,6 +1,7 @@
1
1
  import { callUnifiedTool } from "../mcp/memory-facade.mjs";
2
+ import { recallBlock } from "./recall-frame.mjs";
2
3
 
3
- const HOOK_CONTEXT_MAX_CHARS = 8000;
4
+ export const HOOK_CONTEXT_MAX_CHARS = 8000;
4
5
 
5
6
  function toolText(result) {
6
7
  const t = result?.content?.[0]?.text;
@@ -15,7 +16,7 @@ function toolText(result) {
15
16
  }
16
17
  }
17
18
 
18
- function recallText(result, limit) {
19
+ function recallText(result, limit, maxChars = HOOK_CONTEXT_MAX_CHARS) {
19
20
  try {
20
21
  const value = JSON.parse(result?.content?.[0]?.text || "");
21
22
  const hits = value?.result?.results;
@@ -24,17 +25,25 @@ function recallText(result, limit) {
24
25
  .map((hit) => hit?.text ?? hit?.memory ?? hit?.content ?? "")
25
26
  .filter((item) => typeof item === "string" && item.trim())
26
27
  .join("\n\n")
27
- .slice(0, HOOK_CONTEXT_MAX_CHARS);
28
+ .slice(0, maxChars);
28
29
  }
29
30
  } catch { /* use generic result text */ }
30
- return toolText(result).slice(0, HOOK_CONTEXT_MAX_CHARS);
31
+ return toolText(result).slice(0, maxChars);
32
+ }
33
+
34
+ /**
35
+ * Hook or plugin recall text framed as untrusted data (recall-frame.mjs), at most HOOK_CONTEXT_MAX_CHARS
36
+ * characters frame included; "" for empty text.
37
+ */
38
+ export function hookRecallBlock(text) {
39
+ return recallBlock(String(text ?? "").trim(), HOOK_CONTEXT_MAX_CHARS);
31
40
  }
32
41
 
33
42
  /** @param {ReturnType<import("../mcp/context.mjs").resolveMcpContext>} ctx */
34
- export async function sessionRecall(ctx, { query, bank, limit = 10, budget, maxTokens }, fetchFn = fetch) {
43
+ export async function sessionRecall(ctx, { query, bank, limit = 10, budget, maxTokens, maxChars }, fetchFn = fetch) {
35
44
  const out = await callUnifiedTool(ctx, "memory_recall", { query, bank, limit, budget, maxTokens }, fetchFn);
36
45
  if (out.isError) throw new Error(toolText(out));
37
- return recallText(out, limit);
46
+ return recallText(out, limit, maxChars);
38
47
  }
39
48
 
40
49
  /** @param {ReturnType<import("../mcp/context.mjs").resolveMcpContext>} ctx */
@@ -60,3 +69,18 @@ export async function sessionReflectOrSearch(ctx, { query, bank, limit = 12 }, f
60
69
  }
61
70
  return body;
62
71
  }
72
+
73
+ /**
74
+ * True when the bank has pending or processing ingest (`memory_status` active > 0).
75
+ * Synchronous providers (mem0) and providers without ingest status (Cognee) report false.
76
+ */
77
+ export async function sessionIngestActive(ctx, { bank }, fetchFn = fetch) {
78
+ const out = await callUnifiedTool(ctx, "memory_status", { bank }, fetchFn);
79
+ if (out.isError) throw new Error(toolText(out));
80
+ try {
81
+ const status = JSON.parse(out?.content?.[0]?.text || "");
82
+ return status?.supported !== false && Number(status?.active) > 0;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Text-preserving TOML edits: locate statements (table headers and key/value pairs) with a
3
+ * small lexer that understands strings, multi-line strings, comments and multi-line arrays,
4
+ * so an edit removes exactly one entry's text and leaves every other byte alone.
5
+ */
6
+
7
+ const BARE_KEY = /[A-Za-z0-9_-]/;
8
+
9
+ /** Offset just past the line break at or after `i` (or the end of the text). */
10
+ function lineEnd(text, i) {
11
+ const nl = text.indexOf("\n", i);
12
+ return nl < 0 ? text.length : nl + 1;
13
+ }
14
+
15
+ /** Offset after the string starting at `i` (any of the four TOML string forms). */
16
+ function skipString(text, i) {
17
+ const quote = text[i];
18
+ const triple = text.startsWith(quote.repeat(3), i);
19
+ if (triple) {
20
+ let j = i + 3;
21
+ while (j < text.length) {
22
+ if (quote === '"' && text[j] === "\\") { j += 2; continue; }
23
+ if (text.startsWith(quote.repeat(3), j)) {
24
+ j += 3;
25
+ // Up to two quotes may directly precede the closing delimiter.
26
+ for (let k = 0; k < 2 && text[j] === quote; k++) j++;
27
+ return j;
28
+ }
29
+ j++;
30
+ }
31
+ throw new SyntaxError("unterminated multi-line string");
32
+ }
33
+ let j = i + 1;
34
+ while (j < text.length && text[j] !== "\n") {
35
+ if (quote === '"' && text[j] === "\\") { j += 2; continue; }
36
+ if (text[j] === quote) return j + 1;
37
+ j++;
38
+ }
39
+ throw new SyntaxError("unterminated string");
40
+ }
41
+
42
+ /** Parse a dotted key at `i`; returns the segments and the offset after the key. */
43
+ function parseKey(text, i) {
44
+ const parts = [];
45
+ for (;;) {
46
+ while (text[i] === " " || text[i] === "\t") i++;
47
+ if (text[i] === '"' || text[i] === "'") {
48
+ const end = skipString(text, i);
49
+ const raw = text.slice(i, end);
50
+ if (raw[0] === "'") parts.push(raw.slice(1, -1));
51
+ else {
52
+ try { parts.push(JSON.parse(raw)); } catch { parts.push(raw.slice(1, -1)); }
53
+ }
54
+ i = end;
55
+ } else {
56
+ const start = i;
57
+ while (i < text.length && BARE_KEY.test(text[i])) i++;
58
+ if (i === start) throw new SyntaxError(`bad key at offset ${start}`);
59
+ parts.push(text.slice(start, i));
60
+ }
61
+ while (text[i] === " " || text[i] === "\t") i++;
62
+ if (text[i] !== ".") return { parts, end: i };
63
+ i++;
64
+ }
65
+ }
66
+
67
+ /** Offset after a value starting at `i`, through the end of its last line (comment included). */
68
+ function skipValue(text, i) {
69
+ let depth = 0;
70
+ while (i < text.length) {
71
+ const c = text[i];
72
+ if (c === '"' || c === "'") { i = skipString(text, i); continue; }
73
+ if (c === "#") { i = text.indexOf("\n", i); if (i < 0) return text.length; continue; }
74
+ if (c === "[" || c === "{") depth++;
75
+ else if (c === "]" || c === "}") depth--;
76
+ else if (c === "\n" && depth <= 0) return i + 1;
77
+ i++;
78
+ }
79
+ return text.length;
80
+ }
81
+
82
+ /**
83
+ * Statements of a TOML document in order: `{ kind: "table" | "array-table" | "kv", path, start, end }`.
84
+ * `path` is the full key path (a key/value pair includes its table's path); `start` is the
85
+ * offset of the statement's line and `end` the offset after its last line.
86
+ * @param {string} text
87
+ */
88
+ export function tomlStatements(text) {
89
+ const out = [];
90
+ let table = [];
91
+ let i = 0;
92
+ while (i < text.length) {
93
+ const start = i;
94
+ while (text[i] === " " || text[i] === "\t" || text[i] === "\r") i++;
95
+ if (i >= text.length) break;
96
+ if (text[i] === "\n") { i++; continue; }
97
+ if (text[i] === "#") { i = lineEnd(text, i); continue; }
98
+ if (text[i] === "[") {
99
+ const array = text[i + 1] === "[";
100
+ const key = parseKey(text, i + (array ? 2 : 1));
101
+ table = key.parts;
102
+ out.push({ kind: array ? "array-table" : "table", path: table, start, end: lineEnd(text, key.end) });
103
+ i = lineEnd(text, key.end);
104
+ continue;
105
+ }
106
+ const key = parseKey(text, i);
107
+ if (text[key.end] !== "=") throw new SyntaxError(`expected "=" at offset ${key.end}`);
108
+ const end = skipValue(text, key.end + 1);
109
+ out.push({ kind: "kv", path: [...table, ...key.parts], start, end });
110
+ i = end;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ const startsWith = (pathParts, prefix) => prefix.every((p, k) => pathParts[k] === p);
116
+
117
+ /**
118
+ * Text spans to delete so no statement at or under `prefix` remains: whole tables (header through
119
+ * the table's last key/value pair, so comments that follow it stay) and single key/value pairs.
120
+ * @param {string} text
121
+ * @param {string[]} prefix
122
+ * @returns {{ start: number, end: number }[]}
123
+ */
124
+ export function tomlSpansUnder(text, prefix) {
125
+ const statements = tomlStatements(text);
126
+ const spans = [];
127
+ for (let s = 0; s < statements.length; s++) {
128
+ const st = statements[s];
129
+ if (st.kind === "kv") {
130
+ if (startsWith(st.path, prefix)) spans.push({ start: st.start, end: st.end });
131
+ continue;
132
+ }
133
+ if (!startsWith(st.path, prefix)) continue;
134
+ let end = st.end;
135
+ let k = s + 1;
136
+ for (; k < statements.length && statements[k].kind === "kv"; k++) end = statements[k].end;
137
+ spans.push({ start: st.start, end });
138
+ s = k - 1;
139
+ }
140
+ return spans;
141
+ }
142
+
143
+ /** Remove `spans` from `text`, also dropping blank lines a removal would leave doubled. */
144
+ export function removeSpans(text, spans) {
145
+ let out = text;
146
+ for (const { start, end } of [...spans].sort((a, b) => b.start - a.start)) {
147
+ let stop = end;
148
+ const before = out.slice(0, start);
149
+ if (start === 0 || /\n[ \t\r]*\n$/.test(before)) {
150
+ const m = /^(?:[ \t\r]*\n)+/.exec(out.slice(stop));
151
+ if (m) stop += m[0].length;
152
+ }
153
+ out = out.slice(0, start) + out.slice(stop);
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /** Remove every statement at or under `prefix` (e.g. `["mcp_servers", "hindsight"]`). */
159
+ export function removeTomlPath(text, prefix) {
160
+ return removeSpans(text, tomlSpansUnder(text, prefix));
161
+ }
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Context management (smart compaction) per agent: `[context_proxy.agents]` in config.toml (a missing key
4
+ * follows `[context_proxy] enabled`; with neither set, opencode2 is on and the others off), the agent wiring (install-coding-agent) and the compose
5
+ * `context-proxy` service. Shared by the setup wizard step, install-coding-agent and `openmem context`.
6
+ */
7
+ import { spawnSync } from "node:child_process";
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { contextRoot } from "./coding-agent/context-store.mjs";
12
+ import { proxyCodeVersion, summarizeEvents } from "./coding-agent/session-db.mjs";
13
+ import { agentsPointedAtContextProxy, ensureContextProxyKey, probeContextProxy } from "./coding-agent/install-context-proxy.mjs";
14
+ import { isMainModule } from "./is-main.mjs";
15
+ import { resolveStackRoot } from "./stack.mjs";
16
+ import {
17
+ CONTEXT_AGENT_KEYS,
18
+ contextAgentsFromPmConfig,
19
+ contextProxyFromPmConfig,
20
+ loadPmConfig,
21
+ updatePmConfigContextAgents,
22
+ } from "../wizard/lib/pm-config.mjs";
23
+
24
+ /** Compose service and profile name (compose.yaml). */
25
+ export const CONTEXT_PROXY_SERVICE = "context-proxy";
26
+ /**
27
+ * Agents whose "on" needs the proxy service. Codex goes through LiteLLM's ChatGPT-subscription pass-through,
28
+ * which the pinned dev image has (PR #41522; docs/compatibility.md, footnote 9).
29
+ */
30
+ export const SERVICE_AGENT_KEYS = ["claude-code", "opencode1", "codex"];
31
+ /** How each config key is served. */
32
+ export const CONTEXT_AGENT_ROUTE = {
33
+ "claude-code": "context-proxy",
34
+ opencode1: "context-proxy",
35
+ codex: "context-proxy (ChatGPT login, /backend-api/codex)",
36
+ opencode2: "native plugin",
37
+ };
38
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
39
+ const BIN = "openmem context";
40
+
41
+ const HELP = `${BIN}: turn context management (smart compaction) on or off per agent
42
+
43
+ Usage:
44
+ ${BIN} on [--dry-run] [--agents a,b] save [context_proxy.agents] <agent> = true, re-run
45
+ install-coding-agent (starts the context-proxy service
46
+ when a proxy-routed agent is on)
47
+ ${BIN} off [--dry-run] [--agents a,b] save <agent> = false, re-run install-coding-agent (stops
48
+ the service when no proxy-routed agent is on)
49
+ ${BIN} status [--json] per-agent values, proxy health, agents pointed at it, and
50
+ the last 24 h of trims and fail-opens from the session store
51
+
52
+ Agents: ${CONTEXT_AGENT_KEYS.join(", ")} (default: all). claude-code, opencode1 and codex go through
53
+ the context-proxy service (codex keeps its ChatGPT login); opencode2 trims in its own plugin (on when
54
+ config sets neither its key nor enabled).
55
+ Docs: docs/context-management.md`;
56
+
57
+ /** True when any agent that needs the service resolves on. */
58
+ export function proxyServiceNeeded(agents) {
59
+ return SERVICE_AGENT_KEYS.some((k) => agents[k]);
60
+ }
61
+
62
+ /**
63
+ * Key file and store directories the compose service needs before `up`
64
+ * (env_file, and a bind mount with create_host_path: false).
65
+ */
66
+ export function prepareContextProxyFiles(home, env = process.env) {
67
+ ensureContextProxyKey(home);
68
+ for (const sub of ["sessions", "blobs"]) fs.mkdirSync(path.join(contextRoot(home, env), sub), { recursive: true, mode: 0o700 });
69
+ }
70
+
71
+ /**
72
+ * True when compose reports a container for the service.
73
+ * @param {string[]} baseArgs `docker` arguments up to (not including) the compose subcommand
74
+ * @param {{ cwd?: string, env?: Record<string, string | undefined> }} [opts]
75
+ * @param {typeof spawnSync} [spawn]
76
+ */
77
+ export function contextProxyRunning(baseArgs, opts = {}, spawn = spawnSync) {
78
+ const r = spawn("docker", [...baseArgs, "ps", "-q", CONTEXT_PROXY_SERVICE], { encoding: "utf8", ...opts });
79
+ return r.status === 0 && String(r.stdout || "").trim() !== "";
80
+ }
81
+
82
+ /** `docker` arguments for compose calls on the context-proxy profile from the stack root. */
83
+ export function contextComposeArgs({ project, stackRoot }) {
84
+ return ["compose", "-p", project, ...(stackRoot ? ["--project-directory", stackRoot] : []), "--profile", CONTEXT_PROXY_SERVICE];
85
+ }
86
+
87
+ /**
88
+ * Start the service (`on`) or stop it when it runs and no agent points at it (`!on`). Skipped when the
89
+ * stack root has no compose.yaml. Returns what happened.
90
+ * @param {{ home: string, stackRoot?: string, project: string, on: boolean, dry?: boolean, env?: NodeJS.ProcessEnv,
91
+ * spawn?: typeof spawnSync, log: { info: (m: string) => void, warn: (m: string) => void } }} opts
92
+ * @returns {"started"|"stopped"|"kept"|"not-running"|"no-stack"|"failed"|"dry-run"}
93
+ */
94
+ export function ensureContextProxyService({ home, stackRoot, project, on, dry = false, env = process.env, spawn = spawnSync, log }) {
95
+ if (!stackRoot || !fs.existsSync(path.join(stackRoot, "compose.yaml"))) return "no-stack";
96
+ const base = contextComposeArgs({ project, stackRoot });
97
+ const opts = { cwd: stackRoot, env: { ...env } };
98
+ const run = (args) => {
99
+ const text = `docker ${args.join(" ")}`;
100
+ if (dry) {
101
+ log.info(`[dry-run] ${text}`);
102
+ return 0;
103
+ }
104
+ log.info(text);
105
+ return spawn("docker", args, { stdio: "inherit", ...opts }).status ?? 1;
106
+ };
107
+ if (on) {
108
+ if (!dry) prepareContextProxyFiles(home, env);
109
+ return run([...base, "up", "-d", CONTEXT_PROXY_SERVICE]) === 0 ? (dry ? "dry-run" : "started") : "failed";
110
+ }
111
+ if (!contextProxyRunning(base, opts, spawn)) return "not-running";
112
+ const pointed = dry ? [] : agentsPointedAtContextProxy(home);
113
+ if (pointed.length) {
114
+ log.warn(`context-proxy left running: ${pointed.join(", ")} still point at it`);
115
+ return "kept";
116
+ }
117
+ return run([...base, "stop", CONTEXT_PROXY_SERVICE]) === 0 ? (dry ? "dry-run" : "stopped") : "failed";
118
+ }
119
+
120
+ /** Proxy code version of the checkout the compose service mounts (stack root, else this package), or null. */
121
+ function checkoutProxyVersion(stackRoot) {
122
+ for (const root of [stackRoot, PKG_ROOT].filter(Boolean)) {
123
+ try { return proxyCodeVersion(path.join(root, "proxy", "openmem_context")); } catch { /* not there */ }
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Current state: per-agent values, port, whether the proxy answers, which agents point at it, and the
130
+ * session store's recent events (summarizeEvents) with the checkout's proxy code version to compare.
131
+ * @param {{ home: string, stackRoot?: string, fetch?: typeof fetch, env?: NodeJS.ProcessEnv, now?: number }} opts
132
+ */
133
+ export async function contextStatus({ home, stackRoot, fetch, env = process.env, now }) {
134
+ const cfg = loadPmConfig(home, stackRoot || home).merged;
135
+ const { port } = contextProxyFromPmConfig(cfg);
136
+ const { agents, saved, enabledSaved } = contextAgentsFromPmConfig(cfg);
137
+ return {
138
+ enabled: contextProxyFromPmConfig(cfg).enabled,
139
+ enabledSaved,
140
+ agents: Object.fromEntries(CONTEXT_AGENT_KEYS.map((k) => [k, { on: agents[k], saved: saved.includes(k), route: CONTEXT_AGENT_ROUTE[k] }])),
141
+ port,
142
+ proxyUp: await probeContextProxy(port, fetch ? { fetch } : {}),
143
+ pointedAgents: agentsPointedAtContextProxy(home),
144
+ events: summarizeEvents(path.join(contextRoot(home, env), "sessions"), now === undefined ? {} : { now }),
145
+ checkoutProxyVersion: checkoutProxyVersion(stackRoot),
146
+ };
147
+ }
148
+
149
+ /** Text lines for the events part of `status`. */
150
+ export function eventLines(s) {
151
+ const e = s.events;
152
+ const lines = [`last ${e.hours} h (${e.sessions} session${e.sessions === 1 ? "" : "s"}): ${e.trims} trim${e.trims === 1 ? "" : "s"}`
153
+ + (e.trims ? `, ~${e.tokensSaved} tokens saved (estimated ${e.tokensBefore} -> ${e.tokensAfter}), ${e.dropped} messages dropped` : "")
154
+ + `, ${e.failOpens} fail-open${e.failOpens === 1 ? "" : "s"} (request sent untrimmed)`];
155
+ for (const x of e.lastErrors) lines.push(` ${x.at} ${x.error} at ${x.location} (${x.version}, session ${x.session})`);
156
+ lines.push(...usageLines(e.usage));
157
+ const running = e.latest.proxy;
158
+ if (running && s.checkoutProxyVersion) {
159
+ lines.push(`proxy code: ${running === s.checkoutProxyVersion ? `${running}, matches the checkout`
160
+ : `${running} in the last event, checkout is ${s.checkoutProxyVersion}; restart the context-proxy service to load it`}`);
161
+ }
162
+ lines.push("not recorded here: fail-opens before the session id is known or from the session database itself (proxy stderr only)");
163
+ return lines;
164
+ }
165
+
166
+ /** Prompt-cache lines from summarizeEvents' `usage`; none when no usage event was recorded. */
167
+ function usageLines(u) {
168
+ if (!u?.requests) return [];
169
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
170
+ const ratio = u.hitRatio === null ? "n/a" : `${(u.hitRatio * 100).toFixed(1)}%`;
171
+ const lines = [`prompt cache: ${plural(u.requests, "request")} (${u.withCacheCounts} with cache counts), hit ratio ${ratio}`
172
+ + ` (read ${u.cacheReadTokens} / read + creation ${u.cacheCreationTokens} + uncached ${u.inputTokens})`];
173
+ const avg = (g) => (g.withCacheCounts ? Math.round(g.cacheCreationTokens / g.withCacheCounts) : "n/a");
174
+ lines.push(` cache creation: ${u.newCut.cacheCreationTokens} on ${plural(u.newCut.requests, "request")} making a new cut`
175
+ + ` (avg ${avg(u.newCut)}), ${u.other.cacheCreationTokens} on ${plural(u.other.requests, "other")} (avg ${avg(u.other)})`);
176
+ const counts = Object.entries(u.breakpoints).map(([n, k]) => `${n}: ${k}`).join(", ");
177
+ if (counts) {
178
+ lines.push(` cache_control breakpoints per request: ${counts}; top layouts `
179
+ + u.breakpointLayouts.map((x) => `${x.at} (${x.requests})`).join(", "));
180
+ }
181
+ return lines;
182
+ }
183
+
184
+ function parseArgs(argv) {
185
+ const flags = { action: argv[0], dry: false, json: false, agents: [], help: false, unknown: [] };
186
+ for (let i = 1; i < argv.length; i++) {
187
+ const a = argv[i];
188
+ if (a === "--dry-run") flags.dry = true;
189
+ else if (a === "--json") flags.json = true;
190
+ else if (a === "--agents") flags.agents = String(argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean);
191
+ else if (a === "--help" || a === "-h") flags.help = true;
192
+ else flags.unknown.push(a);
193
+ }
194
+ if (["--help", "-h", "help"].includes(flags.action)) flags.help = true;
195
+ return flags;
196
+ }
197
+
198
+ /**
199
+ * `openmem context on|off|status`. Returns the exit code.
200
+ * @param {{ argv: string[], home: string, stackRoot: string, env?: NodeJS.ProcessEnv,
201
+ * log?: { info: (m: string) => void, warn: (m: string) => void },
202
+ * spawn?: typeof spawnSync, fetch?: typeof fetch }} opts
203
+ */
204
+ export async function runContextCommand(opts) {
205
+ const { home, stackRoot } = opts;
206
+ const env = opts.env || process.env;
207
+ const log = opts.log || { info: (m) => console.log(m), warn: (m) => console.warn(m) };
208
+ const spawn = opts.spawn || spawnSync;
209
+ const flags = parseArgs(opts.argv);
210
+ if (flags.help) {
211
+ log.info(HELP);
212
+ return 0;
213
+ }
214
+ const badAgents = flags.agents.filter((a) => !CONTEXT_AGENT_KEYS.includes(a));
215
+ if (!["on", "off", "status"].includes(flags.action) || flags.unknown.length || badAgents.length) {
216
+ if (badAgents.length) log.warn(`unknown agent: ${badAgents.join(", ")} (known: ${CONTEXT_AGENT_KEYS.join(", ")})`);
217
+ log.warn(HELP);
218
+ return 2;
219
+ }
220
+ if (flags.action === "status") {
221
+ const s = await contextStatus({ home, stackRoot, fetch: opts.fetch, env });
222
+ if (flags.json) log.info(JSON.stringify(s, null, 2));
223
+ else {
224
+ for (const [k, a] of Object.entries(s.agents)) {
225
+ log.info(`${k.padEnd(12)} ${a.on ? "on " : "off"} ${a.saved ? "saved" : s.enabledSaved ? `follows enabled = ${s.enabled}` : "default"} (${a.route})`);
226
+ }
227
+ log.info(`context proxy http://127.0.0.1:${s.port}: ${s.proxyUp ? "up" : "not answering"}`);
228
+ log.info(`agents pointed at the proxy: ${s.pointedAgents.length ? s.pointedAgents.join(", ") : "none"}`);
229
+ for (const line of eventLines(s)) log.info(line);
230
+ }
231
+ return 0;
232
+ }
233
+ const on = flags.action === "on";
234
+ const keys = flags.agents.length ? flags.agents : CONTEXT_AGENT_KEYS;
235
+ const saved = updatePmConfigContextAgents(home, Object.fromEntries(keys.map((k) => [k, on])), { dry: flags.dry });
236
+ const summary = keys.map((k) => `${k} = ${on}`).join(", ");
237
+ log.info(`${saved.result === "dry-run" ? "[dry-run] would save" : "saved"} [context_proxy.agents] ${summary}`);
238
+ // install-coding-agent follows the saved values: it wires agents and starts or stops the service.
239
+ const args = [path.join(PKG_ROOT, "cli", "install-coding-agent.mjs"), "--yes"];
240
+ if (flags.dry) args.push("--dry-run");
241
+ return spawn(process.execPath, args, { stdio: "inherit", env }).status ?? 1;
242
+ }
243
+
244
+ if (isMainModule(import.meta.url)) {
245
+ const home = process.env.HOME || "";
246
+ const sr = resolveStackRoot({ pkgRoot: PKG_ROOT });
247
+ runContextCommand({ argv: process.argv.slice(2), home, stackRoot: sr.root }).then(
248
+ (code) => process.exit(code),
249
+ (err) => {
250
+ console.error(err);
251
+ process.exit(1);
252
+ },
253
+ );
254
+ }
package/cli/feed-sync.mjs CHANGED
@@ -3,9 +3,25 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { isMainModule } from "./is-main.mjs";
6
7
 
7
8
  const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
8
9
  const script = path.join(PKG_ROOT, "indexer", "feed_sync.py");
9
10
 
10
- const r = spawnSync("python3", [script, ...process.argv.slice(2)], { stdio: "inherit", cwd: PKG_ROOT });
11
- process.exit(r.status ?? 1);
11
+ export function pythonCandidates(platform = process.platform, env = process.env) {
12
+ if (env.PYTHON) return [[env.PYTHON, []]];
13
+ return platform === "win32" ? [["py", ["-3"]], ["python", []]] : [["python3", []], ["python", []]];
14
+ }
15
+
16
+ if (isMainModule(import.meta.url)) {
17
+ let missing = true;
18
+ for (const [command, prefix] of pythonCandidates()) {
19
+ const result = spawnSync(command, [...prefix, script, ...process.argv.slice(2)], { stdio: "inherit", cwd: PKG_ROOT });
20
+ if (result.error?.code === "ENOENT") continue;
21
+ missing = false;
22
+ if (result.error) console.error(`feed-sync: ${result.error.message}`);
23
+ process.exit(result.status ?? 1);
24
+ }
25
+ if (missing) console.error("feed-sync: Python 3 not found; install Python 3 with PyYAML or set PYTHON to its executable");
26
+ process.exit(1);
27
+ }