freeharness 0.4.1 → 0.4.3

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