@staix/agent-hub 0.12.28 → 0.12.30
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/CHANGELOG.md +24 -0
- package/README.md +79 -219
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +1 -1
- package/docs/agent-notes/bus.md +3 -1
- package/docs/agent-notes/daemon.md +13 -0
- package/docs/agent-notes/local-worker.md +2 -2
- package/docs/agent-notes/tasks.md +9 -0
- package/docs/agent-notes/tests.md +1 -1
- package/docs/memory.md +70 -152
- package/docs/operations.md +237 -14
- package/docs/plans/2026-10-16-amem-failure-registry.md +101 -0
- package/docs/plans/2026-10-16-amem-p0-baseline.md +110 -0
- package/docs/plans/2026-10-16-amem-p2-read-tools.md +28 -0
- package/docs/plans/amem-cases.json +482 -0
- package/docs/quickstart.md +9 -2
- package/docs/security.md +39 -2
- package/docs/smoke.md +65 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +113 -5
- package/docs/verification/2026-10-16-0.12.29.md +25 -0
- package/docs/verification/2026-10-16-273-live-verification.md +150 -0
- package/docs/verification/2026-10-17-0.12.30.md +70 -0
- package/docs/verified.json +13 -13
- package/package.json +5 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +64 -44
- package/src/cli/console-state.ts +92 -20
- package/src/cli/console.ts +25 -6
- package/src/cli/help.ts +4 -2
- package/src/cli/identity-audit.ts +2 -2
- package/src/cli/identity.ts +15 -1
- package/src/cli/main.js +2 -2
- package/src/cli/main.ts +78 -39
- package/src/cli/memory.ts +87 -187
- package/src/cli/output.ts +6 -0
- package/src/cli/recovery-package.ts +59 -10
- package/src/cli/upgrade-runtime.ts +60 -18
- package/src/cli/upgrade.ts +8 -0
- package/src/hub/board.ts +2 -1
- package/src/hub/bus.ts +33 -12
- package/src/hub/daemon.ts +141 -31
- package/src/hub/envelope.ts +30 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/git.ts +2 -38
- package/src/hub/hub-tools.ts +5 -1
- package/src/hub/lifecycle.ts +2 -2
- package/src/hub/manager.ts +15 -11
- package/src/hub/memory-candidates.ts +103 -0
- package/src/hub/memory-fail.ts +25 -0
- package/src/hub/memory-observations.ts +47 -0
- package/src/hub/memory-plan-guard.ts +33 -0
- package/src/hub/memory-policy.ts +42 -0
- package/src/hub/memory-profile-check.ts +170 -0
- package/src/hub/memory-profile.ts +192 -0
- package/src/hub/memory-staleness.ts +55 -0
- package/src/hub/path-policy.ts +8 -0
- package/src/hub/project.ts +4 -37
- package/src/hub/read-tools.ts +127 -0
- package/src/hub/tasks.ts +201 -41
- package/src/hub/ui.ts +10 -7
- package/src/local/deny.ts +4 -17
- package/src/memory/brief.ts +8 -5
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +50 -110
- package/src/pi/process-signature.ts +2 -42
- package/src/memory/import.ts +0 -90
- package/src/memory/store.ts +0 -805
- package/src/memory/sync.ts +0 -145
package/src/hub/events.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type HubEvent =
|
|
|
43
43
|
| ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number; preparedRoles?: RoleIdentity[] | null } & Partial<TaskAttribution>)
|
|
44
44
|
| { type: "native_turn_end"; peer: string; id?: string }
|
|
45
45
|
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
46
|
-
| { type: "agent_cli"; peer: string; command: string; refused: boolean }
|
|
46
|
+
| { type: "agent_cli"; peer: string; command: string; refused: boolean; delegated?: true }
|
|
47
47
|
| { type: "permission"; id: string; peer: string; event: "requested" | "answered" | "expired" | "cancelled"; latencyMs?: number; surface?: "console" | "dashboard" | "terminal"; option?: "allow_once" | "allow_always" | "reject_once" | "reject_always" }
|
|
48
48
|
/** Completed native turns that received supervision, not feed enqueues or inferred costs. */
|
|
49
49
|
| { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
|
package/src/hub/git.ts
CHANGED
|
@@ -1,38 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The one helper for every git call the hub makes outside the sandbox (#281): the repository's own configuration
|
|
5
|
-
* must not name a program the hub then runs with its own rights (defense in depth behind the seatbelt profile,
|
|
6
|
-
* which refuses a sandboxed command the writes that would plant one). Every call gets:
|
|
7
|
-
* - core.fsmonitor=false: the file-system monitor is an arbitrary command read from the repo config.
|
|
8
|
-
* - core.hooksPath=/dev/null: hooks come from a non-directory, so every hook lookup fails closed.
|
|
9
|
-
* - diff.external= (empty): no external diff driver.
|
|
10
|
-
* - core.pager=cat and --no-pager: no pager, whatever the config says (stdout is a pipe everywhere anyway).
|
|
11
|
-
* - credential.helper= (empty resets the list) and core.askpass= (empty fails closed), with GIT_TERMINAL_PROMPT=0:
|
|
12
|
-
* no hub command authenticates, so any prompt is a bug and must fail instead of running a configured helper.
|
|
13
|
-
* git has no global switch for filter drivers or textconv: both are per-driver config keys. Of the hub's commands
|
|
14
|
-
* only the snapshot's `add`/`restore` would run a filter driver, and only a content diff would run textconv — the
|
|
15
|
-
* hub's one content diff is `diff --no-index`, which DOES read the repository's config (verified: a planted
|
|
16
|
-
* diff.external runs for it), so that call passes `--no-ext-diff` and `--no-textconv` on top of the config here,
|
|
17
|
-
* and the driver command of a filter lives in the `.git/config` the seatbelt profile refuses to a sandboxed
|
|
18
|
-
* command. GIT_CONFIG_NOSYSTEM/global are left alone: those files are the user's own.
|
|
19
|
-
*/
|
|
20
|
-
const SAFE_CONFIG = ["core.fsmonitor=false", "core.hooksPath=/dev/null", "diff.external=", "core.pager=cat", "credential.helper=", "core.askpass="];
|
|
21
|
-
/** Hub memory sync (#258) pushes to the person's own remote: their global credential helper stays, the rest does not. */
|
|
22
|
-
const CREDENTIAL_KEYS = new Set(["credential.helper=", "core.askpass="]);
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* `["git", ...safe options, ...args]`: use instead of a literal "git" argv anywhere under src/. `credentials` keeps the
|
|
26
|
-
* person's credential helper, for a repository the hub created itself (the memory store), never a project's.
|
|
27
|
-
*/
|
|
28
|
-
export const hubGitArgv = (args: string[], opts: { credentials?: boolean } = {}): string[] =>
|
|
29
|
-
["git", "--no-pager", ...SAFE_CONFIG.filter((c) => !opts.credentials || !CREDENTIAL_KEYS.has(c)).flatMap((c) => ["-c", c]), ...args];
|
|
30
|
-
|
|
31
|
-
/** The caller's env plus the no-prompt switch; existing keys (childEnv, GIT_OPTIONAL_LOCKS=0) pass through. */
|
|
32
|
-
export const hubGitEnv = (env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => ({ ...env, GIT_TERMINAL_PROMPT: "0" });
|
|
33
|
-
|
|
34
|
-
/** spawnSync form of the same call: the env option is merged with hubGitEnv, everything else passes through. */
|
|
35
|
-
export function hubGitSync(args: string[], options: SpawnSyncOptionsWithStringEncoding): SpawnSyncReturns<string> {
|
|
36
|
-
const argv = hubGitArgv(args);
|
|
37
|
-
return spawnSync(argv[0]!, argv.slice(1), { ...options, env: hubGitEnv(options.env ?? process.env) });
|
|
38
|
-
}
|
|
1
|
+
// The portable core owns hardened git invocation; retain the hub names as identical exports.
|
|
2
|
+
export { memoryGitArgv as hubGitArgv, memoryGitEnv as hubGitEnv, memoryGitSync as hubGitSync } from "@staix/agent-mem/platform";
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -57,7 +57,11 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
57
57
|
tool("hub_task_list", "The task board. PII tasks show as [pii].", { state: { type: "string", enum: ["proposed", "in_progress", "in_review", "approved", "changes_requested"] }, ready: { type: "boolean", description: "only proposed tasks with nothing left to wait for" } }),
|
|
58
58
|
tool("hub_review", "Give your verdict on a task you were asked to review: map the changed signatures and call sites to the task's plan or detail, read the check result (hub_task_show has it, with the done summary), and list what is unmet. Two changes_requested in a row move the task to another peer.", { id, head: { type: "string", description: "full SHA actually reviewed; enforce binds approval to the current PR head" }, verdict: { type: "string", enum: ["approved", "changes_requested"] }, note: str, unmet: { type: "array", items: str, description: "each requirement of the plan or detail that the change does not meet" } }, ["id", "verdict"]),
|
|
59
59
|
tool("hub_checkpoint", "Answer a checkpoint request from the hub (quota or context window pressure): what you were doing, what is half done, what whoever continues must know. Write the same to .agenthub/checkpoint.md first if you can.", { summary: str, request_id: { type: "string", description: "The request id supplied by a context checkpoint request; required for context checkpoints" } }, ["summary"]),
|
|
60
|
-
tool("
|
|
60
|
+
tool("hub_memory_search", "Search daemon-scoped visible memory, fails first. Query <=200 UTF-8 bytes; default 8, max 20 lines; whole output <=4 KB. Incomplete excerpts are marked. No project argument.", { query: { type: "string", maxLength: 200, description: "At most 200 UTF-8 bytes, enforced by the daemon" }, limit: { type: "integer", minimum: 1, maximum: 20 } }, ["query"]),
|
|
61
|
+
tool("hub_memory_show", "Read up to 8 visible memory ids or unique prefixes (6..64 UTF-8 bytes each). Original ids redirect; unavailable ids share one refusal. Whole output <=8 KB.", { ids: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 6, maxLength: 64, description: "6 to 64 UTF-8 bytes" } } }, ["ids"]),
|
|
62
|
+
tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Up to 8 starts (<=200 UTF-8 bytes each), 1 or 2 hops; default 60/max 120 nodes, max 240 edges, whole output <=6 KB. No project argument.", { starts: { type: "array", minItems: 1, maxItems: 8, items: { type: "string", minLength: 1, maxLength: 200, description: "At most 200 UTF-8 bytes" } }, hops: { type: "integer", enum: [1, 2] }, limit: { type: "integer", minimum: 1, maximum: 120 } }, ["starts"]),
|
|
63
|
+
tool("hub_memory_correct", "Propose a correction to a visible memory entry. This creates a machine-local candidate only; a person accepts or rejects it with amem review. It never edits a note. id is an exact id or unique visible prefix; text and reason must each fit 4000 UTF-8 bytes.", { id: str, text: str, reason: str }, ["id", "text", "reason"]),
|
|
64
|
+
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's memory and claude-mem); the other agents also get it with their next message. A fail is an approach you tried that does not work, and why: the most useful note, it stops the others spending their quota on it. Do not retry what a fail note rules out without new evidence. Conclusions worth recalling, not chatter. For kind fail, optional approach, symptom, cause, evidence and retry_when strings compose fixed labeled text; text may be omitted when structured fields are supplied. The complete text must fit 4000 UTF-8 bytes and is refused, never truncated, when oversized. A note on an open task is short-term until the task is approved, when its decisions, contracts and fails become long-term; horizon long keeps it now, scope shared lets the person's other projects find it.", { text: str, approach: str, symptom: str, cause: str, evidence: str, retry_when: str, title: str, kind: { type: "string", enum: [...NOTE_KINDS] }, task: id, horizon: { type: "string", enum: ["short", "long"] }, scope: { type: "string", enum: ["project", "shared"] } }),
|
|
61
65
|
];
|
|
62
66
|
|
|
63
67
|
export const TASK_TOOL_NAMES = new Set(TASK_TOOLS.map((t) => t.name));
|
package/src/hub/lifecycle.ts
CHANGED
|
@@ -138,7 +138,7 @@ export async function runProjectDaemon(project: Project, unattended = false): Pr
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv } = {}): Promise<any> {
|
|
141
|
+
export async function startProject(project: Project, options: { unattended?: boolean; env?: NodeJS.ProcessEnv; liveTest?: boolean } = {}): Promise<any> {
|
|
142
142
|
assertLifecycleAvailable();
|
|
143
143
|
const registry = new Registry();
|
|
144
144
|
try {
|
|
@@ -160,7 +160,7 @@ export async function startProject(project: Project, options: { unattended?: boo
|
|
|
160
160
|
// Native sessions can outlive the operation which launched them. An expired
|
|
161
161
|
// operation ID must not make a later ordinary startup load a retired snapshot.
|
|
162
162
|
if (launchEnv.AGENTHUB_RECOVERY_OPERATION !== recoveryLock()) delete launchEnv.AGENTHUB_RECOVERY_OPERATION;
|
|
163
|
-
const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, "daemon",
|
|
163
|
+
const child = spawn(process.execPath, [join(import.meta.dir, "../cli/main.ts"), "--project", project.root, ...(options.liveTest ? ["--live-test"] : []), "daemon",
|
|
164
164
|
...(options.unattended ? ["--unattended"] : [])], {
|
|
165
165
|
cwd: project.root, detached: true, stdio: ["ignore", log, log],
|
|
166
166
|
env: { ...launchEnv, AGENTHUB_STATE_DIR: project.stateDir, AGENTHUB_PROJECT_DIR: project.root,
|
package/src/hub/manager.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { ControlClient, PROTOCOL, RECOVERY_SOURCE_PROTOCOLS } from "./control-cl
|
|
|
9
9
|
import { Registry, type Project } from "./registry.ts";
|
|
10
10
|
import { hubHome } from "./project.ts";
|
|
11
11
|
import { startDashboard } from "./ui.ts";
|
|
12
|
-
import { projectChain } from "
|
|
12
|
+
import { projectChain } from "@staix/agent-mem";
|
|
13
13
|
import type { ProjectInspection } from "./lifecycle.ts";
|
|
14
14
|
import { MAX_COMMAND_MS } from "../cli/terminal-recovery.ts";
|
|
15
15
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -21,12 +21,12 @@ export type Lifecycle = {
|
|
|
21
21
|
startProject(project: Project, options?: { unattended?: boolean; env?: NodeJS.ProcessEnv }): Promise<any>;
|
|
22
22
|
stopProject(project: Project, expectedInstance?: string): Promise<void>;
|
|
23
23
|
};
|
|
24
|
-
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number;
|
|
24
|
+
export type ManagerOptions = { registry?: Registry; lifecycle?: Lifecycle; home?: string; cli?: string; orphanWatchMs?: number; liveTest?: boolean;
|
|
25
25
|
/** Test clock seam: maps nominal forwarding deadlines, never comes from dashboard input. */
|
|
26
26
|
forwardTimeoutMs?: (action: string, nominalMs: number) => number };
|
|
27
27
|
type ManagerHandle = { stop(): Promise<void>; stopped: Promise<void> };
|
|
28
28
|
type Manifest = { port: number; protocol: number; instanceId: string; pid: number; pidSignature?: string };
|
|
29
|
-
const active = new Map<string, { handle: ManagerHandle; issue(): string }>();
|
|
29
|
+
const active = new Map<string, { handle: ManagerHandle; issue(liveTest?: boolean): string }>();
|
|
30
30
|
const CONTROL_TIMEOUT = 3000;
|
|
31
31
|
// A current CLI may authenticate the previous protocol-9 manager while
|
|
32
32
|
// refreshing it, then launch the current manager implementation.
|
|
@@ -178,7 +178,7 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
178
178
|
};
|
|
179
179
|
dashboard = startDashboard({ projects: list, snapshot: (after, input = {}) => act({ ...input, action: "snapshot", after }), action: act });
|
|
180
180
|
server = Bun.serve({ hostname: "127.0.0.1", port: 0, maxRequestBodySize: 1024,
|
|
181
|
-
fetch(req) {
|
|
181
|
+
async fetch(req) {
|
|
182
182
|
if (req.headers.has("origin")) return new Response("forbidden", { status: 403 });
|
|
183
183
|
if (req.headers.get("authorization") !== `Bearer ${token}`) return new Response("unauthorized", { status: 401 });
|
|
184
184
|
if (req.method !== "POST") return new Response("POST required", { status: 405 });
|
|
@@ -186,7 +186,10 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
186
186
|
if (expected && expected !== instanceId) return Response.json({ ok: false, error: "manager instance changed" }, { status: 409 });
|
|
187
187
|
if (stopping) return Response.json({ ok: false, error: "manager is stopping" }, { status: 409 });
|
|
188
188
|
const path = new URL(req.url).pathname;
|
|
189
|
-
if (path === "/open")
|
|
189
|
+
if (path === "/open") {
|
|
190
|
+
const input = await req.json().catch(() => ({})) as { liveTest?: boolean };
|
|
191
|
+
return Response.json({ ok: true, instanceId, protocol: PROTOCOL, url: dashboard!.issue(false, input.liveTest === true), ...(input.liveTest === true ? { liveTest: true } : {}) });
|
|
192
|
+
}
|
|
190
193
|
if (path === "/stop") { setTimeout(() => void stop(), 0); return Response.json({ ok: true, instanceId }); }
|
|
191
194
|
return new Response("not found", { status: 404 });
|
|
192
195
|
},
|
|
@@ -204,31 +207,32 @@ export async function startManager(options: ManagerOptions = {}): Promise<Manage
|
|
|
204
207
|
}, options.orphanWatchMs ?? 10_000);
|
|
205
208
|
orphanWatch.unref?.();
|
|
206
209
|
const handle = { stop, stopped };
|
|
207
|
-
active.set(home, { handle, issue: () => dashboard!.issue() });
|
|
210
|
+
active.set(home, { handle, issue: (liveTest) => dashboard!.issue(false, liveTest) });
|
|
208
211
|
return handle;
|
|
209
212
|
} catch (error) { cleanup(); throw error; }
|
|
210
213
|
}
|
|
211
214
|
|
|
212
|
-
async function managerRequest(home: string, path: "/open" | "/stop"): Promise<{ status: Manifest; body: any }> {
|
|
215
|
+
async function managerRequest(home: string, path: "/open" | "/stop", liveTest = false): Promise<{ status: Manifest; body: any }> {
|
|
213
216
|
const status = readManifest(home);
|
|
214
217
|
if (!status) throw new Error("manager manifest is unavailable");
|
|
215
218
|
if (!SUPPORTED_MANAGER_PROTOCOLS.has(status.protocol)) throw new Error("manager protocol is incompatible; use its matching CLI to stop it");
|
|
216
219
|
const token = readFileSync(files(home).token, "utf8").trim();
|
|
217
220
|
if (!token) throw new Error("manager token is unavailable");
|
|
218
221
|
const response = await fetch(`http://127.0.0.1:${status.port}${path}`, {
|
|
219
|
-
method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, signal: AbortSignal.timeout(CONTROL_TIMEOUT),
|
|
222
|
+
method: "POST", headers: { authorization: `Bearer ${token}`, "x-agenthub-instance": status.instanceId }, ...(liveTest ? { body: JSON.stringify({ liveTest: true }) } : {}), signal: AbortSignal.timeout(CONTROL_TIMEOUT),
|
|
220
223
|
});
|
|
221
224
|
if (!response.ok) throw new Error(`manager refused request (${response.status}); ownership was not verified`);
|
|
222
225
|
const body = await response.json() as { ok?: boolean; instanceId?: string; url?: string };
|
|
223
226
|
if (!body.ok || body.instanceId !== status.instanceId) throw new Error("manager identity changed; refresh before retrying");
|
|
227
|
+
if (liveTest && (body as { liveTest?: boolean }).liveTest !== true) throw new Error("manager cannot restrict delegated dashboard content; use a live-test-capable manager");
|
|
224
228
|
return { status, body };
|
|
225
229
|
}
|
|
226
230
|
|
|
227
231
|
export async function openManager(options: ManagerOptions = {}): Promise<string> {
|
|
228
232
|
const home = options.home ?? hubHome();
|
|
229
233
|
const local = active.get(home);
|
|
230
|
-
if (local) return local.issue();
|
|
231
|
-
try { return (await managerRequest(home, "/open")).body.url; }
|
|
234
|
+
if (local) return local.issue(options.liveTest);
|
|
235
|
+
try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
|
|
232
236
|
catch (error) {
|
|
233
237
|
const old = readManifest(home);
|
|
234
238
|
if (old && processLiveness(old.pid, old.pidSignature) !== "gone") throw error;
|
|
@@ -246,7 +250,7 @@ export async function openManager(options: ManagerOptions = {}): Promise<string>
|
|
|
246
250
|
const deadline = Date.now() + 8000;
|
|
247
251
|
let error: unknown;
|
|
248
252
|
while (Date.now() < deadline) {
|
|
249
|
-
try { return (await managerRequest(home, "/open")).body.url; }
|
|
253
|
+
try { return (await managerRequest(home, "/open", options.liveTest)).body.url; }
|
|
250
254
|
catch (caught) { error = caught; await Bun.sleep(100); }
|
|
251
255
|
}
|
|
252
256
|
throw new Error(`manager readiness not confirmed: ${safeError(error)}; see ${join(f.dir, "manager.log")}`);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { looksSecret, type CandidateStore, type CounterKey, type MemoryStore, type View } from "@staix/agent-mem";
|
|
2
|
+
import type { MemoryScreen } from "./read-tools.ts";
|
|
3
|
+
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
4
|
+
|
|
5
|
+
const bytes = (text: string) => Buffer.byteLength(text, "utf8");
|
|
6
|
+
const refuse = (): never => { throw new PreEffectToolRefusal("memory read unavailable"); };
|
|
7
|
+
export interface CorrectionInput { id: string; text: string; reason: string }
|
|
8
|
+
|
|
9
|
+
/** Validate model input before resolving a memory id or opening the candidate sidecar. */
|
|
10
|
+
export function correctionInput(args: unknown): CorrectionInput {
|
|
11
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) refuse();
|
|
12
|
+
const input = args as Record<string, unknown>;
|
|
13
|
+
const string = (value: unknown, min: number, max: number): value is string => typeof value === "string" && value.trim().length > 0 && !value.includes("\0") && bytes(value) >= min && bytes(value) <= max;
|
|
14
|
+
if (Object.keys(input).some(key => !["id", "text", "reason"].includes(key)) || !string(input.id, 6, 64) || !string(input.text, 1, 4000) || !string(input.reason, 1, 4000)) refuse();
|
|
15
|
+
const result = { id: input.id, text: input.text, reason: input.reason } as CorrectionInput;
|
|
16
|
+
if (bytes(JSON.stringify(result)) > 8192) refuse();
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safe(value: unknown, screen: MemoryScreen): boolean {
|
|
21
|
+
try {
|
|
22
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
23
|
+
return !looksSecret(text) && screen([{ title: null, text }]).length === 1;
|
|
24
|
+
} catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function visibleId(input: string, screen: MemoryScreen): string {
|
|
28
|
+
if (typeof input !== "string" || bytes(input) < 6 || bytes(input) > 64 || !safe(input, screen)) refuse();
|
|
29
|
+
const id = input.replace(/^mem:/, "");
|
|
30
|
+
if (id.length < 6) refuse();
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One screened projection per operation; never retained across calls or revisions. */
|
|
35
|
+
function visibleResolver(store: MemoryStore, project: string, screen: MemoryScreen): (id: string) => View {
|
|
36
|
+
const all = screen(store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).filter(view => safe(view, screen));
|
|
37
|
+
const byId = new Map(all.map(view => [view.id, view]));
|
|
38
|
+
const promoted = new Map<string, View[]>();
|
|
39
|
+
for (const view of all) if (view.promotedFrom) {
|
|
40
|
+
const group = promoted.get(view.promotedFrom) ?? [];
|
|
41
|
+
group.push(view); promoted.set(view.promotedFrom, group);
|
|
42
|
+
}
|
|
43
|
+
const aliases = [...new Set([...byId.keys(), ...promoted.keys()])], exactAliases = new Set(aliases);
|
|
44
|
+
return id => {
|
|
45
|
+
const matched = exactAliases.has(id) ? [id] : aliases.filter(alias => alias.startsWith(id));
|
|
46
|
+
let found: View | undefined;
|
|
47
|
+
if (matched.length === 1) {
|
|
48
|
+
found = byId.get(matched[0]!);
|
|
49
|
+
if (!found) {
|
|
50
|
+
const copies = promoted.get(matched[0]!) ?? [];
|
|
51
|
+
if (copies.length === 1) found = copies[0];
|
|
52
|
+
}
|
|
53
|
+
} else if (matched.length === 0) {
|
|
54
|
+
// Older promotion chains need the core's redirect, but may terminate only in this screened snapshot.
|
|
55
|
+
const redirected = store.get(id, project);
|
|
56
|
+
found = redirected && byId.get(redirected.id);
|
|
57
|
+
}
|
|
58
|
+
if (!found || found.supersededBy) return refuse();
|
|
59
|
+
return found;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resolve only screened visible ids and aliases; unavailable and ambiguous inputs have the same refusal. */
|
|
64
|
+
export function resolveVisibleMemory(store: MemoryStore, project: string, screen: MemoryScreen, input: string): View {
|
|
65
|
+
const id = visibleId(input, screen); // Input rejection precedes any storage work.
|
|
66
|
+
try { return store.withDeferredPromotions(() => visibleResolver(store, project, screen)(id)); }
|
|
67
|
+
catch { return refuse(); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type MemoryCounterContext = Omit<CounterKey, "entryId" | "project">;
|
|
71
|
+
export type MemoryCounterField = "rendered" | "delivered" | "cited";
|
|
72
|
+
|
|
73
|
+
/** Sidecar-only observations: the absent fields stay unknown; no message body is persisted. */
|
|
74
|
+
export class MemoryTelemetry {
|
|
75
|
+
constructor(private readonly sidecar: CandidateStore, private readonly store: MemoryStore, private readonly project: string, private readonly screen: MemoryScreen, private readonly memoryCheck?: () => void) {}
|
|
76
|
+
|
|
77
|
+
record(text: string, context: MemoryCounterContext, field: MemoryCounterField, privateTurn = false): void {
|
|
78
|
+
if (privateTurn || !["rendered", "delivered", "cited"].includes(field)) return;
|
|
79
|
+
try {
|
|
80
|
+
this.memoryCheck?.();
|
|
81
|
+
if (!safe(context, this.screen)) return;
|
|
82
|
+
const ids = new Set<string>();
|
|
83
|
+
for (const match of text.matchAll(/\bmem:([a-f0-9]{8,64})(?![a-zA-Z0-9])/g)) {
|
|
84
|
+
try { ids.add(visibleId(match[1]!, this.screen)); } catch { /* unavailable input */ }
|
|
85
|
+
}
|
|
86
|
+
if (!ids.size) return;
|
|
87
|
+
this.memoryCheck?.();
|
|
88
|
+
this.store.withDeferredPromotions(() => {
|
|
89
|
+
const resolve = visibleResolver(this.store, this.project, this.screen), seen = new Set<string>();
|
|
90
|
+
for (const id of ids) {
|
|
91
|
+
try {
|
|
92
|
+
this.memoryCheck?.();
|
|
93
|
+
const view = resolve(id);
|
|
94
|
+
if (seen.has(view.id)) continue;
|
|
95
|
+
seen.add(view.id);
|
|
96
|
+
this.memoryCheck?.();
|
|
97
|
+
this.sidecar.recordCounter({ ...context, project: this.project, entryId: view.id }, { [field]: 1 }, "public");
|
|
98
|
+
} catch { /* A hidden, ambiguous or unavailable citation reveals no counters or diagnostics. */ }
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
} catch { /* Sidecar telemetry never blocks a delivery or an answer. */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const REMEMBER_TEXT_MAX_BYTES = 4000;
|
|
2
|
+
const FAIL_FIELDS = [
|
|
3
|
+
["approach", "Approach"], ["symptom", "Symptom"], ["cause", "Cause"], ["evidence", "Evidence"], ["retry_when", "Retry when"],
|
|
4
|
+
] as const;
|
|
5
|
+
|
|
6
|
+
/** Compose ordinary v1 text only. Validation precedes policy screening, storage and display caps. */
|
|
7
|
+
export function composeRememberText(input: unknown): string {
|
|
8
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("memory note must be an object");
|
|
9
|
+
const note = input as Record<string, unknown>;
|
|
10
|
+
if (note.text !== undefined && typeof note.text !== "string") throw new Error("text must be a string");
|
|
11
|
+
const text = typeof note.text === "string" ? note.text.trim() : "";
|
|
12
|
+
const fields: string[] = [];
|
|
13
|
+
for (const [name, label] of FAIL_FIELDS) {
|
|
14
|
+
const value = note[name];
|
|
15
|
+
if (value === undefined) continue;
|
|
16
|
+
if (note.kind !== "fail") throw new Error(`${name} is only available for kind fail`);
|
|
17
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be a nonempty string`);
|
|
18
|
+
fields.push(`${label}: ${value.trim()}`);
|
|
19
|
+
}
|
|
20
|
+
if (!text && !fields.length) throw new Error("text is required, or supply structured fields with kind fail");
|
|
21
|
+
const result = text && fields.length ? `${text}\n\n${fields.join("\n")}` : text || fields.join("\n");
|
|
22
|
+
const bytes = Buffer.byteLength(result, "utf8");
|
|
23
|
+
if (bytes > REMEMBER_TEXT_MAX_BYTES) throw new Error(`memory text is ${bytes} UTF-8 bytes; maximum is ${REMEMBER_TEXT_MAX_BYTES} bytes (refused without truncation)`);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Envelope, PeerId } from "./envelope.ts";
|
|
2
|
+
import { HUB } from "./envelope.ts";
|
|
3
|
+
import type { MemoryCounterContext, MemoryTelemetry } from "./memory-candidates.ts";
|
|
4
|
+
|
|
5
|
+
export type MemoryObservationContext = Omit<MemoryCounterContext, "deliveryId">;
|
|
6
|
+
/** Only positively public context is returned. Missing identity fields remain unknown. */
|
|
7
|
+
export type MemoryContext = (peer: PeerId, envelopes: readonly Envelope[]) => MemoryObservationContext | undefined;
|
|
8
|
+
|
|
9
|
+
/** Bind an acknowledgement to the render's context, never to a later turn or session. */
|
|
10
|
+
export class MemoryObservations {
|
|
11
|
+
private readonly pending = new Map<string, { peer: PeerId; context: MemoryObservationContext; text: string }>();
|
|
12
|
+
constructor(private readonly telemetry: MemoryTelemetry, private readonly context: MemoryContext) {}
|
|
13
|
+
|
|
14
|
+
delivery(peer: PeerId, deliveryId: string, out: Envelope[], field: "rendered" | "delivered"): void {
|
|
15
|
+
try {
|
|
16
|
+
if (field === "rendered") {
|
|
17
|
+
const context = this.context(peer, out);
|
|
18
|
+
if (!context || out.some(env => env.private)) return;
|
|
19
|
+
// Ordinary quoted citations are counted as citations, not as a fresh recall rendering.
|
|
20
|
+
const text = out.filter(env => env.kind === "presence" || env.from === HUB && /Memory brief \(/.test(env.body)).map(env => env.body).join("\n");
|
|
21
|
+
if (!text.includes("mem:")) return;
|
|
22
|
+
this.pending.set(deliveryId, { peer, context, text });
|
|
23
|
+
// ponytail: retain at most 2048 live render contexts; an evicted or pre-restart acknowledgement stays unknown.
|
|
24
|
+
// Persist ids-only render context with the journal if measurements need complete crash/recovery coverage.
|
|
25
|
+
while (this.pending.size > 2048) this.pending.delete(this.pending.keys().next().value!);
|
|
26
|
+
this.telemetry.record(text, { ...context, deliveryId }, "rendered");
|
|
27
|
+
} else {
|
|
28
|
+
const prior = this.pending.get(deliveryId);
|
|
29
|
+
if (!prior || prior.peer !== peer) return;
|
|
30
|
+
this.pending.delete(deliveryId);
|
|
31
|
+
const now = this.context(peer, out);
|
|
32
|
+
if (!now || out.some(env => env.private) || now.session !== prior.context.session) return;
|
|
33
|
+
this.telemetry.record(prior.text, { ...prior.context, deliveryId }, "delivered");
|
|
34
|
+
}
|
|
35
|
+
} catch { /* optional sidecar observations never change transport or task outcomes */ }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ponytail: citations without a proven source delivery use message:/summary: observation ids, not journal receipts.
|
|
39
|
+
// Join citations to a verified source delivery if per-delivery causal ratios become a study requirement.
|
|
40
|
+
cite(peer: PeerId, text: string, deliveryId: string, envelopes: readonly Envelope[] = []): void {
|
|
41
|
+
try {
|
|
42
|
+
const context = this.context(peer, envelopes);
|
|
43
|
+
if (!context || envelopes.some(env => env.private)) return;
|
|
44
|
+
this.telemetry.record(text, { ...context, deliveryId }, "cited");
|
|
45
|
+
} catch { /* unknown, never a fabricated zero */ }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { extractNodes, nodePath, type View } from "@staix/agent-mem";
|
|
2
|
+
import type { BriefTask } from "../memory/brief.ts";
|
|
3
|
+
|
|
4
|
+
export interface PlanGuardContext { cwd: string; project: string; repo: string; deny?: string[] }
|
|
5
|
+
|
|
6
|
+
/** Explicit plan/ref names only, with no filesystem probes or free-text inference. */
|
|
7
|
+
export function planGuardNodes(task: BriefTask, context: PlanGuardContext): string[] {
|
|
8
|
+
// Task refs/plan allow 150 explicit inputs; v1 note extraction caps each result at 64 nodes.
|
|
9
|
+
// Normalize each input separately, retaining the original single-file symbol qualification rule.
|
|
10
|
+
const nodes = new Set<string>();
|
|
11
|
+
for (const path of [...(task.refs.paths ?? []), ...(task.plan?.paths ?? [])]) {
|
|
12
|
+
for (const node of extractNodes("", { ...context, paths: [path], checkExists: false })) nodes.add(node);
|
|
13
|
+
}
|
|
14
|
+
const onlyPath = nodes.size === 1 ? nodePath([...nodes][0]!) : undefined;
|
|
15
|
+
for (const symbol of task.plan?.symbols ?? []) {
|
|
16
|
+
for (const node of extractNodes("", { ...context, paths: onlyPath ? [onlyPath] : [], symbols: [symbol], checkExists: false })) nodes.add(node);
|
|
17
|
+
}
|
|
18
|
+
return [...nodes].sort();
|
|
19
|
+
}
|
|
20
|
+
const samePath = (a: string, b: string) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
21
|
+
|
|
22
|
+
/** Fail.about is the source of the graph's rules_out edges in v1; there is no additional stored rules_out field. */
|
|
23
|
+
export function matchingPlanFails<T extends Pick<View, "kind" | "about" | "supersededBy">>(task: BriefTask, notes: readonly T[], context: PlanGuardContext): T[] {
|
|
24
|
+
const nodes = planGuardNodes(task, context);
|
|
25
|
+
if (!nodes.length) return [];
|
|
26
|
+
return notes.filter(note => note.kind === "fail" && !note.supersededBy && note.about.some(about => nodes.some(node => {
|
|
27
|
+
if (node === about) return true;
|
|
28
|
+
if (node.startsWith("symbol:") && about.startsWith("symbol:")) return false; // Qualified symbols require both name and file identity.
|
|
29
|
+
const path = nodePath(node), other = nodePath(about);
|
|
30
|
+
// File/directory scope intersects at a whole path component, never a lexical substring.
|
|
31
|
+
return !!path && !!other && samePath(path, other);
|
|
32
|
+
})));
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { detectSignals, type Routing } from "./routing.ts";
|
|
2
|
+
import { matchesPolicy } from "./path-policy.ts";
|
|
3
|
+
import { looksSecret, type PromotionScreen } from "@staix/agent-mem";
|
|
4
|
+
import { nodePath, pathCandidates } from "@staix/agent-mem";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a note is about a path the `pii.paths` policy makes private: a stored node's path, a path-like word of its
|
|
8
|
+
* text, or, for a policy entry with no glob that a word split cannot find (a space in it), that entry written out.
|
|
9
|
+
*/
|
|
10
|
+
export function mentionsPrivatePath(text: string, about: string[], policy: string[], cwd: string): boolean {
|
|
11
|
+
if (!policy.length) return false;
|
|
12
|
+
const nfc = (v: string) => v.normalize("NFC"), body = nfc(text), entries = policy.map(nfc);
|
|
13
|
+
const paths = [...about.map(nodePath), ...pathCandidates(body)].filter((p): p is string => p !== undefined).map(nfc);
|
|
14
|
+
if (paths.some((p) => matchesPolicy(cwd, p, entries, true))) return true;
|
|
15
|
+
// The entry's literal part (up to its first glob character) written out: catches a path a word split breaks (a space).
|
|
16
|
+
return entries.some((entry) => { const lit = entry.replace(/^\.\//, "").split(/[*?[\]{}]/)[0]!.replace(/\/+$/, ""); return /[/\s]/.test(lit) && body.includes(lit); });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Notes whose title, text, nodes, task key or project key match the PII patterns of `routing`, or that are about a path
|
|
21
|
+
* the project's `pii.paths` (`policy`) makes private, left out: what may go to a model.
|
|
22
|
+
*/
|
|
23
|
+
export function withoutPii<T extends { title: string | null; text: string; about?: string[]; task?: string | null; project?: string }>(notes: T[], routing: Routing, cwd: string, policy: string[] = []): T[] {
|
|
24
|
+
// A task key's `#<id>@<created ms>` is the hub's own numbers, not note content: a digit-run pattern must not withhold a
|
|
25
|
+
// clean note for it. The project part of the key stays screened.
|
|
26
|
+
const keyText = (k: string) => k.replace(/#\d+(?:@\d+)?$/, "");
|
|
27
|
+
return notes.filter((v) => !mentionsPrivatePath(`${v.title ?? ""}\n${v.text}`, v.about ?? [], policy, cwd) && !detectSignals({ title: v.title ?? "",
|
|
28
|
+
detail: [v.text, ...(v.about ?? []).map((n) => n.startsWith("task:") ? keyText(n) : n), keyText(v.task ?? ""), v.project ?? ""].join("\n"), refs: {} }, routing, cwd).includes("pii"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The promotion screen for one project: another project's notes are left to that project's own processes; this one's
|
|
33
|
+
* move only when no secret, PII pattern or private path (`policy()`, read when asked) is in them.
|
|
34
|
+
*/
|
|
35
|
+
export function promotionScreen(project: string, cwd: string, routing: () => Routing, policy: () => string[], taskPaths: (task: string) => string[] = () => []): PromotionScreen {
|
|
36
|
+
return (v) => {
|
|
37
|
+
if (v.project !== project) return "skip";
|
|
38
|
+
// The task's current paths too, with no denylist: a path the note's own nodes leave out is still checked.
|
|
39
|
+
const screened = { ...v, about: [...v.about, ...(v.task ? taskPaths(v.task) : []).map((p) => `path:${p}`)] };
|
|
40
|
+
return looksSecret(`${v.title ?? ""}\n${v.text}`) || !withoutPii([screened], routing(), cwd, policy()).length ? "keep" : "promote";
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { accessSync, constants, lstatSync } from "node:fs";
|
|
2
|
+
import { join, parse, resolve, sep } from "node:path";
|
|
3
|
+
import { registeredProjects } from "../cli/upgrade.ts";
|
|
4
|
+
import { ControlClient, readControl, type ControlDescriptor } from "./control-client.ts";
|
|
5
|
+
import { canonicalPath, hubHome, realPath } from "./project.ts";
|
|
6
|
+
import { processLiveness, type Liveness } from "../pi/process-signature.ts";
|
|
7
|
+
import type { Project } from "./registry.ts";
|
|
8
|
+
import type { UnboundMemoryProfileReceipt } from "./memory-profile.ts";
|
|
9
|
+
|
|
10
|
+
export interface ExpectedMemoryProfile {
|
|
11
|
+
home: string;
|
|
12
|
+
profile: "shared-v1" | "generation";
|
|
13
|
+
generation?: string;
|
|
14
|
+
selectorRevision: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RunningMemoryProfile extends ExpectedMemoryProfile {
|
|
17
|
+
agentMemVersion: string;
|
|
18
|
+
state: "ready" | "stale" | "unavailable";
|
|
19
|
+
skippedRows?: number;
|
|
20
|
+
}
|
|
21
|
+
export type UnboundMemoryProfile = UnboundMemoryProfileReceipt;
|
|
22
|
+
export type ReportedMemoryProfile = RunningMemoryProfile | UnboundMemoryProfile;
|
|
23
|
+
export interface ProfileEvidence {
|
|
24
|
+
project: Project;
|
|
25
|
+
liveness: Liveness;
|
|
26
|
+
descriptor?: ControlDescriptor;
|
|
27
|
+
status?: unknown;
|
|
28
|
+
/** Resolved only after authenticated status; unknown is never treated as a different home. */
|
|
29
|
+
canonicalHome?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface CheckedMemoryDaemon {
|
|
32
|
+
projectId: string;
|
|
33
|
+
projectRoot: string;
|
|
34
|
+
pid: number | null;
|
|
35
|
+
instanceId: string | null;
|
|
36
|
+
hubVersion: string | null;
|
|
37
|
+
agentMemVersion: string | null;
|
|
38
|
+
memoryProfile: ReportedMemoryProfile | null;
|
|
39
|
+
scope: "same-home" | "different-home" | "unknown";
|
|
40
|
+
ready: boolean;
|
|
41
|
+
quiesced?: true;
|
|
42
|
+
reason?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface MemoryProfileCheck {
|
|
45
|
+
ready: boolean;
|
|
46
|
+
expected: ExpectedMemoryProfile;
|
|
47
|
+
/** Only this registry was inventoried; other custom registries are outside this receipt. */
|
|
48
|
+
registryHome?: string;
|
|
49
|
+
daemons: CheckedMemoryDaemon[];
|
|
50
|
+
reason?: string;
|
|
51
|
+
}
|
|
52
|
+
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
const revision = (value: unknown): value is string => value === "absent" || typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
|
54
|
+
const generationCapable = (version: string): boolean => {
|
|
55
|
+
const parts = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(version);
|
|
56
|
+
return !!parts && (Number(parts[1]) > 0 || Number(parts[2]) >= 3);
|
|
57
|
+
};
|
|
58
|
+
function receipt(value: unknown): ReportedMemoryProfile | undefined {
|
|
59
|
+
if (!object(value) || typeof value.home !== "string" || !value.home || value.home.length > 4096 || typeof value.agentMemVersion !== "string") return undefined;
|
|
60
|
+
if (!Object.hasOwn(value, "profile") && ["disabled", "unavailable"].includes(String(value.state))) {
|
|
61
|
+
if (["generation", "selectorRevision", "skippedRows"].some(field => Object.hasOwn(value, field))) return undefined;
|
|
62
|
+
return { home: value.home, agentMemVersion: value.agentMemVersion, state: value.state as UnboundMemoryProfile["state"] };
|
|
63
|
+
}
|
|
64
|
+
if (!["shared-v1", "generation"].includes(String(value.profile)) || !["ready", "stale", "unavailable"].includes(String(value.state)) || !revision(value.selectorRevision)
|
|
65
|
+
|| value.profile === "generation" && (typeof value.generation !== "string" || !/^[a-f0-9]{32}$/.test(value.generation))
|
|
66
|
+
|| value.profile === "shared-v1" && value.generation !== undefined
|
|
67
|
+
|| value.skippedRows !== undefined && (!Number.isSafeInteger(value.skippedRows) || Number(value.skippedRows) < 0)) return undefined;
|
|
68
|
+
return { home: value.home, profile: value.profile as RunningMemoryProfile["profile"], ...(value.generation ? { generation: value.generation as string } : {}),
|
|
69
|
+
selectorRevision: value.selectorRevision, state: value.state as RunningMemoryProfile["state"], agentMemVersion: value.agentMemVersion,
|
|
70
|
+
...(value.skippedRows !== undefined ? { skippedRows: value.skippedRows as number } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Evaluate finite evidence only; installed package metadata is deliberately not an input. */
|
|
74
|
+
export function evaluateMemoryProfileCheck(expected: ExpectedMemoryProfile, evidence: readonly ProfileEvidence[]): MemoryProfileCheck {
|
|
75
|
+
const daemons: CheckedMemoryDaemon[] = [];
|
|
76
|
+
for (const item of evidence) {
|
|
77
|
+
if (item.liveness === "gone") continue;
|
|
78
|
+
const { project, descriptor, status } = item;
|
|
79
|
+
const result: CheckedMemoryDaemon = { projectId: project.id, projectRoot: project.root, pid: project.pid, instanceId: project.instanceId,
|
|
80
|
+
hubVersion: null, agentMemVersion: null, memoryProfile: null, scope: "unknown", ready: false };
|
|
81
|
+
const reject = (reason: string) => { result.reason = reason; daemons.push(result); };
|
|
82
|
+
if (item.liveness !== "live" || !project.pidSignature || !project.instanceId || !project.pid) { reject("running owner identity is unknown"); continue; }
|
|
83
|
+
if (!descriptor || descriptor.pid !== project.pid || descriptor.pidSignature !== project.pidSignature || descriptor.projectId !== project.id
|
|
84
|
+
|| descriptor.instanceId !== project.instanceId || descriptor.cwd !== project.root || !object(status) || status.pid !== project.pid
|
|
85
|
+
|| status.projectId !== project.id || status.instanceId !== project.instanceId || status.cwd !== project.root) { reject("authenticated running identity was not confirmed"); continue; }
|
|
86
|
+
if (typeof status.version === "string") result.hubVersion = status.version;
|
|
87
|
+
const bound = receipt(status.memoryProfile);
|
|
88
|
+
if (!bound || !item.canonicalHome) { reject("running memory home/profile receipt is unavailable"); continue; }
|
|
89
|
+
result.memoryProfile = bound; result.agentMemVersion = bound.agentMemVersion;
|
|
90
|
+
if (item.canonicalHome !== expected.home) { result.scope = "different-home"; result.reason = "different canonical memory home"; daemons.push(result); continue; }
|
|
91
|
+
result.scope = "same-home";
|
|
92
|
+
if (!result.hubVersion || !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(result.hubVersion) || !generationCapable(bound.agentMemVersion)) { reject("running memory library does not confirm generation support"); continue; }
|
|
93
|
+
if (bound.profile === undefined) {
|
|
94
|
+
result.ready = true; result.quiesced = true; result.reason = `memory handle ${bound.state}; quiesced`; daemons.push(result); continue;
|
|
95
|
+
}
|
|
96
|
+
if (bound.state !== "ready") { reject("running memory binding is not ready"); continue; }
|
|
97
|
+
if (bound.profile !== expected.profile || bound.generation !== expected.generation || bound.selectorRevision !== expected.selectorRevision) { reject("running memory binding differs from the selected profile"); continue; }
|
|
98
|
+
result.ready = true; daemons.push(result);
|
|
99
|
+
}
|
|
100
|
+
const relevant = daemons.filter(row => row.scope !== "different-home");
|
|
101
|
+
const ready = relevant.every(row => row.ready);
|
|
102
|
+
return { ready, expected, daemons, ...(!relevant.length ? { reason: "no live binding in this registry for this memory home; quiesced in this inventory only" } : {}) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Reuse the recovery planner's readonly SQLite reader: never constructs/migrates a Registry. */
|
|
106
|
+
export function readProfileCheckProjects(registryHome = hubHome()): Project[] {
|
|
107
|
+
const absolute = resolve(registryHome), root = parse(absolute).root;
|
|
108
|
+
let cursor = root;
|
|
109
|
+
for (const part of absolute.slice(root.length).split(sep).filter(Boolean)) {
|
|
110
|
+
cursor = join(cursor, part);
|
|
111
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
112
|
+
try { stat = lstatSync(cursor); }
|
|
113
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw new Error("profile-check registry path unavailable"); }
|
|
114
|
+
// Existing directory aliases are supported, but a dangling alias is not proof of an absent registry.
|
|
115
|
+
if (stat.isSymbolicLink()) { try { stat = lstatSync(realPath(cursor)); } catch { throw new Error("profile-check registry path unavailable"); } }
|
|
116
|
+
if (!stat.isDirectory() || !(Number(stat.mode) & 0o111)) throw new Error("profile-check registry path unavailable");
|
|
117
|
+
accessSync(cursor, constants.X_OK);
|
|
118
|
+
}
|
|
119
|
+
const file = join(absolute, "registry.db");
|
|
120
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
121
|
+
try { stat = lstatSync(file); }
|
|
122
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw new Error("profile-check registry unavailable"); }
|
|
123
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !(Number(stat.mode) & 0o444)) throw new Error("profile-check registry unavailable");
|
|
124
|
+
accessSync(file, constants.R_OK);
|
|
125
|
+
return registeredProjects(canonicalPath(absolute));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function probeMemoryProfile(project: Project): Promise<ProfileEvidence> {
|
|
129
|
+
const liveness = project.pid ? processLiveness(project.pid, project.pidSignature) : project.instanceId ? "unknown" : "gone";
|
|
130
|
+
const evidence: ProfileEvidence = { project, liveness };
|
|
131
|
+
if (liveness !== "live" || !project.pidSignature || !project.instanceId) return evidence;
|
|
132
|
+
const descriptor = readControl(project.stateDir);
|
|
133
|
+
evidence.descriptor = descriptor;
|
|
134
|
+
if (!descriptor || descriptor.pid !== project.pid || descriptor.pidSignature !== project.pidSignature || descriptor.projectId !== project.id
|
|
135
|
+
|| descriptor.instanceId !== project.instanceId || descriptor.cwd !== project.root) return evidence;
|
|
136
|
+
let client: ControlClient | undefined;
|
|
137
|
+
try {
|
|
138
|
+
client = await ControlClient.connect(project.stateDir, { role: "console", projectId: project.id, instanceId: project.instanceId, projectRoot: project.root }, 3000, descriptor.protocol);
|
|
139
|
+
const reply = await client.request({ t: "status" }, 5000);
|
|
140
|
+
if (reply.t !== "status" || reply.ok === false) return evidence;
|
|
141
|
+
evidence.status = reply.status;
|
|
142
|
+
const bound = object(reply.status) ? receipt(reply.status.memoryProfile) : undefined;
|
|
143
|
+
if (bound) evidence.canonicalHome = canonicalPath(bound.home);
|
|
144
|
+
evidence.liveness = processLiveness(project.pid!, project.pidSignature);
|
|
145
|
+
} catch { /* A timeout, old protocol or refusal is unknown evidence, never permission to migrate. */ }
|
|
146
|
+
finally { client?.close(); }
|
|
147
|
+
return evidence;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function checkMemoryProfiles(expected: ExpectedMemoryProfile, options: {
|
|
151
|
+
registryHome?: string;
|
|
152
|
+
projects?: readonly Project[];
|
|
153
|
+
probe?: (project: Project) => Promise<ProfileEvidence>;
|
|
154
|
+
} = {}): Promise<MemoryProfileCheck> {
|
|
155
|
+
let projects: readonly Project[];
|
|
156
|
+
let registryHome = resolve(options.registryHome ?? hubHome());
|
|
157
|
+
try { projects = options.projects ?? readProfileCheckProjects(registryHome); registryHome = canonicalPath(registryHome); }
|
|
158
|
+
catch { return { ready: false, expected, registryHome, daemons: [], reason: "project registry could not be read without mutation" }; }
|
|
159
|
+
const evidence: ProfileEvidence[] = [];
|
|
160
|
+
for (const project of projects) evidence.push(await (options.probe ?? probeMemoryProfile)(project));
|
|
161
|
+
return { ...evaluateMemoryProfileCheck({ ...expected, home: canonicalPath(expected.home) }, evidence), registryHome };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function formatMemoryProfileCheck(result: MemoryProfileCheck): string {
|
|
165
|
+
const lines = [`memory profile-check: ${result.ready ? "ready" : "not ready"}`, `home: ${result.expected.home}`, `selected: ${result.expected.profile}${result.expected.generation ? ` ${result.expected.generation}` : ""}`];
|
|
166
|
+
if (result.registryHome) lines.push(`registry inventory: ${result.registryHome}`, "scope: this registry only; check other custom registries separately before migration");
|
|
167
|
+
if (result.reason) lines.push(result.reason);
|
|
168
|
+
for (const row of result.daemons) lines.push(`${row.projectId} pid:${row.pid ?? "unknown"} hub:${row.hubVersion ?? "unknown"} agent-mem:${row.agentMemVersion ?? "unknown"} ${row.quiesced ? "unbound" : row.memoryProfile?.profile ?? "unknown"}${row.memoryProfile?.generation ? ` ${row.memoryProfile.generation}` : ""}: ${row.quiesced ? row.reason : row.ready ? "ready" : row.reason ?? "not ready"}`);
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|