freeharness 0.4.1 → 0.4.2

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 (194) hide show
  1. package/README.md +8 -7
  2. package/canonical/HARVEST.yaml +25 -14
  3. package/canonical/PROVENANCE.yaml +1 -1
  4. package/canonical/agents/README.md +77 -36
  5. package/canonical/agents/agentic-architect.md +2 -1
  6. package/canonical/agents/database-reviewer.md +6 -8
  7. package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
  8. package/canonical/agents/implementer.md +30 -0
  9. package/canonical/agents/pr-reviewer.md +9 -11
  10. package/canonical/agents/python-reviewer.md +7 -9
  11. package/canonical/agents/report-formatter.md +7 -3
  12. package/canonical/agents/security-reviewer.md +7 -8
  13. package/canonical/agents/test-writer.md +5 -2
  14. package/canonical/hooks/cost-prices.json +11 -9
  15. package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
  16. package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
  17. package/canonical/hooks/freeharness-converge.mjs +78 -23
  18. package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
  19. package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
  20. package/canonical/hooks/freeharness-precompact.mjs +217 -87
  21. package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
  22. package/canonical/hooks/freeharness-reload-context.mjs +197 -0
  23. package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
  24. package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
  25. package/canonical/hooks/freeharness-session-start +297 -59
  26. package/canonical/hooks/hooks.claude-code.json +1 -1
  27. package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
  28. package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
  29. package/canonical/hooks/hooks.converge.claude-code.json +1 -1
  30. package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
  31. package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
  32. package/canonical/openspec/schema.spec-driven.yaml +29 -8
  33. package/canonical/openspec/templates/follow-ups.md +3 -2
  34. package/canonical/openspec/templates/proposal.md +1 -1
  35. package/canonical/openspec/templates/tasks.md +8 -0
  36. package/canonical/rules/CLAUDE.md +5 -4
  37. package/canonical/rules/routing.md +10 -9
  38. package/canonical/skills/agent-evaluation/SKILL.md +17 -6
  39. package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
  40. package/canonical/skills/agentic-architecture/SKILL.md +4 -0
  41. package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
  42. package/canonical/skills/code-review/SKILL.md +9 -2
  43. package/canonical/skills/code-review/evals/cases.yaml +1118 -27
  44. package/canonical/skills/git-workflow/SKILL.md +2 -0
  45. package/canonical/skills/local-code/SKILL.md +2 -2
  46. package/canonical/skills/local-code/evals/cases.yaml +1 -1
  47. package/canonical/skills/report-formatting/SKILL.md +10 -2
  48. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
  49. package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
  50. package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
  51. package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
  52. package/canonical/skills/report-formatting/reference/authoring.md +25 -37
  53. package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
  54. package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
  55. package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
  56. package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
  57. package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
  58. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
  59. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
  60. package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
  61. package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
  62. package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
  63. package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
  64. package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
  65. package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
  66. package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
  67. package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
  68. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
  69. package/dist/adapters/harness/claude-code.d.ts +19 -3
  70. package/dist/adapters/harness/claude-code.js +204 -39
  71. package/dist/adapters/harness/opencode.js +16 -2
  72. package/dist/adapters/harness/plugin.js +28 -17
  73. package/dist/adapters/memory/doctor.d.ts +174 -29
  74. package/dist/adapters/memory/doctor.js +758 -134
  75. package/dist/adapters/memory/migrate.d.ts +95 -0
  76. package/dist/adapters/memory/migrate.js +497 -0
  77. package/dist/adapters/memory/paths.d.ts +22 -0
  78. package/dist/adapters/memory/paths.js +85 -0
  79. package/dist/adapters/memory/vendored.d.ts +56 -29
  80. package/dist/adapters/memory/vendored.js +191 -154
  81. package/dist/adapters/sync/diff.js +4 -1
  82. package/dist/cli/cleanup-prompt.d.ts +35 -0
  83. package/dist/cli/cleanup-prompt.js +68 -0
  84. package/dist/cli/cleanup.d.ts +22 -24
  85. package/dist/cli/cleanup.js +182 -68
  86. package/dist/cli/cost-quota.d.ts +158 -0
  87. package/dist/cli/cost-quota.js +645 -0
  88. package/dist/cli/cost.d.ts +408 -46
  89. package/dist/cli/cost.js +1731 -57
  90. package/dist/cli/doctor.d.ts +52 -13
  91. package/dist/cli/doctor.js +193 -44
  92. package/dist/cli/hard-cleanup.d.ts +93 -0
  93. package/dist/cli/hard-cleanup.js +552 -0
  94. package/dist/cli/install-facts.d.ts +28 -0
  95. package/dist/cli/install-facts.js +66 -0
  96. package/dist/cli/json-key-edit.d.ts +18 -0
  97. package/dist/cli/json-key-edit.js +118 -0
  98. package/dist/cli/memory-import.d.ts +74 -0
  99. package/dist/cli/memory-import.js +469 -0
  100. package/dist/cli/plugin-validate.d.ts +12 -0
  101. package/dist/cli/plugin-validate.js +43 -0
  102. package/dist/cli/uninstall.d.ts +1 -1
  103. package/dist/cli/uninstall.js +2 -2
  104. package/dist/cli/upstream-launchers.d.ts +49 -0
  105. package/dist/cli/upstream-launchers.js +287 -0
  106. package/dist/index.js +247 -70
  107. package/dist/infra/fsutil.d.ts +31 -3
  108. package/dist/infra/fsutil.js +69 -6
  109. package/dist/infra/manifest.d.ts +2 -1
  110. package/dist/infra/manifest.js +3 -2
  111. package/dist/infra/prices.d.ts +18 -0
  112. package/dist/infra/prices.js +64 -0
  113. package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
  114. package/node_modules/@freeharness/core/dist/tiers.js +24 -2
  115. package/node_modules/@freeharness/core/package.json +1 -1
  116. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
  117. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
  118. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
  119. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
  120. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
  121. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
  122. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
  123. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
  124. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
  125. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
  126. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
  127. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
  128. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
  129. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
  130. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
  131. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
  132. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
  133. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
  134. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
  135. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
  136. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
  137. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
  138. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
  139. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
  140. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
  141. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
  142. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
  143. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
  144. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
  145. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
  146. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
  147. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
  148. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
  149. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
  150. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
  151. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
  152. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
  153. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
  154. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
  155. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
  156. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
  157. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
  158. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
  159. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
  160. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
  161. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
  162. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
  163. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
  164. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
  165. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
  166. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
  167. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
  168. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
  169. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
  170. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
  171. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
  172. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
  173. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
  174. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
  175. package/node_modules/@freeharness/sdd/package.json +1 -1
  176. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
  177. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
  178. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
  179. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
  180. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
  181. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
  182. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
  183. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
  184. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
  185. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
  186. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
  187. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
  188. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
  189. package/package.json +6 -4
  190. package/provenance/canonical.PROVENANCE.yaml +1 -1
  191. package/provenance/memory.PROVENANCE.yaml +458 -6
  192. package/provenance/sdd.PROVENANCE.yaml +279 -2
  193. package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
  194. package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
@@ -6,20 +6,36 @@
6
6
  // carries the `.install-version` marker written by VendoredMemory and ships exactly
7
7
  // one skill (mem-search); an upstream install ships many (cloud-sync, babysit, ...).
8
8
  // - installed_plugins.json — the installPath Claude Code loads hooks/skills from.
9
- // - the running worker — /api/health reports its version; GET / answers 200 with the
10
- // React viewer on upstream and, on our build, 200 with the FreeHarness viewer page
11
- // (marker "FreeHarness memory", FIXUP section 8) or 404 on a pre-viewer build.
12
- // - the data dir (~/.claude-mem or CLAUDE_MEM_DATA_DIR): claude-mem.db + chroma, plus the
9
+ // - the running worker — /api/health reports its version and, on our build, the product
10
+ // marker "free-mem"; an unmarked answer is an older build of ours only when the free-mem
11
+ // PID file names its pid and port. GET / proves nothing: upstream also answers 404 there.
12
+ // - the data dir (resolveMemoryPaths in ./paths.ts): the database (a leftover chroma/
13
+ // directory is only sized and reported, never touched), plus the
13
14
  // observer ledgers written by the ported 13.24 engine (observer-health.json,
14
15
  // quota-cooldown.json) that `memory status` renders as rejections / cooldowns.
15
16
  import fs from "node:fs";
16
17
  import os from "node:os";
17
18
  import path from "node:path";
18
19
  import { spawn, spawnSync } from "node:child_process";
19
- import { MARKETPLACE_OWNER, PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_NAME } from "./vendored.js";
20
+ import { MARKETPLACE_OWNER, PLUGIN_KEY, PLUGIN_NAME, UPSTREAM_MARKETPLACE_OWNER, UPSTREAM_PLUGIN_KEY, UPSTREAM_PLUGIN_NAME, claudePluginPaths, isUpstreamRegistered, declaresPluginDeps, installPluginDeps, missingPluginBundles, missingPluginDeps, pluginVersion, probeBun, stagePluginCache, writeInstallMarker, } from "./vendored.js";
21
+ import { FREE_DIR_NAME, LEGACY_DB_FILENAME, LEGACY_DIR_NAME, readMemorySettings, resolveMemoryPaths } from "./paths.js";
22
+ import { pidFileLive } from "./migrate.js";
20
23
  import { resolveCanonicalDir, sourceRepoRoot } from "../../infra/paths.js";
21
- /** Marker in the FreeHarness viewer page served on GET / (plugin/ui/viewer.html). */
22
- export const VIEWER_MARKER = "FreeHarness memory";
24
+ const OLD_PREFIX = "CLAUDE_MEM_";
25
+ const NEW_PREFIX = "FREE_MEM_";
26
+ function legacyName(key) {
27
+ return OLD_PREFIX + key.slice(NEW_PREFIX.length);
28
+ }
29
+ /** A FREE_MEM_ setting, else its CLAUDE_MEM_ twin: the engine reads an unrenamed file the same way. */
30
+ function setting(values, key) {
31
+ return values[key] ?? values[legacyName(key)];
32
+ }
33
+ /** Like `setting`, but an empty value counts as unset, as the engine's envGet reads the environment. */
34
+ function envSetting(env, key) {
35
+ return env[key] || env[legacyName(key)] || undefined;
36
+ }
37
+ /** The /api/health product marker of our worker; a test pins it to the engine's and the hooks' copies. */
38
+ export const FREE_MEM_PRODUCT = "free-mem";
23
39
  /** Mirror of engine shared/observer-health.ts: warn only after this many consecutive failures. */
24
40
  export const OBSERVER_UNHEALTHY_FAILURE_THRESHOLD = 3;
25
41
  /** Mirror of engine shared/quota-cooldown.ts: how long requests are withheld after a quota refusal. */
@@ -178,25 +194,64 @@ export function observerLines(s, now = Date.now()) {
178
194
  }
