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.
- package/NOTICE +2 -1
- package/README.md +24 -19
- package/canonical/HARVEST.yaml +27 -16
- package/canonical/PROVENANCE.yaml +3 -3
- package/canonical/agents/README.md +77 -36
- package/canonical/agents/agentic-architect.md +2 -1
- package/canonical/agents/database-reviewer.md +6 -8
- package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
- package/canonical/agents/implementer.md +30 -0
- package/canonical/agents/pr-reviewer.md +9 -11
- package/canonical/agents/python-reviewer.md +7 -9
- package/canonical/agents/report-formatter.md +7 -3
- package/canonical/agents/security-reviewer.md +14 -9
- package/canonical/agents/test-writer.md +5 -2
- package/canonical/hooks/cost-prices.json +11 -9
- package/canonical/hooks/freeharness-config-guard.mjs +4155 -93
- package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
- package/canonical/hooks/freeharness-converge.mjs +78 -23
- package/canonical/hooks/freeharness-cost-tracker.mjs +1116 -218
- package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
- package/canonical/hooks/freeharness-precompact.mjs +206 -87
- package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
- package/canonical/hooks/freeharness-reload-context.mjs +197 -0
- package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
- package/canonical/hooks/freeharness-sdd-context.mjs +959 -0
- package/canonical/hooks/freeharness-session-start +297 -59
- package/canonical/hooks/hooks.claude-code.json +1 -1
- package/canonical/hooks/hooks.config-guard.claude-code.json +2 -2
- package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
- package/canonical/hooks/hooks.converge.claude-code.json +1 -1
- package/canonical/hooks/hooks.memory-guard.claude-code.json +1 -1
- package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
- package/canonical/openspec/schema.spec-driven.yaml +29 -8
- package/canonical/openspec/templates/follow-ups.md +3 -2
- package/canonical/openspec/templates/proposal.md +1 -1
- package/canonical/openspec/templates/tasks.md +8 -0
- package/canonical/rules/CLAUDE.md +5 -4
- package/canonical/rules/routing.md +10 -9
- package/canonical/skills/agent-evaluation/SKILL.md +17 -6
- package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
- package/canonical/skills/agentic-architecture/SKILL.md +4 -0
- package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
- package/canonical/skills/code-review/SKILL.md +10 -3
- package/canonical/skills/code-review/evals/cases.yaml +1118 -27
- package/canonical/skills/git-workflow/SKILL.md +2 -0
- package/canonical/skills/local-code/SKILL.md +2 -2
- package/canonical/skills/local-code/evals/cases.yaml +1 -1
- package/canonical/skills/report-formatting/SKILL.md +10 -2
- package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
- package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
- package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
- package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
- package/canonical/skills/report-formatting/reference/authoring.md +25 -37
- package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
- package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
- package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
- package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
- package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
- package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
- package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
- package/canonical/skills/subagent-driven-development/evals/cases.yaml +3625 -19
- package/canonical/skills/subagent-driven-development/reference/brief.md +187 -35
- package/canonical/skills/subagent-driven-development/reference/handoff.md +96 -2
- package/canonical/skills/subagent-driven-development/reference/loop.md +56 -0
- package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
- package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
- package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4035 -327
- package/dist/adapters/harness/claude-code.d.ts +26 -3
- package/dist/adapters/harness/claude-code.js +214 -43
- package/dist/adapters/harness/opencode.js +17 -3
- package/dist/adapters/harness/plugin.js +28 -17
- package/dist/adapters/memory/doctor.d.ts +172 -33
- package/dist/adapters/memory/doctor.js +750 -139
- package/dist/adapters/memory/migrate.d.ts +95 -0
- package/dist/adapters/memory/migrate.js +497 -0
- package/dist/adapters/memory/paths.d.ts +15 -0
- package/dist/adapters/memory/paths.js +47 -0
- package/dist/adapters/memory/vendored.d.ts +52 -30
- package/dist/adapters/memory/vendored.js +161 -169
- package/dist/adapters/memory/worker-control.d.ts +9 -0
- package/dist/adapters/memory/worker-control.js +30 -0
- package/dist/adapters/sync/diff.js +4 -1
- package/dist/cli/cleanup-prompt.d.ts +35 -0
- package/dist/cli/cleanup-prompt.js +68 -0
- package/dist/cli/cleanup.d.ts +22 -24
- package/dist/cli/cleanup.js +204 -75
- package/dist/cli/cost-quota.d.ts +158 -0
- package/dist/cli/cost-quota.js +645 -0
- package/dist/cli/cost.d.ts +408 -46
- package/dist/cli/cost.js +1731 -57
- package/dist/cli/doctor.d.ts +52 -13
- package/dist/cli/doctor.js +199 -45
- package/dist/cli/hard-cleanup.d.ts +93 -0
- package/dist/cli/hard-cleanup.js +552 -0
- package/dist/cli/install-facts.d.ts +29 -0
- package/dist/cli/install-facts.js +68 -0
- package/dist/cli/json-key-edit.d.ts +18 -0
- package/dist/cli/json-key-edit.js +118 -0
- package/dist/cli/memory-import.d.ts +74 -0
- package/dist/cli/memory-import.js +469 -0
- package/dist/cli/plugin-validate.d.ts +12 -0
- package/dist/cli/plugin-validate.js +43 -0
- package/dist/cli/profile.d.ts +1 -1
- package/dist/cli/profile.js +10 -10
- package/dist/cli/uninstall.d.ts +1 -1
- package/dist/cli/uninstall.js +2 -2
- package/dist/cli/upstream-launchers.d.ts +49 -0
- package/dist/cli/upstream-launchers.js +287 -0
- package/dist/index.js +252 -73
- package/dist/infra/fsutil.d.ts +30 -3
- package/dist/infra/fsutil.js +68 -6
- package/dist/infra/manifest.d.ts +2 -1
- package/dist/infra/manifest.js +3 -2
- package/dist/infra/prices.d.ts +18 -0
- package/dist/infra/prices.js +64 -0
- package/node_modules/@freeharness/core/dist/registry.d.ts +1 -1
- package/node_modules/@freeharness/core/dist/registry.js +3 -3
- package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
- package/node_modules/@freeharness/core/dist/tiers.js +24 -2
- package/node_modules/@freeharness/core/package.json +1 -1
- package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
- package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
- package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
- package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
- package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
- package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
- package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
- package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
- package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
- package/node_modules/@freeharness/sdd/package.json +1 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
- package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
- package/package.json +6 -4
- package/provenance/canonical.PROVENANCE.yaml +3 -3
- package/provenance/memory.PROVENANCE.yaml +472 -6
- package/provenance/sdd.PROVENANCE.yaml +279 -2
- package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
- package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
- package/canonical/openspec/config.base.yaml +0 -89
- 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
|
|
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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// - the data dir (
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
198
|
+
* Read the applied schema_versions via the sqlite3 CLI (no node sqlite dep), read-only.
|
|
183
199
|
*
|
|
184
200
|
* Returns:
|
|
185
|
-
* The
|
|
201
|
+
* The versions, or null when the db/sqlite3 is unavailable.
|
|
186
202
|
*/
|
|
187
|
-
function
|
|
203
|
+
function dbSchemaVersions(dbPath) {
|
|
188
204
|
if (!fs.existsSync(dbPath))
|
|
189
205
|
return null;
|
|
190
|
-
const r = spawnSync("sqlite3", ["-readonly", dbPath, "SELECT
|
|
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
|
|
194
|
-
return
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
|
|
226
|
-
|
|
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,
|
|
286
|
+
const [window, rawModel, observations, tokens] = line.split("|");
|
|
230
287
|
if (window !== "today" && window !== "seven_day")
|
|
231
288
|
continue;
|
|
232
|
-
|
|
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
|
-
|
|
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 `
|
|
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,
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
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 (
|
|
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.
|
|
551
|
-
const simple = str(file.
|
|
552
|
-
const
|
|
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: `
|
|
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 `
|
|
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.
|
|
843
|
+
if (values.FREE_MEM_PROVIDER !== "local")
|
|
574
844
|
return { provider: "claude", model: null };
|
|
575
|
-
const raw = values.
|
|
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:
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
* `memory status`'s WARN
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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 `
|
|
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.
|
|
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
|
-
* `
|
|
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
|
-
|
|
622
|
-
|
|
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 `
|
|
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 `
|
|
630
|
-
* model: Candidate `
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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 (`
|
|
663
|
-
* plus four `
|
|
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
|
-
* `
|
|
689
|
-
* `
|
|
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)
|
|
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 `
|
|
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: `
|
|
836
|
-
* 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
|
-
* `
|
|
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.
|
|
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
|
|
884
|
-
|
|
885
|
-
:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
904
|
-
|
|
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
|
-
|
|
1202
|
+
return [];
|
|
910
1203
|
}
|
|
911
|
-
return def;
|
|
912
1204
|
}
|
|
913
|
-
/**
|
|
914
|
-
function
|
|
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 =
|
|
919
|
-
if (
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1316
|
+
return { port, reachable: false };
|
|
946
1317
|
}
|
|
947
|
-
|
|
948
|
-
let body = "";
|
|
1318
|
+
let health;
|
|
949
1319
|
try {
|
|
950
|
-
|
|
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
|
-
/*
|
|
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
|
|
956
|
-
|
|
957
|
-
|
|
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 =
|
|
1013
|
-
const
|
|
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
|
-
|
|
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(
|
|
1076
|
-
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1079
|
-
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
*
|
|
1163
|
-
*
|
|
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:
|
|
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
|
}
|