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