179
195
  /** Highest schema_versions row our vendored SessionStore applies (13.24.0 port; the upstream v50 observed_model/observed_billing migration is telemetry-only and not ported). */
180
196
  export const OUR_SCHEMA_VERSION = 49;
197
+ /** Versions at or above this belong to FreeHarness migrations, so they must not be compared with upstream's counter. */
198
+ const FREEHARNESS_SCHEMA_BASE = 9000;
199
+ /** Every FreeHarness migration the vendored SessionStore records; a store holding some but not all of them did not finish migrating. */
200
+ const FREEHARNESS_SCHEMA_VERSIONS = [9001, 9002, 9003, 9004, 9005, 9006, 9007];
181
201
  /**
182
- * Read max(version) from schema_versions via the sqlite3 CLI (no node sqlite dep).
202
+ * Read the applied schema_versions via the sqlite3 CLI (no node sqlite dep), read-only.
183
203
  *
184
204
  * Returns:
185
- * The version, or null when the db/sqlite3 is unavailable.
205
+ * The versions, or null when the db/sqlite3 is unavailable.
186
206
  */
187
- function dbSchemaVersion(dbPath) {
207
+ function dbSchemaVersions(dbPath) {
188
208
  if (!fs.existsSync(dbPath))
189
209
  return null;
190
- const r = spawnSync("sqlite3", ["-readonly", dbPath, "SELECT MAX(version) FROM schema_versions;"], { encoding: "utf8" });
210
+ const r = spawnSync("sqlite3", ["-readonly", dbPath, "SELECT version FROM schema_versions;"], { encoding: "utf8" });
191
211
  if (r.status !== 0)
192
212
  return null;
193
- const n = parseInt(r.stdout.trim(), 10);
194
- return Number.isFinite(n) ? n : null;
213
+ const versions = r.stdout.split("\n").map((l) => parseInt(l.trim(), 10)).filter((n) => Number.isFinite(n));
214
+ return versions.length > 0 ? versions : null;
215
+ }
216
+ /**
217
+ * Build the schema status line. Upstream and FreeHarness versions are split at 9000 so a
218
+ * FreeHarness migration is never reported as a newer upstream engine. A store that records
219
+ * some FreeHarness versions but not all of them has not finished migrating (a migration
220
+ * rolled back, or the worker predates the update), so the line warns and names each missing
221
+ * version; a store with none has never been opened
222
+ * by a FreeHarness worker and keeps the upstream verdict.
223
+ *
224
+ * Returns:
225
+ * The line, or null when the db/sqlite3 is unavailable.
226
+ */
227
+ export function schemaVersionLine(dbPath, dbName) {
228
+ const versions = dbSchemaVersions(dbPath);
229
+ if (versions === null)
230
+ return null;
231
+ const upstream = versions.filter((v) => v < FREEHARNESS_SCHEMA_BASE);
232
+ const own = [...new Set(versions.filter((v) => v >= FREEHARNESS_SCHEMA_BASE))].sort((a, b) => a - b);
233
+ const schema = upstream.length > 0 ? Math.max(...upstream) : 0;
234
+ const label = `schema v${schema}${own.length > 0 ? ` + FreeHarness ${own.join(", ")}` : ""}`;
235
+ const newer = `schema v${schema} is NEWER than our engine (v${OUR_SCHEMA_VERSION})`;
236
+ const missing = own.length > 0 ? FREEHARNESS_SCHEMA_VERSIONS.filter((v) => !own.includes(v)) : [];
237
+ if (missing.length > 0) {
238
+ const upstreamNote = schema > OUR_SCHEMA_VERSION && schema !== 50 ? `; upstream ${newer}` : "";
239
+ return `WARN ${dbName} ${label}: FreeHarness migration ${missing.join(", ")} missing — a failed or partial migration, or a worker not restarted since the update; restart the worker so it retries, and read the worker log if this line stays${upstreamNote}`;
240
+ }
241
+ if (schema <= OUR_SCHEMA_VERSION)
242
+ return `OK ${dbName} ${label} (our engine knows up to v${OUR_SCHEMA_VERSION})`;
243
+ if (schema === 50)
244
+ return `OK ${dbName} ${label} (upstream 13.2x; v50 only adds nullable sdk_sessions columns our engine v${OUR_SCHEMA_VERSION} ignores)`;
245
+ return `WARN ${dbName} ${newer} — migrated by an upstream build; migrations are additive so it usually works, but keep the backup and watch the first worker start`;
195
246
  }
247
+ /** Not yet migrated: the cost-tracker hook's `observerSink` tolerates the same error. */
248
+ const MISSING_CONSOLIDATION_TABLE = /no such table: (?:main\.)?consolidation_(?:calls|jobs)\b/;
196
249
  /**
197
250
  * Reads observer spend (observation count and summed `discovery_tokens`) grouped by day
198
251
  * window and `generated_by_model`, through `sqlite3 -readonly` (same invocation shape as
199
- * `dbSchemaVersion`). The seven-day window includes today, matching the running total an
252
+ * `dbSchemaVersions`), plus each model's `consolidation_calls` tokens: consolidation
253
+ * observations carry `discovery_tokens` 0, and the cost-tracker hook's `observerSink` counts
254
+ * the same rows. The seven-day window includes today, matching the running total an
200
255
  * owner reads off `memory status` (not "the other six days").
201
256
  *
202
257
  * Args:
@@ -210,28 +265,220 @@ function dbSchemaVersion(dbPath) {
210
265
  export function observerSpend(dbPath, spawn = spawnSync) {
211
266
  if (!fs.existsSync(dbPath))
212
267
  return [];
268
+ // sqlite3 prints a statement's rows before preparing the next, so a missing table in the
269
+ // last statement keeps the observation rows.
213
270
  const query = [
214
271
  "SELECT 'today', generated_by_model, COUNT(*), COALESCE(SUM(discovery_tokens),0)",
215
272
  "FROM observations WHERE date(created_at) = date('now') GROUP BY generated_by_model",
216
273
  "UNION ALL",
217
274
  "SELECT 'seven_day', generated_by_model, COUNT(*), COALESCE(SUM(discovery_tokens),0)",
218
275
  "FROM observations WHERE created_at >= datetime('now', '-7 days') GROUP BY generated_by_model;",
276
+ "SELECT 'today', model, 0, COALESCE(SUM(tokens),0)",
277
+ "FROM consolidation_calls WHERE date(settled_at_epoch / 1000, 'unixepoch') = date('now') GROUP BY model",
278
+ "UNION ALL",
279
+ "SELECT 'seven_day', model, 0, COALESCE(SUM(tokens),0)",
280
+ "FROM consolidation_calls WHERE settled_at_epoch >= CAST(strftime('%s', 'now', '-7 days') AS INTEGER) * 1000 GROUP BY model;",
219
281
  ].join(" ");
220
282
  const r = spawn("sqlite3", ["-readonly", dbPath, query], { encoding: "utf8" });
221
283
  if (r.error)
222
284
  return { reason: r.error.message || "sqlite3 not found on PATH", missingBinary: true };
223
- if (r.status !== 0)
285
+ if (r.status !== 0 && !MISSING_CONSOLIDATION_TABLE.test(r.stderr || "")) {
224
286
  return { reason: (r.stderr || "").trim().split("\n")[0] || `sqlite3 exited ${r.status}`, missingBinary: false };
225
- const rows = [];
226
- for (const line of r.stdout.split("\n")) {
287
+ }
288
+ const rows = new Map();
289
+ for (const line of (r.stdout || "").split("\n")) {
227
290
  if (!line.trim())
228
291
  continue;
229
- const [window, model, observations, tokens] = line.split("|");
292
+ const [window, rawModel, observations, tokens] = line.split("|");
230
293
  if (window !== "today" && window !== "seven_day")
231
294
  continue;
232
- rows.push({ window, model: model || "(unknown)", observations: parseInt(observations, 10) || 0, tokens: parseInt(tokens, 10) || 0 });
295
+ const model = rawModel || "(unknown)";
296
+ const row = rows.get(`${window}|${model}`) ?? { window, model, observations: 0, tokens: 0 };
297
+ row.observations += parseInt(observations, 10) || 0;
298
+ row.tokens += parseInt(tokens, 10) || 0;
299
+ rows.set(`${window}|${model}`, row);
300
+ }
301
+ return [...rows.values()];
302
+ }
303
+ /** The engine's spool file name; temp files, `bad/` and the counters do not match. */
304
+ const SPOOL_FILE = /^\d{1,16}-[0-9a-f]{64}\.json$/;
305
+ function spoolCounts(dataDir) {
306
+ const dir = path.join(dataDir, "spool");
307
+ let names;
308
+ try {
309
+ names = fs.readdirSync(dir);
310
+ }
311
+ catch {
312
+ return null;
313
+ }
314
+ let dropped = 0;
315
+ try {
316
+ const value = parseInt(fs.readFileSync(path.join(dir, ".dropped"), "utf8").trim(), 10);
317
+ if (Number.isFinite(value) && value >= 0)
318
+ dropped = value;
319
+ }
320
+ catch {
321
+ // No drops recorded yet.
322
+ }
323
+ return { queued: names.filter((n) => SPOOL_FILE.test(n)).length, dropped };
324
+ }
325
+ function readOnlyRows(dbPath, sql, spawn) {
326
+ if (!fs.existsSync(dbPath))
327
+ return null;
328
+ const r = spawn("sqlite3", ["-readonly", dbPath, sql], { encoding: "utf8" });
329
+ if (r.error || r.status !== 0)
330
+ return null;
331
+ return String(r.stdout || "").split("\n").filter((l) => l.trim()).map((l) => l.split("|"));
332
+ }
333
+ /** The lexical fused search streams, in the order explain lists them. */
334
+ const LEXICAL_STREAMS = "phrase, terms, session, files, tag";
335
+ /** The default FREE_MEM_EMBED_MAX_DISTANCE, measured on all-minilm only. */
336
+ const DEFAULT_EMBED_MAX_DISTANCE = 1;
337
+ const CALIBRATED_EMBED_MODEL = "all-minilm";
338
+ const grouped = (n) => n.toLocaleString("en-US");
339
+ /**
340
+ * The streams line with the vector state. The vector stream is listed among the fused
341
+ * streams only in the states where a query can still vote with it (ready, backfilling,
342
+ * degraded); off and unreachable fuse the lexical streams alone.
343
+ */
344
+ function searchLine(e) {
345
+ const line = (vector, state) => `INFO search: fused (${LEXICAL_STREAMS}${vector ? ", vector" : ""}); vector ${state}`;
346
+ if (!e)
347
+ return line(false, "n/a (worker not answering)");
348
+ const floor = e.model === null ? "" : `, model ${e.model}, floor ${e.maxDistance.toFixed(2)}`;
349
+ switch (e.state) {
350
+ case "ready":
351
+ return line(true, `ready ${grouped(e.embedded)}/${grouped(e.total)}${floor}`);
352
+ case "backfilling":
353
+ return line(true, `backfilling ${grouped(e.embedded)}/${grouped(e.total)}${floor}`);
354
+ case "degraded":
355
+ return line(true, `degraded, ${grouped(e.failed)} failed of ${grouped(e.total)}${floor}`);
356
+ case "unreachable":
357
+ return line(false, `unreachable (${e.lastErrorClass ?? "unknown"})${e.model === null ? "" : `, model ${e.model}`}`);
358
+ default:
359
+ return line(false, `off (${e.reason ?? "unset"})`);
360
+ }
361
+ }
362
+ /** A refused base URL, a model on a floor measured for another model, and unused model lineages. */
363
+ function embeddingsLines(e) {
364
+ if (!e)
365
+ return [];
366
+ const out = [];
367
+ if (e.reason === "refused") {
368
+ out.push("WARN memory embeddings: base URL refused (non-loopback, userinfo, the worker port or an uncovered proxy variable); vectors off");
369
+ }
370
+ if (e.model !== null && e.model !== CALIBRATED_EMBED_MODEL && e.maxDistance === DEFAULT_EMBED_MAX_DISTANCE) {
371
+ out.push(`WARN memory embeddings: model ${e.model} uses the default floor 1.00, which is calibrated for ${CALIBRATED_EMBED_MODEL}; set FREE_MEM_EMBED_MAX_DISTANCE from a sweep`);
372
+ }
373
+ for (const other of e.otherModels) {
374
+ out.push(`INFO memory embeddings: ${grouped(other.count)} vectors of model ${other.model} are kept and unused`);
375
+ }
376
+ return out;
377
+ }
378
+ /**
379
+ * Validates the /api/health `embeddings` field; anything malformed counts as absent. The answer
380
+ * may come from another program on the port, so its strings are filtered before they print.
381
+ */
382
+ function parseEmbeddingsHealth(raw) {
383
+ if (!raw || typeof raw !== "object")
384
+ return undefined;
385
+ const r = raw;
386
+ const states = ["off", "ready", "backfilling", "degraded", "unreachable"];
387
+ if (typeof r.state !== "string" || !states.includes(r.state) || (typeof r.model !== "string" && r.model !== null))
388
+ return undefined;
389
+ const num = (v, fallback) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : fallback);
390
+ const otherModels = Array.isArray(r.otherModels)
391
+ ? r.otherModels.flatMap((o) => {
392
+ const m = o;
393
+ return m && typeof m.model === "string" && typeof m.count === "number" ? [{ model: filterForeignText(m.model), count: m.count }] : [];
394
+ })
395
+ : [];
396
+ return {
397
+ state: r.state,
398
+ reason: r.reason === "unset" || r.reason === "refused" ? r.reason : null,
399
+ model: typeof r.model === "string" ? filterForeignText(r.model) : null,
400
+ maxDistance: num(r.maxDistance, DEFAULT_EMBED_MAX_DISTANCE),
401
+ embedded: num(r.embedded, 0),
402
+ failed: num(r.failed, 0),
403
+ total: num(r.total, 0),
404
+ lastErrorClass: typeof r.lastErrorClass === "string" ? filterForeignText(r.lastErrorClass) : null,
405
+ otherModels,
406
+ };
407
+ }
408
+ const MISSING_SIGNAL_TABLE = /no such table: (?:main\.)?(?:gc_runs|observation_feedback|observation_supersedes)\b/;
409
+ /** "2h", "15m", "3d": the parked age on one status line. */
410
+ function shortAge(ms) {
411
+ const minutes = Math.max(0, Math.round(ms / 60_000));
412
+ if (minutes < 60)
413
+ return `${minutes}m`;
414
+ const hours = Math.round(minutes / 60);
415
+ return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`;
416
+ }
417
+ /**
418
+ * The gc and feedback lines from the 9006 tables. A verdict counts only when it is the newest
419
+ * for its observation, as search reads it, so a corrected `wrong` stops counting as wrong.
420
+ */
421
+ function signalLines(dbPath, spawn) {
422
+ if (!fs.existsSync(dbPath))
423
+ return ["INFO gc: n/a", "INFO feedback: n/a"];
424
+ const sql = [
425
+ "SELECT 'gc', outcome, COALESCE(finished_at_epoch, started_at_epoch), superseded FROM gc_runs ORDER BY id DESC LIMIT 1;",
426
+ "SELECT 'verdict', verdict, COUNT(*) FROM observation_feedback f",
427
+ "WHERE id = (SELECT MAX(id) FROM observation_feedback g WHERE g.observation_id = f.observation_id) GROUP BY verdict;",
428
+ "SELECT 'superseded', COUNT(*) FROM observation_supersedes;",
429
+ ].join(" ");
430
+ const r = spawn("sqlite3", ["-readonly", dbPath, sql], { encoding: "utf8" });
431
+ if (!r.error && r.status !== 0 && MISSING_SIGNAL_TABLE.test(String(r.stderr || ""))) {
432
+ return ["INFO gc: tables missing (schema 9006)", "INFO feedback: tables missing (schema 9006)"];
233
433
  }
234
- return rows;
434
+ if (r.error || r.status !== 0)
435
+ return ["INFO gc: n/a", "INFO feedback: n/a"];
436
+ const rows = String(r.stdout || "").split("\n").filter((l) => l.trim()).map((l) => l.split("|"));
437
+ const gc = rows.find((row) => row[0] === "gc");
438
+ const at = gc ? parseInt(gc[2], 10) : NaN;
439
+ const gcLine = gc
440
+ ? `INFO gc: last run ${Number.isFinite(at) ? new Date(at).toISOString() : "?"} ${gc[1]}, superseded ${parseInt(gc[3], 10) || 0}`
441
+ : "INFO gc: no run yet";
442
+ const verdicts = (v) => parseInt(rows.find((row) => row[0] === "verdict" && row[1] === v)?.[2] ?? "0", 10) || 0;
443
+ const superseded = parseInt(rows.find((row) => row[0] === "superseded")?.[1] ?? "0", 10) || 0;
444
+ return [gcLine, `INFO feedback: ${verdicts("wrong")} wrong, ${verdicts("stale")} stale, ${superseded} superseded`];
445
+ }
446
+ function parkedLine(worker) {
447
+ if (!worker.reachable)
448
+ return "INFO parked: worker not answering";
449
+ if (!worker.parked)
450
+ return "INFO parked: n/a (the worker reports no parked count)";
451
+ const { count, oldestAgeMs } = worker.parked;
452
+ const batches = `${count} batch${count === 1 ? "" : "es"}`;
453
+ return count > 0 && oldestAgeMs !== null ? `INFO parked: ${batches}, oldest ${shortAge(oldestAgeMs)}` : `INFO parked: ${batches}`;
454
+ }
455
+ /**
456
+ * The capture pipeline and retrieval health as INFO lines: spool backlog and drops,
457
+ * consolidation jobs by status, the rules tier's share of the last 7 days' observations, the
458
+ * search streams, the last gc run, the feedback counts and the parked batches the worker
459
+ * reports. Anything unreadable prints `n/a` rather than failing the report.
460
+ *
461
+ * Args:
462
+ * worker: The /api/health probe; omitted, the worker counts as not answering.
463
+ */
464
+ export function pipelineLines(dataDir, dbPath, spawn = spawnSync, worker = { reachable: false }) {
465
+ const spool = spoolCounts(dataDir);
466
+ const lines = [spool ? `INFO spool: ${spool.queued} queued, ${spool.dropped} dropped` : "INFO spool: n/a"];
467
+ const jobs = readOnlyRows(dbPath, "SELECT status, COUNT(*) FROM consolidation_jobs GROUP BY status;", spawn);
468
+ if (jobs === null) {
469
+ lines.push("INFO consolidation: n/a");
470
+ }
471
+ else {
472
+ const count = (status) => parseInt(jobs.find((row) => row[0] === status)?.[1] ?? "0", 10) || 0;
473
+ lines.push(`INFO consolidation: done ${count("done")}, failed ${count("failed")}, pending ${count("pending")}`);
474
+ }
475
+ const share = readOnlyRows(dbPath, "SELECT COUNT(*), COALESCE(SUM(generated_by_model = 'rules'),0) FROM observations WHERE created_at >= datetime('now', '-7 days');", spawn);
476
+ const total = share ? parseInt(share[0]?.[0] ?? "0", 10) || 0 : 0;
477
+ const rules = share ? parseInt(share[0]?.[1] ?? "0", 10) || 0 : 0;
478
+ lines.push(total > 0 ? `INFO rules share (7d): ${Math.round((rules * 100) / total)}%` : "INFO rules share (7d): n/a");
479
+ const embeddings = worker.reachable ? worker.embeddings : undefined;
480
+ lines.push(searchLine(embeddings), ...embeddingsLines(embeddings), ...signalLines(dbPath, spawn), parkedLine(worker));
481
+ return lines;
235
482
  }
236
483
  /**
237
484
  * Model ids that name metered Claude spend: the `claude-*` families, a bare tier alias
@@ -341,7 +588,7 @@ function readTail(filePath, maxBytes) {
341
588
  *
342
589
  * Returns:
343
590
  * The day's batch/fallback counts and whether the read was truncated; null when the log file
344
- * does not exist yet (a fresh `CLAUDE_MEM_PROVIDER=claude` machine has never written one);
591
+ * does not exist yet (a fresh `FREE_MEM_PROVIDER=claude` machine has never written one);
345
592
  * the reason when the file is there but could not be read, which is a different state from
346
593
  * an absent log and gets its own printed line.
347
594
  */
@@ -356,7 +603,7 @@ export function readLocalProviderLog(logPath, dayStartMs, nowMs, maxBytes = DEFA
356
603
  return null;
357
604
  return { reason: err && err.message ? err.message : String(e) };
358
605
  }
359
- const summary = { batches: 0, local: 0, fallbacks: {}, skippedLines: 0, truncated: tail.truncated };
606
+ const summary = { batches: 0, local: 0, fallbacks: {}, skippedLines: 0, truncated: tail.truncated, degraded: {}, pausedWindows: {} };
360
607
  for (const rawLine of tail.text.split("\n")) {
361
608
  const line = rawLine.trim();
362
609
  if (!line)
@@ -380,10 +627,20 @@ export function readLocalProviderLog(logPath, dayStartMs, nowMs, maxBytes = DEFA
380
627
  summary.skippedLines++;
381
628
  continue;
382
629
  }
383
- if (attempted !== "local")
384
- continue; // a claude-attempted line: well-formed, not counted here
385
630
  if (tsMs < dayStartMs || tsMs > nowMs)
386
631
  continue; // outside the day: well-formed, not today
632
+ // A degraded batch is tallied by outcome, not by which provider was attempted first: a
633
+ // paused run can hand a batch to either provider and still owe it a terminal line.
634
+ const outcome = e.outcome;
635
+ const isTerminal = outcome === "stored" || outcome === "skipped" || outcome === "dropped";
636
+ if (e.degraded === true && isTerminal) {
637
+ const tier = typeof e.tier === "string" && e.tier.length > 0 ? e.tier : "unknown";
638
+ summary.degraded[tier] = (summary.degraded[tier] ?? 0) + 1;
639
+ const window = typeof e.paused_window === "string" && e.paused_window.length > 0 ? e.paused_window : "unknown";
640
+ summary.pausedWindows[window] = (summary.pausedWindows[window] ?? 0) + 1;
641
+ }
642
+ if (attempted !== "local")
643
+ continue; // a claude-attempted line: well-formed, not counted here
387
644
  summary.batches++;
388
645
  if (e.answered === "local") {
389
646
  summary.local++;
@@ -451,10 +708,12 @@ export function observerDuplicateRate(dbPath, spawn = spawnSync) {
451
708
  }
452
709
  /**
453
710
  * Renders the local-provider log summary and near-duplicate rate as printable INFO lines: the
454
- * batch/fallback line, then the near-duplicate-rate line (omitted when the rate could not be
455
- * read). Nothing at all when `summary` is null (no `local-provider.jsonl` yet); a single WARN
456
- * naming the reason when the log is present but unreadable, so a permission or I/O failure
457
- * never reads like a machine that has not run the local provider.
711
+ * batch/fallback line, the degraded-day line (present only when today has at least one
712
+ * `degraded: true` batch, naming the tier breakdown and the most frequent `paused_window`),
713
+ * then the near-duplicate-rate line (omitted when the rate could not be read). Nothing at all
714
+ * when `summary` is null (no `local-provider.jsonl` yet); a single WARN naming the reason when
715
+ * the log is present but unreadable, so a permission or I/O failure never reads like a machine
716
+ * that has not run the local provider.
458
717
  *
459
718
  * Args:
460
719
  * summary: `readLocalProviderLog`'s return value.
@@ -476,6 +735,22 @@ export function localProviderLines(summary, duplicates) {
476
735
  const out = [
477
736
  `INFO local observer today: ${summary.batches} batches, ${summary.local} local, ${fallbackTotal} fallback${fallbackTotal === 1 ? "" : "s"}${kindText ? ` (${kindText})` : ""}${summary.truncated ? " (log truncated)" : ""}`,
478
737
  ];
738
+ const degradedTotal = Object.values(summary.degraded).reduce((sum, n) => sum + n, 0);
739
+ if (degradedTotal > 0) {
740
+ const tierText = Object.keys(summary.degraded)
741
+ .sort()
742
+ .map((tier) => `${tier} ${summary.degraded[tier]}`)
743
+ .join(", ");
744
+ let window = "unknown";
745
+ let windowCount = -1;
746
+ for (const w of Object.keys(summary.pausedWindows).sort()) {
747
+ if (summary.pausedWindows[w] > windowCount) {
748
+ window = w;
749
+ windowCount = summary.pausedWindows[w];
750
+ }
751
+ }
752
+ out.push(`INFO observer degraded today: ${degradedTotal} batches (${tierText}) while claude paused on ${window}`);
753
+ }
479
754
  if (!("reason" in duplicates)) {
480
755
  const share = (d) => {
481
756
  const pct = d.observations > 0 ? ((d.duplicates / d.observations) * 100).toFixed(1) : "0.0";
@@ -491,7 +766,7 @@ export function isHaikuAlias(model) {
491
766
  }
492
767
  /**
493
768
  * Defensive fallback for `engineDefaultObserverModels` when the vendored engine tree is not a
494
- * sibling on disk (an npx-bridge install with no vendored source next to it). Mirrors
769
+ * sibling on disk (a tree-absent install, such as the npm tarball). Mirrors
495
770
  * SettingsDefaultsManager.ts's current defaults; the parser above is the source of truth when
496
771
  * the tree is present, so a future default change there needs no edit here to take effect on a
497
772
  * checkout — this fallback only covers the tree-absent case.
@@ -547,9 +822,10 @@ export function effectiveObserverModels(settingsPath) {
547
822
  const defaults = engineDefaultObserverModels();
548
823
  const file = readFlatSettings(settingsPath);
549
824
  const str = (v, fallback) => (typeof v === "string" && v.length > 0 ? v : fallback);
550
- const base = str(file.CLAUDE_MEM_MODEL, defaults.base);
551
- const simple = str(file.CLAUDE_MEM_TIER_SIMPLE_MODEL, defaults.simple);
552
- const rawSummary = typeof file.CLAUDE_MEM_TIER_SUMMARY_MODEL === "string" ? file.CLAUDE_MEM_TIER_SUMMARY_MODEL : defaults.summary;
825
+ const base = str(setting(file, "FREE_MEM_MODEL"), defaults.base);
826
+ const simple = str(setting(file, "FREE_MEM_TIER_SIMPLE_MODEL"), defaults.simple);
827
+ const summaryValue = setting(file, "FREE_MEM_TIER_SUMMARY_MODEL");
828
+ const rawSummary = typeof summaryValue === "string" ? summaryValue : defaults.summary;
553
829
  const summary = rawSummary.length > 0 ? rawSummary : base;
554
830
  return { base, simple, summary };
555
831
  }
@@ -557,9 +833,9 @@ export function effectiveObserverModels(settingsPath) {
557
833
  const ENGINE_LOCAL_BASE_URL_DEFAULT = "http://127.0.0.1:8080/v1";
558
834
  const ENGINE_LOCAL_MODEL_DEFAULT = "local-model";
559
835
  /**
560
- * Effective observer provider: `CLAUDE_MEM_PROVIDER` from settings.json (default `claude`; any
836
+ * Effective observer provider: `FREE_MEM_PROVIDER` from settings.json (default `claude`; any
561
837
  * value other than the literal `local` is treated as `claude`, mirroring the engine's own
562
- * `SettingsDefaultsManager` fallback), and when local, the configured `CLAUDE_MEM_LOCAL_MODEL`
838
+ * `SettingsDefaultsManager` fallback), and when local, the configured `FREE_MEM_LOCAL_MODEL`
563
839
  * or the engine's `local-model` default.
564
840
  *
565
841
  * Args:
@@ -570,34 +846,36 @@ const ENGINE_LOCAL_MODEL_DEFAULT = "local-model";
570
846
  */
571
847
  export function effectiveObserverProvider(settingsPath) {
572
848
  const values = readFlatSettings(settingsPath);
573
- if (values.CLAUDE_MEM_PROVIDER !== "local")
849
+ if (setting(values, "FREE_MEM_PROVIDER") !== "local")
574
850
  return { provider: "claude", model: null };
575
- const raw = values.CLAUDE_MEM_LOCAL_MODEL;
851
+ const raw = setting(values, "FREE_MEM_LOCAL_MODEL");
576
852
  return { provider: "local", model: typeof raw === "string" && raw.length > 0 ? raw : ENGINE_LOCAL_MODEL_DEFAULT };
577
853
  }
578
854
  /**
579
- * The FreeHarness-recommended observer settings: a haiku base and simple tier (the daily-spend
580
- * gate), a sonnet summary tier (session summaries read by every future session, worth the
581
- * larger model), the upstream skip list extended with the tools whose decisions matter for
582
- * recall (Read, Glob, Grep and the multi-agent tools), and a concurrency cap of 2. Shared by
583
- * `memory status`'s WARN check and the `memory settings` command.
855
+ * The FreeHarness-recommended observer settings: Haiku on every tier, because the summary tier
856
+ * alone spent 25.4M Sonnet tokens in a week, and the claude provider, because a local provider
857
+ * running beside it doubled the observer work. The skip list extends the upstream one with
858
+ * the read-only and multi-agent tools, which carry no decision worth recording, and
859
+ * concurrency is capped at 2. Shared by `memory status`'s WARN checks and the `memory
860
+ * settings` command.
584
861
  */
585
862
  export const RECOMMENDED_OBSERVER_SETTINGS = {
586
- CLAUDE_MEM_MODEL: "haiku",
587
- CLAUDE_MEM_TIER_SIMPLE_MODEL: "haiku",
588
- CLAUDE_MEM_TIER_SUMMARY_MODEL: "sonnet",
589
- CLAUDE_MEM_SKIP_TOOLS: "ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion,Read,Glob,Grep,ListAgents,SendMessage,Monitor,TaskOutput,ToolSearch,TaskStop",
590
- CLAUDE_MEM_MAX_CONCURRENT_AGENTS: "2",
863
+ FREE_MEM_MODEL: "haiku",
864
+ FREE_MEM_TIER_SIMPLE_MODEL: "haiku",
865
+ FREE_MEM_TIER_SUMMARY_MODEL: "haiku",
866
+ FREE_MEM_PROVIDER: "claude",
867
+ FREE_MEM_SKIP_TOOLS: "ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion,Read,Glob,Grep,ListAgents,SendMessage,Monitor,TaskOutput,ToolSearch,TaskStop",
868
+ FREE_MEM_MAX_CONCURRENT_AGENTS: "2",
591
869
  };
592
870
  /** `memory settings --local`'s fixed defaults for the two tuning keys. */
593
871
  const LOCAL_OBSERVER_SETTINGS_DEFAULTS = {
594
- CLAUDE_MEM_LOCAL_MAX_PROMPT_TOKENS: "4000",
595
- CLAUDE_MEM_LOCAL_TIMEOUT_MS: "90000",
872
+ FREE_MEM_LOCAL_MAX_PROMPT_TOKENS: "4000",
873
+ FREE_MEM_LOCAL_TIMEOUT_MS: "90000",
596
874
  };
597
875
  /** The read-only tools the local provider itself observes once it is on. */
598
876
  const LOCAL_PROVIDER_SERVED_TOOLS = ["Read", "Glob", "Grep"];
599
877
  /**
600
- * Derives `memory settings --local`'s `CLAUDE_MEM_SKIP_TOOLS` value from the recommended
878
+ * Derives `memory settings --local`'s `FREE_MEM_SKIP_TOOLS` value from the recommended
601
879
  * default with `Read`, `Glob` and `Grep` removed, order kept: those three cost subscription
602
880
  * tokens for little signal at ingest, which is why the recommended default skips them, but the
603
881
  * local provider observes them at zero subscription cost, so the skip list follows the provider.
@@ -608,26 +886,26 @@ const LOCAL_PROVIDER_SERVED_TOOLS = ["Read", "Glob", "Grep"];
608
886
  * The recommended default's tool list minus the three served tools, comma-joined.
609
887
  */
610
888
  function localProviderSkipTools() {
611
- return RECOMMENDED_OBSERVER_SETTINGS.CLAUDE_MEM_SKIP_TOOLS.split(",")
889
+ return RECOMMENDED_OBSERVER_SETTINGS.FREE_MEM_SKIP_TOOLS.split(",")
612
890
  .filter((tool) => !LOCAL_PROVIDER_SERVED_TOOLS.includes(tool))
613
891
  .join(",");
614
892
  }
615
893
  /**
616
894
  * `memory settings --no-local`: the two keys that return the observer to the Claude provider —
617
- * `CLAUDE_MEM_PROVIDER` and `CLAUDE_MEM_SKIP_TOOLS` restored to the recommended default, so the
895
+ * `FREE_MEM_PROVIDER` and `FREE_MEM_SKIP_TOOLS` restored to the recommended default, so the
618
896
  * skip list follows the provider back.
619
897
  */
620
898
  export const NO_LOCAL_OBSERVER_SETTINGS = {
621
- CLAUDE_MEM_PROVIDER: "claude",
622
- CLAUDE_MEM_SKIP_TOOLS: RECOMMENDED_OBSERVER_SETTINGS.CLAUDE_MEM_SKIP_TOOLS,
899
+ FREE_MEM_PROVIDER: "claude",
900
+ FREE_MEM_SKIP_TOOLS: RECOMMENDED_OBSERVER_SETTINGS.FREE_MEM_SKIP_TOOLS,
623
901
  };
624
902
  /**
625
- * Builds the six `CLAUDE_MEM_LOCAL_*` / `CLAUDE_MEM_PROVIDER` / `CLAUDE_MEM_SKIP_TOOLS` keys for
903
+ * Builds the six `FREE_MEM_LOCAL_*` / `FREE_MEM_PROVIDER` / `FREE_MEM_SKIP_TOOLS` keys for
626
904
  * `memory settings --local`.
627
905
  *
628
906
  * Args:
629
- * baseUrl: Candidate `CLAUDE_MEM_LOCAL_BASE_URL` value.
630
- * model: Candidate `CLAUDE_MEM_LOCAL_MODEL` value.
907
+ * baseUrl: Candidate `FREE_MEM_LOCAL_BASE_URL` value.
908
+ * model: Candidate `FREE_MEM_LOCAL_MODEL` value.
631
909
  *
632
910
  * Returns:
633
911
  * The six local-provider keys.
@@ -651,16 +929,16 @@ function localObserverSettings(baseUrl, model) {
651
929
  throw new Error(`FAIL invalid --local model id: ${model}`);
652
930
  }
653
931
  return {
654
- CLAUDE_MEM_PROVIDER: "local",
655
- CLAUDE_MEM_LOCAL_BASE_URL: baseUrl,
656
- CLAUDE_MEM_LOCAL_MODEL: model,
932
+ FREE_MEM_PROVIDER: "local",
933
+ FREE_MEM_LOCAL_BASE_URL: baseUrl,
934
+ FREE_MEM_LOCAL_MODEL: model,
657
935
  ...LOCAL_OBSERVER_SETTINGS_DEFAULTS,
658
- CLAUDE_MEM_SKIP_TOOLS: localProviderSkipTools(),
936
+ FREE_MEM_SKIP_TOOLS: localProviderSkipTools(),
659
937
  };
660
938
  }
661
939
  /**
662
- * Resolves the key set `memory settings` previews or writes: the local set (`CLAUDE_MEM_PROVIDER`
663
- * plus four `CLAUDE_MEM_LOCAL_*` keys plus the narrowed `CLAUDE_MEM_SKIP_TOOLS`), the two no-local
940
+ * Resolves the key set `memory settings` previews or writes: the local set (`FREE_MEM_PROVIDER`
941
+ * plus four `FREE_MEM_LOCAL_*` keys plus the narrowed `FREE_MEM_SKIP_TOOLS`), the two no-local
664
942
  * keys, or the five recommended keys when neither `--local` nor `--no-local` is given.
665
943
  *
666
944
  * Args:
@@ -685,8 +963,8 @@ function resolveObserverKeySet(opts) {
685
963
  /**
686
964
  * Previews `memory settings`: the selected key set against the current file, same or
687
965
  * would-change, writing nothing. Plain (no options): the five recommended keys. `--local`:
688
- * `CLAUDE_MEM_PROVIDER` plus the four `CLAUDE_MEM_LOCAL_*` keys plus the narrowed
689
- * `CLAUDE_MEM_SKIP_TOOLS`. `--no-local`: `CLAUDE_MEM_PROVIDER` and `CLAUDE_MEM_SKIP_TOOLS`
966
+ * `FREE_MEM_PROVIDER` plus the four `FREE_MEM_LOCAL_*` keys plus the narrowed
967
+ * `FREE_MEM_SKIP_TOOLS`. `--no-local`: `FREE_MEM_PROVIDER` and `FREE_MEM_SKIP_TOOLS`
690
968
  * restored to the recommended default.
691
969
  *
692
970
  * Args:
@@ -713,7 +991,7 @@ export function previewObserverSettings(settingsPath, opts) {
713
991
  }
714
992
  }
715
993
  const rows = Object.entries(keySet).map(([key, recommended]) => {
716
- const raw = values[key];
994
+ const raw = setting(values, key);
717
995
  const current = raw === undefined ? undefined : typeof raw === "string" ? raw : JSON.stringify(raw);
718
996
  return { key, current, recommended, changed: current !== recommended };
719
997
  });
@@ -721,7 +999,8 @@ export function previewObserverSettings(settingsPath, opts) {
721
999
  }
722
1000
  /**
723
1001
  * Applies `memory settings`: adds or updates the selected key set (see `previewObserverSettings`
724
- * for which set), preserving every other key, after backing up an existing file first. Refuses
1002
+ * for which set) and drops each written key's `CLAUDE_MEM_` twin, preserving every other key,
1003
+ * after backing up an existing file first. Refuses
725
1004
  * (throws) without writing anything when `opts` fails validation or the existing file is not
726
1005
  * valid JSON.
727
1006
  *
@@ -758,6 +1037,7 @@ export function applyObserverSettings(settingsPath, opts) {
758
1037
  }
759
1038
  for (const [key, value] of Object.entries(keySet)) {
760
1039
  values[key] = value;
1040
+ delete values[legacyName(key)];
761
1041
  }
762
1042
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
763
1043
  fs.writeFileSync(settingsPath, JSON.stringify(values, null, 2) + "\n");
@@ -811,7 +1091,7 @@ async function getWithSignal(url, signal, timeoutMs) {
811
1091
  }
812
1092
  }
813
1093
  /**
814
- * Splits a `CLAUDE_MEM_LOCAL_BASE_URL` into its health root (no `/v1`) and models base (with
1094
+ * Splits a `FREE_MEM_LOCAL_BASE_URL` into its health root (no `/v1`) and models base (with
815
1095
  * `/v1`), accepting the configured value with or without the `/v1` suffix.
816
1096
  *
817
1097
  * Args:
@@ -832,8 +1112,8 @@ function providerUrls(baseUrl) {
832
1112
  * Down is a one-line reason, never a throw.
833
1113
  *
834
1114
  * Args:
835
- * baseUrl: `CLAUDE_MEM_LOCAL_BASE_URL`.
836
- * model: `CLAUDE_MEM_LOCAL_MODEL`.
1115
+ * baseUrl: `FREE_MEM_LOCAL_BASE_URL`.
1116
+ * model: `FREE_MEM_LOCAL_MODEL`.
837
1117
  * timeoutMs: The whole probe's deadline (both requests together).
838
1118
  *
839
1119
  * Returns:
@@ -864,7 +1144,7 @@ export async function probeMemoryProvider(baseUrl, model, timeoutMs = 3000) {
864
1144
  }
865
1145
  /**
866
1146
  * The `memory provider` doctor line: only when the data dir's settings.json sets
867
- * `CLAUDE_MEM_PROVIDER=local`, a probe of the configured base URL and model, OK or WARN with
1147
+ * `FREE_MEM_PROVIDER=local`, a probe of the configured base URL and model, OK or WARN with
868
1148
  * the reason; absent or `claude` prints nothing.
869
1149
  *
870
1150
  * Args:
@@ -876,16 +1156,16 @@ export async function probeMemoryProvider(baseUrl, model, timeoutMs = 3000) {
876
1156
  */
877
1157
  export async function memoryProviderLines(settingsPath, probe = probeMemoryProvider) {
878
1158
  const values = readFlatSettings(settingsPath);
879
- if (values.CLAUDE_MEM_PROVIDER !== "local")
1159
+ if (setting(values, "FREE_MEM_PROVIDER") !== "local")
880
1160
  return [];
881
1161
  // The worker applies these same defaults for an absent key, so probing "" would report a
882
1162
  // URL-parse failure for a server the observer is in fact reaching.
883
- const baseUrl = typeof values.CLAUDE_MEM_LOCAL_BASE_URL === "string" && values.CLAUDE_MEM_LOCAL_BASE_URL.trim() !== ""
884
- ? values.CLAUDE_MEM_LOCAL_BASE_URL
885
- : ENGINE_LOCAL_BASE_URL_DEFAULT;
886
- const model = typeof values.CLAUDE_MEM_LOCAL_MODEL === "string" && values.CLAUDE_MEM_LOCAL_MODEL.trim() !== ""
887
- ? values.CLAUDE_MEM_LOCAL_MODEL
888
- : ENGINE_LOCAL_MODEL_DEFAULT;
1163
+ const configured = (key, fallback) => {
1164
+ const v = setting(values, key);
1165
+ return typeof v === "string" && v.trim() !== "" ? v : fallback;
1166
+ };
1167
+ const baseUrl = configured("FREE_MEM_LOCAL_BASE_URL", ENGINE_LOCAL_BASE_URL_DEFAULT);
1168
+ const model = configured("FREE_MEM_LOCAL_MODEL", ENGINE_LOCAL_MODEL_DEFAULT);
889
1169
  const r = await probe(baseUrl, model);
890
1170
  return [`${r.ok ? "OK " : "WARN"} memory provider: ${baseUrl} ${model} ${r.ok ? "OK" : r.reason}`];
891
1171
  }
@@ -893,30 +1173,75 @@ export async function memoryProviderLines(settingsPath, probe = probeMemoryProvi
893
1173
  export function configDir() {
894
1174
  return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
895
1175
  }
896
- /** Mirror engine shared/paths.ts resolveDataDir (env, then settings.json, then default). */
1176
+ /** The engine's data directory and database for this process. */
1177
+ export function memoryPaths() {
1178
+ return resolveMemoryPaths(process.env, os.homedir());
1179
+ }
897
1180
  export function dataDir() {
898
- const expand = (p) => (p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p);
899
- if (process.env.CLAUDE_MEM_DATA_DIR)
900
- return expand(process.env.CLAUDE_MEM_DATA_DIR);
901
- const def = path.join(os.homedir(), ".claude-mem");
1181
+ return memoryPaths().dataDir;
1182
+ }
1183
+ /**
1184
+ * One WARN per `CLAUDE_MEM_*` variable in `env`, naming its `FREE_MEM_*` replacement, and one
1185
+ * WARN for all `CLAUDE_MEM_*` keys in the active settings file: a seeded legacy file holds dozens,
1186
+ * and a line per key would bury the rest of the report.
1187
+ */
1188
+ export function deprecatedNameLines(env, settingsPath) {
1189
+ const lines = Object.keys(env)
1190
+ .filter((k) => k.startsWith(OLD_PREFIX) && env[k] !== undefined)
1191
+ .sort()
1192
+ .map((k) => `WARN deprecated environment variable ${k} is set; use FREE_MEM_${k.slice(OLD_PREFIX.length)}`);
1193
+ const keys = Object.keys(readMemorySettings(settingsPath) ?? {}).filter((k) => k.startsWith(OLD_PREFIX)).sort();
1194
+ if (keys.length > 0) {
1195
+ lines.push(`WARN deprecated settings keys (${keys.length}) in ${settingsPath}: ${keys.join(", ")}; copying the settings to the free-mem directory renames them to FREE_MEM_*`);
1196
+ }
1197
+ return lines;
1198
+ }
1199
+ /**
1200
+ * A WARN when the legacy database (or its WAL) changed after the migration marker's `at`:
1201
+ * a build still on the old name keeps writing memories the new directory never sees.
1202
+ */
1203
+ export function legacyWriteLines(home) {
1204
+ const freeDir = path.join(home, FREE_DIR_NAME);
1205
+ let marker;
902
1206
  try {
903
- const raw = JSON.parse(fs.readFileSync(path.join(def, "settings.json"), "utf8"));
904
- const s = raw.env ?? raw;
905
- if (typeof s.CLAUDE_MEM_DATA_DIR === "string")
906
- return expand(s.CLAUDE_MEM_DATA_DIR);
1207
+ marker = JSON.parse(fs.readFileSync(path.join(freeDir, ".migrated-from.json"), "utf8"));
907
1208
  }
908
1209
  catch {
909
- /* default */
1210
+ return [];
910
1211
  }
911
- return def;
1212
+ const at = typeof marker.at === "string" ? Date.parse(marker.at) : NaN;
1213
+ if (Number.isNaN(at))
1214
+ return [];
1215
+ const source = typeof marker.source === "string" && marker.source ? marker.source : path.join(home, LEGACY_DIR_NAME);
1216
+ const db = path.join(source, LEGACY_DB_FILENAME);
1217
+ let changed = 0;
1218
+ for (const file of [db, `${db}-wal`]) {
1219
+ try {
1220
+ changed = Math.max(changed, fs.statSync(file).mtimeMs);
1221
+ }
1222
+ catch {
1223
+ /* absent */
1224
+ }
1225
+ }
1226
+ if (changed <= at)
1227
+ return [];
1228
+ return [`WARN legacy database ${db} changed at ${new Date(changed).toISOString()}, after the migration at ${new Date(at).toISOString()}; a build still on the old name may be writing there, and those memories are not in ${freeDir}`];
912
1229
  }
913
- /** Mirror engine SettingsDefaultsManager: CLAUDE_MEM_WORKER_PORT or 37700 + uid % 100. */
914
- function workerPort(dir) {
1230
+ /** Leading decimal integer, as the engine's parseInt reads it, when in 1..65535. */
1231
+ function validPort(value) {
1232
+ const p = parseInt(String(value ?? ""), 10);
1233
+ return p >= 1 && p <= 65535 ? p : null;
1234
+ }
1235
+ /** Engine worker port: env, then settings.json, then 37700 + uid % 100. */
1236
+ export function workerPort(dir) {
1237
+ const env = validPort(envSetting(process.env, "FREE_MEM_WORKER_PORT"));
1238
+ if (env !== null)
1239
+ return env;
915
1240
  try {
916
1241
  const raw = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf8"));
917
1242
  const s = raw.env ?? raw;
918
- const p = parseInt(String(s.CLAUDE_MEM_WORKER_PORT ?? ""), 10);
919
- if (Number.isFinite(p) && p > 0)
1243
+ const p = validPort(setting(s, "FREE_MEM_WORKER_PORT"));
1244
+ if (p !== null)
920
1245
  return p;
921
1246
  }
922
1247
  catch {
@@ -924,37 +1249,111 @@ function workerPort(dir) {
924
1249
  }
925
1250
  return 37700 + ((process.getuid?.() ?? 77) % 100);
926
1251
  }
927
- async function probeWorker(port) {
928
- const base = `http://127.0.0.1:${port}`;
929
- const get = async (p) => {
930
- try {
931
- return await fetch(base + p, { signal: AbortSignal.timeout(1500) });
932
- }
933
- catch {
934
- return null;
935
- }
1252
+ function readJsonOr(file, fallback) {
1253
+ try {
1254
+ return JSON.parse(fs.readFileSync(file, "utf8"));
1255
+ }
1256
+ catch {
1257
+ return fallback;
1258
+ }
1259
+ }
1260
+ /** One WARN while the upstream plugin is still registered: both workers bind the same port. */
1261
+ export function upstreamLines(cfg, port) {
1262
+ const installed = readJsonOr(path.join(cfg, "plugins", "installed_plugins.json"), {});
1263
+ const settings = readJsonOr(path.join(cfg, "settings.json"), {});
1264
+ if (!isUpstreamRegistered(installed, settings))
1265
+ return [];
1266
+ return [`WARN ${UPSTREAM_PLUGIN_KEY} is still registered; it shares worker port ${port} with ${PLUGIN_NAME}; disable it with \`claude plugin disable ${UPSTREAM_PLUGIN_KEY}\``];
1267
+ }
1268
+ const FOREIGN_TEXT_MAX = 200;
1269
+ const ANSI_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
1270
+ // Copy of the engine's filterForeignText (the CLI may not import engine source). Built from code
1271
+ // points so this source holds no raw line separator or bidi control; under the u flag the
1272
+ // surrogate class matches only an unpaired half.
1273
+ const UNPRINTABLE_RANGES = [
1274
+ [0x00, 0x1f], [0x7f, 0x9f], [0x200e, 0x200f], [0x2028, 0x2029], [0x202a, 0x202e], [0x2066, 0x2069],
1275
+ ];
1276
+ const hex = (cp) => "\\u" + cp.toString(16).padStart(4, "0");
1277
+ const UNPRINTABLE = new RegExp("[" + UNPRINTABLE_RANGES.map(([from, to]) => hex(from) + "-" + hex(to)).join("") + "]|[" + hex(0xd800) + "-" + hex(0xdfff) + "]", "gu");
1278
+ /** A foreign worker writes these strings, and doctor prints them to the user's terminal. */
1279
+ function filterForeignText(value) {
1280
+ if (typeof value !== "string")
1281
+ return "";
1282
+ const clean = value.replace(ANSI_SEQUENCE, "").replace(UNPRINTABLE, "");
1283
+ return Array.from(clean).slice(0, FOREIGN_TEXT_MAX).join("");
1284
+ }
1285
+ function trustedPid(value) {
1286
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
1287
+ }
1288
+ /** The pid and port in the data dir's worker.pid, the file the engine writes and stopWorker reads. */
1289
+ function ownedWorker(dir) {
1290
+ const raw = readJsonOr(path.join(dir, "worker.pid"), null);
1291
+ if (raw === null || typeof raw !== "object")
1292
+ return null;
1293
+ const { pid, port } = raw;
1294
+ const owned = trustedPid(pid);
1295
+ return owned !== undefined && typeof port === "number" ? { pid: owned, port } : null;
1296
+ }
1297
+ /**
1298
+ * The engine's classifyWorker: an unmarked answer is an older build of ours only when the
1299
+ * free-mem PID file names both its pid and the probed port, since a foreign worker can report
1300
+ * any pid it likes.
1301
+ */
1302
+ function classifyHealth(health, owned, port) {
1303
+ if (health === null || typeof health !== "object" || Array.isArray(health)) {
1304
+ return { identity: "foreign", facts: { workerPath: "", version: "" } };
1305
+ }
1306
+ const answer = health;
1307
+ if (answer.product === FREE_MEM_PRODUCT)
1308
+ return { identity: "ours" };
1309
+ const pid = trustedPid(answer.pid);
1310
+ if (owned !== null && owned.port === port && pid !== undefined && owned.pid === pid)
1311
+ return { identity: "ours-stale" };
1312
+ return {
1313
+ identity: "foreign",
1314
+ facts: { ...(pid !== undefined ? { pid } : {}), workerPath: filterForeignText(answer.workerPath), version: filterForeignText(answer.version) },
936
1315
  };
937
- const health = await get("/api/health");
938
- if (!health)
939
- return { port, reachable: false };
940
- let version;
1316
+ }
1317
+ function foreignWorkerLine(port, facts) {
1318
+ const where = facts.workerPath || "unknown path";
1319
+ const version = facts.version || "unknown";
1320
+ const cleanup = "run `freeharness cleanup --scope user` to remove its launchers";
1321
+ if (facts.pid !== undefined) {
1322
+ return `FAIL worker on :${port} is another program (pid ${facts.pid}, ${where}, version ${version}); memory is off — stop it with \`kill ${facts.pid}\` and ${cleanup}`;
1323
+ }
1324
+ return `FAIL worker on :${port} is another program (${where}, version ${version}); memory is off — stop the program listening on port ${port} and ${cleanup}`;
1325
+ }
1326
+ async function probeWorker(port, dir) {
1327
+ let res;
941
1328
  try {
942
- version = String((await health.json()).version ?? "");
1329
+ // Unfollowed, so the port's holder cannot redirect the probe to a server that vouches for it.
1330
+ res = await fetch(`http://127.0.0.1:${port}/api/health`, { redirect: "manual", signal: AbortSignal.timeout(1500) });
943
1331
  }
944
1332
  catch {
945
- /* non-JSON */
1333
+ return { port, reachable: false };
946
1334
  }
947
- const root = await get("/");
948
- let body = "";
1335
+ let health;
949
1336
  try {
950
- body = root ? await root.text() : "";
1337
+ const text = await res.text();
1338
+ // Our worker always answers health with 200, so any other answer comes from another program.
1339
+ health = res.status === 200 ? JSON.parse(text) : undefined;
951
1340
  }
952
1341
  catch {
953
- /* ignore */
1342
+ /* non-JSON or cut off: classified as foreign below */
1343
+ }
1344
+ const verdict = classifyHealth(health, ownedWorker(dir), port);
1345
+ const fields = (health !== null && typeof health === "object" ? health : {});
1346
+ let parked;
1347
+ const count = fields.parked?.count;
1348
+ if (typeof count === "number" && Number.isInteger(count) && count >= 0) {
1349
+ const age = fields.parked?.oldestAgeMs;
1350
+ parked = { count, oldestAgeMs: typeof age === "number" && Number.isFinite(age) && age >= 0 ? age : null };
1351
+ }
1352
+ const embeddings = parseEmbeddingsHealth(fields.embeddings);
1353
+ if (verdict.identity === "foreign") {
1354
+ return { port, reachable: true, version: verdict.facts.version || undefined, identity: "foreign", ours: false, foreign: verdict.facts, parked, embeddings };
954
1355
  }
955
- const viewer = root?.status === 200;
956
- const ours = root ? root.status === 404 || body.includes(VIEWER_MARKER) : undefined;
957
- return { port, reachable: true, version, viewer, ours };
1356
+ return { port, reachable: true, version: String(fields.version ?? ""), identity: verdict.identity, ours: true, parked, embeddings };
958
1357
  }
959
1358
  /**
960
1359
  * Lists cache versions under one plugin-cache root.
@@ -1001,16 +1400,50 @@ function cacheTag(c) {
1001
1400
  return "previous FreeHarness build or upstream (legacy thedotmack/claude-mem cache)";
1002
1401
  return "FreeHarness build";
1003
1402
  }
1403
+ const TREE_BYTES_MAX_ENTRIES = 100_000;
1404
+ /** Bytes of regular files under `dir`; symlinks are never followed and the walk is capped, so a huge tree yields a lower bound. */
1405
+ function treeBytes(dir) {
1406
+ let bytes = 0;
1407
+ let seen = 0;
1408
+ const stack = [dir];
1409
+ while (stack.length > 0 && seen < TREE_BYTES_MAX_ENTRIES) {
1410
+ const cur = stack.pop();
1411
+ let entries;
1412
+ try {
1413
+ entries = fs.readdirSync(cur, { withFileTypes: true });
1414
+ }
1415
+ catch {
1416
+ continue;
1417
+ }
1418
+ for (const e of entries) {
1419
+ if (++seen > TREE_BYTES_MAX_ENTRIES)
1420
+ break;
1421
+ const p = path.join(cur, e.name);
1422
+ if (e.isDirectory())
1423
+ stack.push(p);
1424
+ else if (e.isFile()) {
1425
+ try {
1426
+ bytes += fs.lstatSync(p).size;
1427
+ }
1428
+ catch {
1429
+ // vanished between readdir and lstat
1430
+ }
1431
+ }
1432
+ }
1433
+ }
1434
+ return bytes;
1435
+ }
1004
1436
  /**
1005
1437
  * Diagnose which claude-mem is active.
1006
1438
  *
1007
1439
  * Returns:
1008
- * A MemoryDiagnosis with a verdict and printable lines (OK/WARN prefixed).
1440
+ * A MemoryDiagnosis with a verdict and printable lines (OK/WARN/INFO prefixed; FAIL for a
1441
+ * foreign worker on the memory port).
1009
1442
  */
1010
1443
  export async function diagnoseMemory() {
1011
1444
  const cfg = configDir();
1012
- const data = dataDir();
1013
- const dbPath = path.join(data, "claude-mem.db");
1445
+ const { dataDir: data, dbFile: dbPath } = memoryPaths();
1446
+ const dbName = path.basename(dbPath);
1014
1447
  const dbBytes = fs.existsSync(dbPath) ? fs.statSync(dbPath).size : 0;
1015
1448
  const chromaPresent = fs.existsSync(path.join(data, "chroma"));
1016
1449
  const cache = listCache(cfg);
@@ -1027,7 +1460,7 @@ export async function diagnoseMemory() {
1027
1460
  /* not registered */
1028
1461
  }
1029
1462
  const activeIsOurs = activeInstallPath ? fs.existsSync(path.join(activeInstallPath, ".install-version")) : undefined;
1030
- const worker = await probeWorker(workerPort(data));
1463
+ const worker = await probeWorker(workerPort(data), data);
1031
1464
  const observer = readObserverStatus(data);
1032
1465
  for (const c of cache) {
1033
1466
  lines.push(`${c.ours && !c.legacy ? "OK " : "WARN"} plugin cache ${c.version}: ${cacheTag(c)}; skills: ${c.skills.join(", ") || "(none)"}`);
@@ -1040,12 +1473,15 @@ export async function diagnoseMemory() {
1040
1473
  else {
1041
1474
  lines.push(`WARN ${PLUGIN_KEY} not registered in installed_plugins.json`);
1042
1475
  }
1476
+ lines.push(...upstreamLines(cfg, worker.port));
1043
1477
  if (!worker.reachable) {
1044
1478
  lines.push(`OK no worker listening on :${worker.port} (next SessionStart starts the registered plugin's worker)`);
1045
1479
  }
1480
+ else if (worker.identity === "foreign") {
1481
+ lines.push(foreignWorkerLine(worker.port, worker.foreign ?? { workerPath: "", version: "" }));
1482
+ }
1046
1483
  else {
1047
- const isOurs = worker.ours === true;
1048
- lines.push(`${isOurs ? "OK " : "WARN"} worker on :${worker.port} version ${worker.version ?? "?"} — ${isOurs ? `FreeHarness build (viewer: http://localhost:${worker.port}/)` : "serves the React viewer at / => UPSTREAM daemon still running"}`);
1484
+ lines.push(`OK worker on :${worker.port} version ${worker.version ?? "?"} — FreeHarness build (viewer: http://localhost:${worker.port}/)`);
1049
1485
  // A worker outlives the plugin upgrade that replaced its files: it keeps serving the old
1050
1486
  // version until restarted. The verdict is unaffected (both are ours when both are ours).
1051
1487
  if (worker.version && activeVersion && worker.version !== activeVersion) {
@@ -1063,6 +1499,20 @@ export async function diagnoseMemory() {
1063
1499
  const provider = effectiveObserverProvider(path.join(data, "settings.json"));
1064
1500
  const providerSuffix = provider.provider === "local" ? ` provider=local ${provider.model}` : " provider=claude";
1065
1501
  lines.push(`${baseIsHaiku ? "OK " : "WARN"} observer model base=${models.base} simple=${models.simple} summary=${models.summary}${providerSuffix}${baseIsHaiku ? "" : " — not a haiku alias; run `freeharness memory settings`"}`);
1502
+ if (!isHaikuAlias(models.summary)) {
1503
+ lines.push(`WARN observer summary tier FREE_MEM_TIER_SUMMARY_MODEL=${models.summary} is not a haiku alias; run \`freeharness memory settings\``);
1504
+ }
1505
+ if (provider.provider === "local") {
1506
+ lines.push("WARN observer FREE_MEM_PROVIDER=local runs beside the claude observer; run `freeharness memory settings`");
1507
+ }
1508
+ const skipRaw = setting(readFlatSettings(path.join(data, "settings.json")), "FREE_MEM_SKIP_TOOLS");
1509
+ if (typeof skipRaw === "string") {
1510
+ const skipped = skipRaw.split(",").map((t) => t.trim());
1511
+ const missing = LOCAL_PROVIDER_SERVED_TOOLS.filter((t) => !skipped.includes(t));
1512
+ if (missing.length > 0) {
1513
+ lines.push(`WARN observer FREE_MEM_SKIP_TOOLS lacks ${missing.join(", ")}, so the observer pays for them; run \`freeharness memory settings\``);
1514
+ }
1515
+ }
1066
1516
  lines.push(...(await memoryProviderLines(path.join(data, "settings.json"))));
1067
1517
  lines.push(...spendLines(observerSpend(dbPath)));
1068
1518
  const localLogSummary = readLocalProviderLog(path.join(data, "local-provider.jsonl"), startOfUtcDayMs(now), now);
@@ -1072,17 +1522,19 @@ export async function diagnoseMemory() {
1072
1522
  ? observerDuplicateRate(dbPath)
1073
1523
  : { today: { observations: 0, duplicates: 0 }, yesterday: { observations: 0, duplicates: 0 } };
1074
1524
  lines.push(...localProviderLines(localLogSummary, duplicateStats));
1075
- lines.push(`INFO data dir ${data}: claude-mem.db ${(dbBytes / 1048576).toFixed(1)} MB${chromaPresent ? " + chroma/" : ""} (shared by both builds; back up before switching)`);
1076
- const schema = dbSchemaVersion(dbPath);
1077
- if (schema !== null) {
1078
- lines.push(schema <= OUR_SCHEMA_VERSION
1079
- ? `OK claude-mem.db schema v${schema} (our engine knows up to v${OUR_SCHEMA_VERSION})`
1080
- : schema === 50
1081
- ? `OK claude-mem.db schema v50 (upstream 13.2x; v50 only adds nullable sdk_sessions columns our engine v${OUR_SCHEMA_VERSION} ignores)`
1082
- : `WARN claude-mem.db schema v${schema} is NEWER than our engine (v${OUR_SCHEMA_VERSION}) — migrated by an upstream build; migrations are additive so it usually works, but keep the backup and watch the first worker start`);
1525
+ lines.push(...pipelineLines(data, dbPath, spawnSync, worker));
1526
+ lines.push(`INFO memory data dir ${data}: ${dbName} ${(dbBytes / 1048576).toFixed(1)} MB (shared by both builds; back up before switching)`);
1527
+ if (chromaPresent) {
1528
+ const mb = (treeBytes(path.join(data, "chroma")) / 1048576).toFixed(1);
1529
+ lines.push(`INFO memory data dir ${data}: chroma/ (${mb} MB) is no longer used by free-mem and is kept; move it aside by hand to reclaim the space`);
1083
1530
  }
1531
+ lines.push(...deprecatedNameLines(process.env, path.join(data, "settings.json")));
1532
+ lines.push(...legacyWriteLines(os.homedir()));
1533
+ const schemaLine = schemaVersionLine(dbPath, dbName);
1534
+ if (schemaLine !== null)
1535
+ lines.push(schemaLine);
1084
1536
  const foreign = cache.some((c) => !c.ours);
1085
- const upstreamWorker = worker.reachable && worker.ours !== true;
1537
+ const upstreamWorker = worker.reachable && worker.identity === "foreign";
1086
1538
  let verdict = "none";
1087
1539
  if (cache.length > 0) {
1088
1540
  if (activeIsOurs && !foreign && !upstreamWorker)
@@ -1158,17 +1610,159 @@ export async function restartWorker(dryRun, diagnosis) {
1158
1610
  }
1159
1611
  return { method: "nothing", detail: `no worker on :${d.worker.port} and no registered FreeHarness build to launch (run install.sh --with-memory)` };
1160
1612
  }
1613
+ // Set when a stop-only call stopped a running worker, so the next restart decision owes it back.
1614
+ let stopOwed = false;
1615
+ async function answers(port) {
1616
+ try {
1617
+ await fetch(`http://127.0.0.1:${port}/api/health`, { signal: AbortSignal.timeout(1000) });
1618
+ return true;
1619
+ }
1620
+ catch {
1621
+ return false;
1622
+ }
1623
+ }
1161
1624
  /**
1162
- * Make the FreeHarness build the only active claude-mem: stop a running worker, move
1163
- * foreign cache versions aside (never deleted), point installed_plugins at ours.
1625
+ * Stops the worker without starting another, then waits until its pid file names no live process
1626
+ * and its port is closed.
1627
+ * @param mode Only `shutdown`.
1628
+ * @param opts Data dir and port (the resolved ones by default) and the wait bound in ms.
1629
+ * @returns Whether it stopped, whether one was running, and the reason on timeout.
1630
+ */
1631
+ export async function stopWorker(mode, opts = {}) {
1632
+ const dir = opts.dir ?? dataDir();
1633
+ const port = opts.port ?? workerPort(dir);
1634
+ const pidFile = path.join(dir, "worker.pid");
1635
+ const timeoutMs = opts.timeoutMs ?? 15_000;
1636
+ const up = await answers(port);
1637
+ const wasRunning = up || pidFileLive(pidFile);
1638
+ if (up && mode === "shutdown") {
1639
+ try {
1640
+ await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, { method: "POST", signal: AbortSignal.timeout(5000) });
1641
+ }
1642
+ catch {
1643
+ /* it may exit before answering */
1644
+ }
1645
+ }
1646
+ const deadline = Date.now() + timeoutMs;
1647
+ for (;;) {
1648
+ const answering = await answers(port);
1649
+ const live = pidFileLive(pidFile);
1650
+ if (!answering && !live)
1651
+ break;
1652
+ if (Date.now() >= deadline) {
1653
+ return { stopped: false, wasRunning, reason: answering ? `worker on :${port} still answers after ${timeoutMs} ms` : `${pidFile} still names a live process after ${timeoutMs} ms` };
1654
+ }
1655
+ await new Promise((r) => setTimeout(r, 100));
1656
+ }
1657
+ if (wasRunning)
1658
+ stopOwed = true;
1659
+ return { stopped: true, wasRunning };
1660
+ }
1661
+ /** Whether a worker answers on the data dir's worker port right now. */
1662
+ export async function workerRunning() {
1663
+ const dir = dataDir();
1664
+ return (await probeWorker(workerPort(dir), dir)).reachable;
1665
+ }
1666
+ /** The engine's log file for a day, where a failed worker start leaves its reason. */
1667
+ export function workerLogPath(now = new Date()) {
1668
+ return path.join(dataDir(), "logs", `free-mem-${now.toISOString().split("T")[0]}.log`);
1669
+ }
1670
+ /** `<pid>:<version>` from `/api/health` on the data dir's worker port; null when nothing answers. */
1671
+ async function workerIdentity() {
1672
+ try {
1673
+ const res = await fetch(`http://127.0.0.1:${workerPort(dataDir())}/api/health`, { signal: AbortSignal.timeout(1500) });
1674
+ const body = (await res.json().catch(() => ({})));
1675
+ return `${String(body.pid ?? "")}:${String(body.version ?? "")}`;
1676
+ }
1677
+ catch {
1678
+ return null;
1679
+ }
1680
+ }
1681
+ /**
1682
+ * Polls health until a worker other than `previous` answers: one seen after the port went down,
1683
+ * or one whose pid or version differs. Without this, the old worker still answering right
1684
+ * after the launcher spawn passes as the restarted one.
1685
+ *
1686
+ * Args:
1687
+ * ms: Poll budget.
1688
+ * previous: The identity read before the restart (null when nothing answered).
1689
+ *
1690
+ * Returns:
1691
+ * `up` for a new worker; `old` when the previous one still answered at the deadline; `down` otherwise.
1692
+ */
1693
+ async function workerAnswers(ms, previous) {
1694
+ const deadline = Date.now() + ms;
1695
+ let previousGone = previous === null;
1696
+ do {
1697
+ const current = await workerIdentity();
1698
+ if (current === null)
1699
+ previousGone = true;
1700
+ else if (previousGone || current !== previous)
1701
+ return "up";
1702
+ await new Promise((r) => setTimeout(r, 250));
1703
+ } while (Date.now() < deadline);
1704
+ return previousGone ? "down" : "old";
1705
+ }
1706
+ /**
1707
+ * Restart a worker that was running before the command when the command staged a different build
1708
+ * or stopped that worker itself; a worker that was not running stays stopped. A failed
1709
+ * restart, including a launched worker that never answers health or an old worker that never
1710
+ * gives way to a new one, is reported, never thrown.
1164
1711
  *
1165
1712
  * Args:
1713
+ * wasRunning: Whether a worker answered before the command changed anything.
1714
+ * cause: Whether the command staged a build or stopped the worker.
1166
1715
  * dryRun: Report only.
1716
+ * healthMs: How long a launched worker has to answer `/api/health`.
1717
+ *
1718
+ * Returns:
1719
+ * The decision and, on apply, its outcome.
1720
+ */
1721
+ export async function restartAfterStage(wasRunning, cause, dryRun, healthMs = 15_000) {
1722
+ if (!wasRunning)
1723
+ return { planned: false, reason: "not running; it stays stopped" };
1724
+ if (!cause.staged && !cause.stopped && !stopOwed)
1725
+ return { planned: false, reason: "build unchanged" };
1726
+ const out = { planned: true, reason: cause.staged ? "a different build was staged" : "the command stopped it" };
1727
+ if (dryRun)
1728
+ return out;
1729
+ stopOwed = false;
1730
+ try {
1731
+ const previous = await workerIdentity();
1732
+ out.result = await restartWorker(false);
1733
+ const outcome = out.result.method === "worker-service restart" ? await workerAnswers(healthMs, previous) : undefined;
1734
+ if (outcome === undefined)
1735
+ out.error = `${out.result.method}: ${out.result.detail}`;
1736
+ else if (outcome === "old")
1737
+ out.error = `no new worker answered /api/health within ${healthMs} ms after ${out.result.detail}; the previous worker (pid:version ${previous}) still answers`;
1738
+ else if (outcome === "down")
1739
+ out.error = `worker did not answer /api/health within ${healthMs} ms after ${out.result.detail}`;
1740
+ }
1741
+ catch (e) {
1742
+ out.error = e instanceof Error ? e.message : String(e);
1743
+ }
1744
+ if (out.error)
1745
+ out.log = workerLogPath();
1746
+ return out;
1747
+ }
1748
+ /**
1749
+ * Make the FreeHarness build the only active claude-mem: stage the vendored build when it is
1750
+ * built and differs from the cache, stop a running worker, move foreign cache versions aside
1751
+ * (never deleted), point installed_plugins at ours, and restart the worker it stopped.
1752
+ * Staging runs first, so a staging failure leaves a running worker up.
1753
+ *
1754
+ * Args:
1755
+ * dryRun: Report only.
1756
+ * opts: The vendored plugin dir; without it the newest cached build is registered as is.
1167
1757
  *
1168
1758
  * Returns:
1169
1759
  * What was (or would be) done.
1760
+ *
1761
+ * Raises:
1762
+ * Error: When the cache holds no FreeHarness build, the plugin version is not semver, staging
1763
+ * fails, or deps need a missing Bun (refused before any cache write).
1170
1764
  */
1171
- export async function activateOurs(dryRun) {
1765
+ export async function activateOurs(dryRun, opts = {}) {
1172
1766
  const d = await diagnoseMemory();
1173
1767
  // A native-cache build always wins over a legacy (pre-rename) one, even at a lower version:
1174
1768
  // the native path is what the current vendored engine's constants resolve to.
@@ -1177,7 +1771,36 @@ export async function activateOurs(dryRun) {
1177
1771
  .sort((a, b) => Number(a.legacy) - Number(b.legacy) || b.version.localeCompare(a.version, undefined, { numeric: true }))[0];
1178
1772
  if (!ours)
1179
1773
  throw new Error("no FreeHarness build in the plugin cache — run install.sh --with-memory first");
1180
- const res = { stoppedWorker: false, disabled: [], registered: false };
1774
+ const res = { stoppedWorker: false, disabled: [], registered: false, stageNotes: [], restart: { planned: false, reason: "" } };
1775
+ let active = { dir: ours.dir, version: ours.version };
1776
+ const unbuilt = opts.pluginDir ? missingPluginBundles(opts.pluginDir) : [];
1777
+ if (!opts.pluginDir)
1778
+ res.stageNotes.push("no vendored build in this checkout; the cached build is registered as is");
1779
+ else if (unbuilt.length > 0) {
1780
+ // plugin/scripts is untracked build output: an unbuilt checkout must never wipe a working cache.
1781
+ res.stageNotes.push(`${opts.pluginDir} is not built (missing ${unbuilt.map((f) => `scripts/${f}`).join(", ")}); the cached build is kept`);
1782
+ }
1783
+ else {
1784
+ const version = pluginVersion(opts.pluginDir);
1785
+ const cacheDir = claudePluginPaths(version).cacheDir;
1786
+ const bun = probeBun();
1787
+ // A re-stage empties node_modules, and a registered cache without its deps fails every hook.
1788
+ if (!bun.path && declaresPluginDeps(opts.pluginDir) && stagePluginCache(opts.pluginDir, cacheDir, { dryRun: true }).staged) {
1789
+ throw new Error(`Bun is required to install the plugin runtime deps in ${cacheDir} (https://bun.sh); the plugin cache is unchanged`);
1790
+ }
1791
+ const stage = stagePluginCache(opts.pluginDir, cacheDir, { dryRun });
1792
+ if (!dryRun && (stage.staged || !fs.existsSync(path.join(cacheDir, ".install-version")))) {
1793
+ if (missingPluginDeps(cacheDir).length > 0) {
1794
+ if (!bun.path)
1795
+ throw new Error(`Bun is required to install the plugin runtime deps in ${cacheDir} (https://bun.sh)`);
1796
+ installPluginDeps(cacheDir, bun.path);
1797
+ }
1798
+ writeInstallMarker(cacheDir, version, bun.version ?? "unknown");
1799
+ }
1800
+ res.stage = { ...stage, cacheDir, version };
1801
+ res.stageNotes.push(...stage.notes);
1802
+ active = { dir: cacheDir, version };
1803
+ }
1181
1804
  if (d.worker.reachable && !dryRun) {
1182
1805
  try {
1183
1806
  await fetch(`http://127.0.0.1:${d.worker.port}/api/admin/shutdown`, { method: "POST", signal: AbortSignal.timeout(5000) });
@@ -1209,9 +1832,10 @@ export async function activateOurs(dryRun) {
1209
1832
  const ip = JSON.parse(fs.readFileSync(ipPath, "utf8"));
1210
1833
  const now = new Date().toISOString();
1211
1834
  ip.plugins = ip.plugins ?? {};
1212
- ip.plugins[PLUGIN_KEY] = [{ scope: "user", installPath: ours.dir, version: ours.version, installedAt: now, lastUpdated: now }];
1835
+ ip.plugins[PLUGIN_KEY] = [{ scope: "user", installPath: active.dir, version: active.version, installedAt: now, lastUpdated: now }];
1213
1836
  fs.writeFileSync(ipPath, JSON.stringify(ip, null, 2) + "\n");
1214
1837
  }
1215
1838
  res.registered = true;
1839
+ res.restart = await restartAfterStage(d.worker.reachable, { staged: res.stage?.staged === true, stopped: res.stoppedWorker }, dryRun);
1216
1840
  return res;
1217
1841
  }