@staix/agent-hub 0.34.0 → 0.35.0
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 +51 -0
- package/README.md +35 -6
- package/docs/adding-an-ai-tool.md +2 -2
- package/docs/addons/claude-mem.md +4 -4
- package/docs/addons/open-code-review.md +1 -1
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +9 -5
- package/docs/agent-notes/budget.md +1 -1
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +4 -3
- package/docs/agent-notes/local-worker.md +1 -1
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +1 -1
- package/docs/agent-notes/tests.md +5 -0
- package/docs/design/adapters.md +110 -2
- package/docs/design/agent-hub-architecture.md +63 -1
- package/docs/design/agent-hub.md +6 -0
- package/docs/design/bus.md +18 -0
- package/docs/design/execution-reference.md +146 -0
- package/docs/design/task-board.md +10 -0
- package/docs/dogfood.md +33 -12
- package/docs/events.md +38 -20
- package/docs/memory.md +21 -7
- package/docs/operations.md +539 -24
- package/docs/quickstart.md +18 -4
- package/docs/research.md +71 -13
- package/docs/roles.md +1 -1
- package/docs/sdlc.md +9 -7
- package/docs/security.md +87 -11
- package/docs/smoke.md +14 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +8 -3
- package/docs/verification/2026-11-07-0.34.1.md +54 -0
- package/docs/verification/2026-11-07-compat-python-startup-1248.md +51 -0
- package/docs/verification/2026-11-07-compat-receipt-harness-1248.md +105 -0
- package/docs/verification/2026-11-07-compat-receipt-producers-1248.md +96 -0
- package/docs/verification/2026-11-07-compat-workflow-1248.md +94 -0
- package/docs/verification/2026-11-07-daemon-startup-1232.md +169 -0
- package/docs/verification/2026-11-07-jcode-receipt-reconstruction-1248.md +118 -0
- package/docs/verification/2026-11-07-kimi-delayed-usage.md +34 -0
- package/docs/verification/2026-11-07-kimi-review-watch-1284.md +115 -0
- package/docs/verification/2026-11-08-agy-1.5.3.md +96 -0
- package/docs/verification/2026-11-08-agy-acp-t0.md +56 -0
- package/docs/verification/2026-11-08-hub-visual-1058.md +161 -0
- package/docs/verification/2026-11-08-jcode-0991-model-setter-1317.md +143 -0
- package/docs/verification/2026-11-08-kimi-252-model-setter-1317.md +137 -0
- package/docs/verification/2026-11-08-kimi-tui-completion-1282.md +45 -0
- package/docs/verification/2026-11-08-kimi-wire-yield-1283.md +31 -0
- package/docs/verification/2026-11-08-live-model-override-1317.md +250 -0
- package/docs/verification/2026-11-08-qwen-0269-acp.md +138 -0
- package/docs/verification/2026-11-08-qwen-0269-model-setter-1317.md +91 -0
- package/docs/verification/2026-11-09-0.35.0.md +129 -0
- package/docs/verification/2026-11-09-headless-records-1320.md +157 -0
- package/docs/verification/claude-permission-transport-native.py +10 -3
- package/docs/verification/claude-shell-wrapper-native.py +8 -8
- package/docs/verified.json +245 -15
- package/package.json +3 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/kimi-hook.js +20397 -0
- package/plugins/agent-hub/kimi-review-policy-worker.js +217 -0
- package/plugins/agent-hub/server.js +2701 -664
- package/scripts/check-docs.mjs +12 -1
- package/src/adapters/acp-model-policy.ts +56 -0
- package/src/adapters/acp.ts +175 -5
- package/src/adapters/agy/entry.ts +8 -5
- package/src/adapters/agy/hooks.ts +65 -15
- package/src/adapters/channel-peer.ts +33 -1
- package/src/adapters/claude-implementer.ts +16 -3
- package/src/adapters/codex-appserver.ts +118 -8
- package/src/adapters/codex-review-isolation.ts +3 -1
- package/src/adapters/compat.ts +11 -6
- package/src/adapters/definitions/builtin-metadata.ts +8 -1
- package/src/adapters/definitions/native-cli-entry-kimi.ts +76 -7
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -2
- package/src/adapters/definitions/native-cli-preview-kimi.ts +9 -2
- package/src/adapters/definitions/reviewer-variants.ts +3 -1
- package/src/adapters/kimi-review-policy-worker.js +2 -0
- package/src/adapters/kimi-review-policy-worker.ts +85 -0
- package/src/adapters/kimi-review-policy.ts +68 -0
- package/src/adapters/kimi-review.ts +41 -41
- package/src/adapters/kimi-tui-facts.ts +153 -0
- package/src/adapters/kimi-wire-usage.ts +33 -8
- package/src/adapters/local-worker.ts +59 -20
- package/src/adapters/model-token.ts +3 -0
- package/src/adapters/pi.ts +27 -5
- package/src/adapters/registry-types.ts +34 -3
- package/src/adapters/registry.ts +1 -0
- package/src/adapters/reviewer-policy-watch.ts +86 -0
- package/src/adapters/reviewer-print.ts +80 -18
- package/src/cli/agents.ts +1 -1
- package/src/cli/facts-hook.ts +4 -2
- package/src/cli/help.ts +3 -2
- package/src/cli/identity.ts +3 -0
- package/src/cli/kimi-facts-hook.ts +11 -0
- package/src/cli/kimi-plugin.ts +157 -0
- package/src/cli/main.ts +48 -9
- package/src/cli/model.ts +52 -0
- package/src/cli/output.ts +23 -1
- package/src/cli/recovery-package.ts +15 -7
- package/src/cli/reset.ts +1 -1
- package/src/cli/status-lines.ts +8 -4
- package/src/hub/budget.ts +1 -1
- package/src/hub/bus.ts +40 -2
- package/src/hub/code-facts.ts +10 -3
- package/src/hub/completions.ts +3 -1
- package/src/hub/config-apply.ts +2 -1
- package/src/hub/control-client.ts +1 -1
- package/src/hub/daemon.ts +1050 -106
- package/src/hub/deliverable-files.ts +38 -5
- package/src/hub/dogfood-profile.ts +1 -1
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +28 -18
- package/src/hub/execution-lineage.ts +120 -0
- package/src/hub/execution-reference.ts +36 -0
- package/src/hub/hub-tools.ts +21 -2
- package/src/hub/instances.ts +4 -3
- package/src/hub/kimi-events.ts +46 -0
- package/src/hub/kimi-usage.ts +132 -29
- package/src/hub/lifecycle.ts +31 -15
- package/src/hub/limits.ts +13 -4
- package/src/hub/manager.ts +15 -2
- package/src/hub/memory-policy.ts +2 -1
- package/src/hub/memory-sync-attestation.ts +54 -0
- package/src/hub/memory-sync-state.ts +20 -5
- package/src/hub/model-overrides.ts +115 -0
- package/src/hub/native-hook-launch.ts +12 -3
- package/src/hub/operational-view.ts +2 -2
- package/src/hub/peers.ts +27 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/person-figures.ts +88 -0
- package/src/hub/report.ts +38 -4
- package/src/hub/research-dataset.ts +158 -0
- package/src/hub/research-memory.ts +68 -0
- package/src/hub/research-quality.ts +118 -0
- package/src/hub/research-record.ts +279 -0
- package/src/hub/research.ts +126 -27
- package/src/hub/restart.ts +14 -0
- package/src/hub/settings.ts +3 -0
- package/src/hub/startup-event-seeds.ts +51 -0
- package/src/hub/startup-readiness.ts +65 -0
- package/src/hub/startup-timing.ts +38 -0
- package/src/hub/tasks.ts +29 -9
- package/src/hub/ui.ts +9 -5
- package/src/hub/visual-assets.ts +50 -0
- package/src/hub/visual.ts +257 -0
- package/src/memory/brief.ts +169 -14
- package/src/memory/capture.ts +2 -1
- package/src/memory/recall.ts +46 -5
- package/src/memory/record-provenance.ts +82 -0
- package/src/memory/records.ts +116 -0
- package/src/models/route/adec.ts +190 -0
- package/src/models/route/config.ts +37 -3
- package/src/models/route/decision-evidence.ts +83 -0
- package/src/models/route/decision.ts +143 -0
- package/src/models/route/runtime.ts +13 -6
- package/src/ui/index.html +134 -6
- package/src/ui/plugin.ts +65 -4
- package/templates/AGENTS.block.md +1 -1
- package/templates/config.json +1 -1
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +1 -1
- package/templates/profiles/writing.json +1 -1
package/src/hub/ui.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
12
12
|
|
|
13
13
|
/** What a request's session may do (#269): `settings` while a session opened by `ahub ui --settings` is inside its window. */
|
|
14
|
-
export interface DashboardSession { settings: boolean; settingsUntil?: number; liveTest?: true }
|
|
14
|
+
export interface DashboardSession { settings: boolean; settingsUntil?: number; liveTest?: true; /** Host-only verified session identity; never browser input or public metadata. */ readonly authorityId?: string }
|
|
15
15
|
export interface DashboardOptions {
|
|
16
16
|
snapshot: (after: number, input?: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
17
17
|
action: (input: Record<string, unknown>, session?: DashboardSession) => unknown | Promise<unknown>;
|
|
@@ -51,7 +51,7 @@ export function renderDashboard(): { html: string; styles: string[]; markup: str
|
|
|
51
51
|
export function startDashboard(options: DashboardOptions) {
|
|
52
52
|
const now = options.now ?? Date.now;
|
|
53
53
|
const tickets = new Map<string, { expires: number; settings: boolean; liveTest?: true }>();
|
|
54
|
-
const sessions = new Map<string, { expires: number; settingsUntil?: number; liveTest?: true }>();
|
|
54
|
+
const sessions = new Map<string, { expires: number; authorityId: string; settingsUntil?: number; liveTest?: true }>();
|
|
55
55
|
// Inject the same pure public-board model used by the console before computing CSP hashes.
|
|
56
56
|
const { html } = renderDashboard();
|
|
57
57
|
const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
|
|
@@ -70,8 +70,12 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
70
70
|
for (const [key, entry] of map) if (entry.expires <= now()) map.delete(key);
|
|
71
71
|
}
|
|
72
72
|
const sessionOf = (cookie: string | undefined): DashboardSession => {
|
|
73
|
-
const
|
|
74
|
-
|
|
73
|
+
const verified = cookie ? sessions.get(cookie) : undefined, until = verified?.settingsUntil;
|
|
74
|
+
const session: DashboardSession = { ...(until !== undefined && until > now() ? { settings: true, settingsUntil: until } : { settings: false }), ...(verified?.liveTest ? { liveTest: true as const } : {}) };
|
|
75
|
+
// A separate opaque id binds previews to this verified cookie without exposing the credential.
|
|
76
|
+
// Non-enumerable metadata also stays out of accidental snapshot spreads and JSON serialization.
|
|
77
|
+
if (verified) Object.defineProperty(session, "authorityId", { value: verified.authorityId });
|
|
78
|
+
return session;
|
|
75
79
|
};
|
|
76
80
|
const server = Bun.serve({
|
|
77
81
|
hostname: "127.0.0.1",
|
|
@@ -113,7 +117,7 @@ export function startDashboard(options: DashboardOptions) {
|
|
|
113
117
|
if (!issued || !tickets.delete(ticket)) return reject(401, "ticket expired or used; run ahub ui");
|
|
114
118
|
if (sessions.size >= LIMIT) return reject(429, "too many sessions; try later");
|
|
115
119
|
const session = secret();
|
|
116
|
-
sessions.set(session, { expires: now() + SESSION_MS, ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}), ...(issued.liveTest ? { liveTest: true } : {}) });
|
|
120
|
+
sessions.set(session, { expires: now() + SESSION_MS, authorityId: secret(), ...(issued.settings ? { settingsUntil: now() + SETTINGS_MS } : {}), ...(issued.liveTest ? { liveTest: true } : {}) });
|
|
117
121
|
return json({ ok: true }, 200, { "Set-Cookie": `${cookieName}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MS / 1000}` });
|
|
118
122
|
}
|
|
119
123
|
if (path === "/projects") {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
import { readGuardedDeliverable, type DeliverablePorts } from "./deliverable-files.ts";
|
|
3
|
+
import type { VisualFile, VisualInput, VisualInputSide, VisualPayload, VisualSide } from "./visual.ts";
|
|
4
|
+
export type { VisualFile } from "./visual.ts";
|
|
5
|
+
|
|
6
|
+
export const VISUAL_ASSET_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
export class VisualAssetRefusal extends Error {}
|
|
8
|
+
const refuse = (field: string, cause: string, next: string): never => { throw new VisualAssetRefusal(`visual ${field}: ${cause}; ${next}`); };
|
|
9
|
+
|
|
10
|
+
/** Check both the caller's extension and the actual bytes. No active SVG/XML format is served. */
|
|
11
|
+
function imageMime(path: string, data: Uint8Array): VisualFile["mime"] | undefined {
|
|
12
|
+
const extension = extname(path).toLowerCase();
|
|
13
|
+
if (extension === ".png" && data.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => data[index] === value)) return "image/png";
|
|
14
|
+
if ([".jpg", ".jpeg"].includes(extension) && data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
|
|
15
|
+
if (extension === ".webp" && data.length >= 12 && data[0] === 82 && data[1] === 73 && data[2] === 70 && data[3] === 70
|
|
16
|
+
&& data[8] === 87 && data[9] === 69 && data[10] === 66 && data[11] === 80) return "image/webp";
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const choose = "choose a public project PNG, JPEG or WebP file no larger than 8 MiB and retry";
|
|
20
|
+
|
|
21
|
+
/** Input text/path screening belongs to visual.ts; this path adds host-owned byte, content and physical-file evidence only. */
|
|
22
|
+
export async function resolveVisualAssets(input: VisualInput, id: string, ports: DeliverablePorts): Promise<{ payload: VisualPayload; files: VisualFile[] }> {
|
|
23
|
+
if (input.kind !== "beforeAfter") return { payload: input, files: [] };
|
|
24
|
+
const files: VisualFile[] = [], identities: { dev: number; ino: number }[] = [];
|
|
25
|
+
const side = async (value: VisualInputSide, name: "before" | "after"): Promise<VisualSide> => {
|
|
26
|
+
if ("text" in value) return value;
|
|
27
|
+
let read: Awaited<ReturnType<typeof readGuardedDeliverable>>;
|
|
28
|
+
try { read = await readGuardedDeliverable(value.path, ports, VISUAL_ASSET_BYTES); }
|
|
29
|
+
catch { return refuse(`${name}.path`, "the image is unavailable or not allowed", choose); }
|
|
30
|
+
const mime = imageMime(value.path, read.data);
|
|
31
|
+
if (!mime) return refuse(`${name}.path`, "only PNG, JPEG or WebP image files are supported", "export a supported image and retry");
|
|
32
|
+
if (identities.some(identity => identity.dev === read.identity.dev && identity.ino === read.identity.ino))
|
|
33
|
+
return refuse(`${name}.path`, "before and after name the same physical file", "choose two different image files and retry");
|
|
34
|
+
identities.push(read.identity);
|
|
35
|
+
const asset = `${id}:${name}`;
|
|
36
|
+
files.push({ asset, path: value.path, sha256: read.sha256, bytes: read.bytes, mime });
|
|
37
|
+
return { asset, alt: value.alt };
|
|
38
|
+
};
|
|
39
|
+
const before = await side(input.before, "before"), after = await side(input.after, "after");
|
|
40
|
+
return { payload: { kind: "beforeAfter", ...(input.caption !== undefined ? { caption: input.caption } : {}), before, after }, files };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Serve only freshly guarded bytes that still match the posted host-owned hash and MIME. */
|
|
44
|
+
export async function readVisualAsset(file: VisualFile, ports: DeliverablePorts): Promise<{ bytes: Uint8Array; mime: VisualFile["mime"]; name: string }> {
|
|
45
|
+
try {
|
|
46
|
+
const read = await readGuardedDeliverable(file.path, ports, VISUAL_ASSET_BYTES), mime = imageMime(file.path, read.data);
|
|
47
|
+
if (read.sha256 !== file.sha256 || read.bytes !== file.bytes || !mime || mime !== file.mime) throw new Error("changed");
|
|
48
|
+
return { bytes: read.data, mime, name: basename(file.path) };
|
|
49
|
+
} catch { return refuse("asset", "changed since posted or no longer allowed", "post the visual again using a current public PNG, JPEG or WebP file"); }
|
|
50
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import type { VisualPayload, VisualSide, TableColumn } from "@staix/agent-console/contract";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { openHubDatabase, type Database } from "./db-schema.ts";
|
|
5
|
+
import { sanitize } from "./envelope.ts";
|
|
6
|
+
export type { VisualPayload, VisualSide, TableColumn };
|
|
7
|
+
export type VisualInputSide = { text: string } | { path: string; alt: string };
|
|
8
|
+
export type VisualInput = Exclude<VisualPayload, { kind: "beforeAfter" }> | { kind: "beforeAfter"; caption?: string; before: VisualInputSide; after: VisualInputSide };
|
|
9
|
+
export interface VisualFile { asset: string; path: string; sha256: string; bytes: number; mime: "image/png" | "image/jpeg" | "image/webp" }
|
|
10
|
+
export interface VisualRecord { id: string; task?: number; from: string; kind: VisualPayload["kind"]; title: string; payload: VisualPayload; files: VisualFile[]; created: number }
|
|
11
|
+
export type VisualSummary = Pick<VisualRecord, "id" | "task" | "from" | "kind" | "title" | "created">;
|
|
12
|
+
export const VISUAL_ID_PATTERN = /^[a-f0-9]{8}-(?:[a-f0-9]{4}-){3}[a-f0-9]{12}$/;
|
|
13
|
+
/** Acon 0.1.7 source limit counts UTF-16 code units; the hub's UTF-8 byte limit is intentionally stricter.
|
|
14
|
+
* Option/point bounds come from contract.ts; table caps from shell.js UI_LIMITS.
|
|
15
|
+
* Other byte ceilings are explicit hub admission bounds, not additions to the acon type. No truncation. */
|
|
16
|
+
export const VISUAL_LIMITS = Object.freeze({ source: 20 * 1024, options: 6, points: 10, rows: 500, columns: 12,
|
|
17
|
+
title: 300, caption: 2000, alt: 1000, text: 20 * 1024, summary: 4000, point: 2000, cell: 4000, key: 128,
|
|
18
|
+
path: 4096, asset: 256, payload: 1024 * 1024, filesJson: 32 * 1024, image: 8 * 1024 * 1024 });
|
|
19
|
+
const DAY_MS = 86_400_000, DEFAULT_RETENTION_DAYS = 30, DEFAULT_LIST_LIMIT = 20, MAX_LIST_LIMIT = 100;
|
|
20
|
+
const MERMAID = /^(?:flowchart|graph|sequenceDiagram|stateDiagram(?:-v2)?|classDiagram|erDiagram|gantt|mindmap|pie|journey|timeline)(?=\s|;|$)/;
|
|
21
|
+
type Screen = (text: string) => boolean;
|
|
22
|
+
const bytes = (value: string) => Buffer.byteLength(value, "utf8");
|
|
23
|
+
export class VisualValidationError extends Error {
|
|
24
|
+
constructor(readonly field: string, reason: string) { super(`${field}: ${reason}; correct the field and retry hub_visual`); this.name = "VisualValidationError"; }
|
|
25
|
+
}
|
|
26
|
+
function fail(field: string, reason: string): never { throw new VisualValidationError(field, reason); }
|
|
27
|
+
/** Clone JSON through data descriptors only: no getters, toJSON, proxies, cycles or custom prototypes execute. */
|
|
28
|
+
function jsonCopy(input: unknown, field: string, cap: number): unknown {
|
|
29
|
+
let size = 0; const ancestors = new Set<object>();
|
|
30
|
+
const knownFields = new Set(["kind", "source", "title", "options", "summary", "pros", "cons", "recommended", "caption", "columns", "rows", "key", "label", "align", "format", "before", "after", "text", "path", "asset", "alt", "id", "task", "from", "payload", "files", "created", "sha256", "bytes", "mime"]);
|
|
31
|
+
const add = (n: number) => { size += n; if (size > cap) fail(field, `serialized JSON exceeds ${cap} UTF-8 bytes`); };
|
|
32
|
+
const copy = (value: unknown, depth: number, location: string): unknown => {
|
|
33
|
+
if (depth > 8) fail(location, "JSON nesting exceeds the visual shape");
|
|
34
|
+
if (value === null || typeof value === "boolean" || typeof value === "string" || typeof value === "number" && Number.isFinite(value)) {
|
|
35
|
+
add(bytes(JSON.stringify(value))); return value;
|
|
36
|
+
}
|
|
37
|
+
if (!value || typeof value !== "object" || types.isProxy(value)) fail(location, "expected finite JSON data");
|
|
38
|
+
const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
|
|
39
|
+
if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) fail(location, "custom prototypes are not accepted");
|
|
40
|
+
if (ancestors.has(value) || Object.getOwnPropertySymbols(value).length) fail(location, "cyclic or symbol-keyed data is not accepted");
|
|
41
|
+
const descriptors = Object.getOwnPropertyDescriptors(value), names = Object.keys(descriptors);
|
|
42
|
+
if (names.some(name => !Object.hasOwn(descriptors[name]!, "value"))) fail(location, "accessor properties are not accepted");
|
|
43
|
+
ancestors.add(value);
|
|
44
|
+
try {
|
|
45
|
+
if (array) {
|
|
46
|
+
const length = descriptors.length?.value;
|
|
47
|
+
if (!Number.isInteger(length) || length < 0 || length > VISUAL_LIMITS.rows || names.length !== length + 1) fail(location, "expected a dense bounded array");
|
|
48
|
+
add(2 + Math.max(0, length - 1)); const out: unknown[] = [];
|
|
49
|
+
for (let i = 0; i < length; i++) { if (!Object.hasOwn(descriptors, String(i))) fail(location, "sparse arrays are not accepted"); out.push(copy(descriptors[String(i)]!.value, depth + 1, `${location}[${i}]`)); }
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
add(2 + Math.max(0, names.length - 1)); const out: Record<string, unknown> = {};
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
if (!descriptors[name]!.enumerable) fail(location, "non-enumerable data is not accepted");
|
|
55
|
+
add(bytes(JSON.stringify(name)) + 1);
|
|
56
|
+
Object.defineProperty(out, name, { value: copy(descriptors[name]!.value, depth + 1, `${location}.${knownFields.has(name) ? name : "value"}`), enumerable: true, writable: true, configurable: true });
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
} finally { ancestors.delete(value); }
|
|
60
|
+
};
|
|
61
|
+
return copy(input, 0, field);
|
|
62
|
+
}
|
|
63
|
+
function object(value: unknown, required: readonly string[], optional: readonly string[], field: string): Record<string, unknown> {
|
|
64
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail(field, "expected an object");
|
|
65
|
+
const row = value as Record<string, unknown>;
|
|
66
|
+
if (required.some(key => !Object.hasOwn(row, key)) || Object.keys(row).some(key => !required.includes(key) && !optional.includes(key))) fail(field, "required fields are missing or unknown fields are present");
|
|
67
|
+
return row;
|
|
68
|
+
}
|
|
69
|
+
function text(value: unknown, cap: number, field: string, screen: Screen, nonempty = false, preserve = false): string {
|
|
70
|
+
if (typeof value !== "string" || bytes(value) > cap) fail(field, `expected text of at most ${cap} UTF-8 bytes`);
|
|
71
|
+
const clean = sanitize(value);
|
|
72
|
+
if (bytes(clean) > cap || nonempty && !clean.trim()) fail(field, "sanitized text is empty or exceeds its byte bound");
|
|
73
|
+
try { if (screen(value) !== true || screen(clean) !== true) fail(field, "privacy pattern screen refused this text"); }
|
|
74
|
+
catch (error) { if (error instanceof VisualValidationError) throw error; fail(field, "privacy screen unavailable"); }
|
|
75
|
+
return preserve ? value : clean;
|
|
76
|
+
}
|
|
77
|
+
function identityText(value: unknown, cap: number, field: string, screen: Screen): string {
|
|
78
|
+
const clean = text(value, cap, field, screen, true);
|
|
79
|
+
if (clean !== value) fail(field, "sanitization changes an identifier; use a plain identifier");
|
|
80
|
+
return clean;
|
|
81
|
+
}
|
|
82
|
+
function pathText(value: unknown, field: string, screen: Screen): string {
|
|
83
|
+
const result = text(value, VISUAL_LIMITS.path, field, screen, true, true);
|
|
84
|
+
if (/[\p{Cc}\p{Cf}]/u.test(result)) fail(field, "file paths cannot contain control characters");
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
function list(value: unknown, maximum: number, field: string): unknown[] {
|
|
88
|
+
if (!Array.isArray(value) || value.length > maximum) fail(field, `expected an array of at most ${maximum} items`);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function validate(input: unknown, screen: Screen, assets: boolean): VisualPayload | VisualInput {
|
|
92
|
+
const raw = jsonCopy(input, "payload", VISUAL_LIMITS.payload), base = object(raw, ["kind"], ["source", "title", "options", "caption", "columns", "rows", "before", "after"], "payload");
|
|
93
|
+
const optional = (row: Record<string, unknown>, key: string, cap: number, field: string) => Object.hasOwn(row, key) ? { [key]: text(row[key], cap, field, screen) } : {};
|
|
94
|
+
let result: VisualPayload | VisualInput;
|
|
95
|
+
if (base.kind === "mermaid") {
|
|
96
|
+
const row = object(raw, ["kind", "source"], ["title"], "payload"), source = text(row.source, VISUAL_LIMITS.source, "payload.source", screen, true);
|
|
97
|
+
const header = source.split(/\r?\n/).map(line => line.trim()).find(line => line && !line.startsWith("%%"));
|
|
98
|
+
if (!header || !MERMAID.test(header)) fail("payload.source", "first non-comment line must start with a known Mermaid diagram keyword");
|
|
99
|
+
result = { kind: "mermaid", source, ...optional(row, "title", VISUAL_LIMITS.title, "payload.title") };
|
|
100
|
+
} else if (base.kind === "comparison") {
|
|
101
|
+
const row = object(raw, ["kind", "options"], ["title"], "payload"), options = list(row.options, VISUAL_LIMITS.options, "payload.options");
|
|
102
|
+
if (options.length < 2) fail("payload.options", "comparison requires 2 to 6 options");
|
|
103
|
+
result = { kind: "comparison", ...optional(row, "title", VISUAL_LIMITS.title, "payload.title"), options: options.map((value, i) => {
|
|
104
|
+
const field = `payload.options[${i}]`, option = object(value, ["title"], ["summary", "pros", "cons", "recommended"], field);
|
|
105
|
+
const out: Extract<VisualPayload, { kind: "comparison" }>["options"][number] = { title: text(option.title, VISUAL_LIMITS.title, `${field}.title`, screen), ...optional(option, "summary", VISUAL_LIMITS.summary, `${field}.summary`) };
|
|
106
|
+
for (const key of ["pros", "cons"] as const) if (Object.hasOwn(option, key)) out[key] = list(option[key], VISUAL_LIMITS.points, `${field}.${key}`).map((point, n) => text(point, VISUAL_LIMITS.point, `${field}.${key}[${n}]`, screen));
|
|
107
|
+
if (Object.hasOwn(option, "recommended")) { if (typeof option.recommended !== "boolean") fail(`${field}.recommended`, "expected a boolean"); out.recommended = option.recommended; }
|
|
108
|
+
return out;
|
|
109
|
+
}) };
|
|
110
|
+
} else if (base.kind === "table") {
|
|
111
|
+
const row = object(raw, ["kind", "columns", "rows"], ["caption"], "payload"), keys = new Set<string>();
|
|
112
|
+
const columns: TableColumn[] = list(row.columns, VISUAL_LIMITS.columns, "payload.columns").map((value, i) => {
|
|
113
|
+
const field = `payload.columns[${i}]`, column = object(value, ["key", "label"], ["align", "format"], field);
|
|
114
|
+
const key = identityText(column.key, VISUAL_LIMITS.key, `${field}.key`, screen); if (keys.has(key)) fail(`${field}.key`, "column keys must be distinct"); keys.add(key);
|
|
115
|
+
const out: TableColumn = { key, label: text(column.label, VISUAL_LIMITS.title, `${field}.label`, screen, true) };
|
|
116
|
+
if (Object.hasOwn(column, "align")) { if (column.align !== "start" && column.align !== "end") fail(`${field}.align`, "expected start or end"); out.align = column.align; }
|
|
117
|
+
if (Object.hasOwn(column, "format")) { if (column.format !== "text" && column.format !== "number" && column.format !== "percent") fail(`${field}.format`, "expected text, number or percent"); out.format = column.format; }
|
|
118
|
+
return out;
|
|
119
|
+
});
|
|
120
|
+
const rows = list(row.rows, VISUAL_LIMITS.rows, "payload.rows").map((value, i) => {
|
|
121
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail(`payload.rows[${i}]`, "expected a cell record");
|
|
122
|
+
const out: Record<string, string | number> = {};
|
|
123
|
+
for (const [rawKey, cell] of Object.entries(value)) {
|
|
124
|
+
const key = identityText(rawKey, VISUAL_LIMITS.key, `payload.rows[${i}].key`, screen);
|
|
125
|
+
const clean = typeof cell === "string" ? text(cell, VISUAL_LIMITS.cell, `payload.rows[${i}].cell`, screen) : cell;
|
|
126
|
+
if (typeof clean !== "string" && (typeof clean !== "number" || !Number.isFinite(clean))) fail(`payload.rows[${i}].cell`, "expected text or a finite number");
|
|
127
|
+
Object.defineProperty(out, key, { value: clean, enumerable: true, writable: true, configurable: true });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
});
|
|
131
|
+
result = { kind: "table", ...optional(row, "caption", VISUAL_LIMITS.caption, "payload.caption"), columns, rows };
|
|
132
|
+
} else if (base.kind === "beforeAfter") {
|
|
133
|
+
const row = object(raw, ["kind", "before", "after"], ["caption"], "payload");
|
|
134
|
+
const side = (value: unknown, field: string): VisualSide | VisualInputSide => {
|
|
135
|
+
if (value && typeof value === "object" && Object.hasOwn(value, "text")) { const v = object(value, ["text"], [], field); return { text: text(v.text, VISUAL_LIMITS.text, `${field}.text`, screen) }; }
|
|
136
|
+
const name = assets ? "asset" : "path", v = object(value, [name], ["alt"], field), alt = text(v.alt, VISUAL_LIMITS.alt, `${field}.alt`, screen);
|
|
137
|
+
return assets ? { asset: identityText(v.asset, VISUAL_LIMITS.asset, `${field}.asset`, screen), alt } : { path: pathText(v.path, `${field}.path`, screen), alt };
|
|
138
|
+
};
|
|
139
|
+
const before = side(row.before, "payload.before"), after = side(row.after, "payload.after");
|
|
140
|
+
if (!assets && "path" in before && "path" in after && before.path === after.path) fail("payload.after.path", "before and after must name different files");
|
|
141
|
+
result = { kind: "beforeAfter", ...optional(row, "caption", VISUAL_LIMITS.caption, "payload.caption"), before, after } as VisualPayload | VisualInput;
|
|
142
|
+
} else fail("payload.kind", "expected mermaid, comparison, table or beforeAfter");
|
|
143
|
+
if (bytes(JSON.stringify(result)) > VISUAL_LIMITS.payload) fail("payload", "sanitized serialized payload exceeds 1 MiB");
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
export function validateVisualInput(input: unknown, screen: Screen): VisualInput { return validate(input, screen, false) as VisualInput; }
|
|
147
|
+
export function validateVisualPayload(input: unknown, screen: Screen): VisualPayload { return validate(input, screen, true) as VisualPayload; }
|
|
148
|
+
|
|
149
|
+
function integer(value: unknown, field: string, minimum = 0): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(field, "expected a safe integer in range"); return value; }
|
|
150
|
+
function visualId(value: unknown): string { if (typeof value !== "string" || !VISUAL_ID_PATTERN.test(value)) fail("id", "expected a canonical lowercase UUID"); return value; }
|
|
151
|
+
function summary(input: unknown): VisualSummary {
|
|
152
|
+
const row = object(input, ["id", "from", "kind", "title", "created"], ["task"], "record"), allow = () => true;
|
|
153
|
+
const kind = row.kind; if (!["mermaid", "comparison", "table", "beforeAfter"].includes(kind as string)) fail("record.kind", "unknown visual kind");
|
|
154
|
+
return { id: visualId(row.id), ...(Object.hasOwn(row, "task") ? { task: integer(row.task, "record.task", 1) } : {}), from: identityText(row.from, 128, "record.from", allow), kind: kind as VisualPayload["kind"], title: text(row.title, VISUAL_LIMITS.caption, "record.title", allow), created: integer(row.created, "record.created") };
|
|
155
|
+
}
|
|
156
|
+
function fileMetadata(input: unknown): VisualFile[] {
|
|
157
|
+
const files = list(input, 2, "record.files").map((value, i): VisualFile => {
|
|
158
|
+
const field = `record.files[${i}]`, file = object(value, ["asset", "path", "sha256", "bytes", "mime"], [], field);
|
|
159
|
+
if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(file.sha256)) fail(`${field}.sha256`, "expected lowercase SHA-256");
|
|
160
|
+
if (!["image/png", "image/jpeg", "image/webp"].includes(file.mime as string)) fail(`${field}.mime`, "only PNG, JPEG and WebP are accepted");
|
|
161
|
+
const size = integer(file.bytes, `${field}.bytes`, 1); if (size > VISUAL_LIMITS.image) fail(`${field}.bytes`, "image exceeds 8 MiB");
|
|
162
|
+
return { asset: identityText(file.asset, VISUAL_LIMITS.asset, `${field}.asset`, () => true), path: pathText(file.path, `${field}.path`, () => true), sha256: file.sha256, bytes: size, mime: file.mime as VisualFile["mime"] };
|
|
163
|
+
});
|
|
164
|
+
if (files.length === 2 && files[0]!.path === files[1]!.path) fail("record.files", "before and after must name different files");
|
|
165
|
+
return files;
|
|
166
|
+
}
|
|
167
|
+
function record(input: unknown): VisualRecord {
|
|
168
|
+
const row = object(jsonCopy(input, "record", VISUAL_LIMITS.payload + VISUAL_LIMITS.filesJson + 16 * 1024), ["id", "from", "kind", "title", "payload", "files", "created"], ["task"], "record");
|
|
169
|
+
const meta = summary(Object.fromEntries(Object.entries(row).filter(([key]) => key !== "payload" && key !== "files"))), payload = validateVisualPayload(row.payload, () => true);
|
|
170
|
+
if (meta.kind !== payload.kind || meta.title !== (payload.kind === "table" || payload.kind === "beforeAfter" ? payload.caption ?? "" : payload.title ?? "")) fail("record.title", "kind/title must agree with the stored payload");
|
|
171
|
+
const files = fileMetadata(row.files);
|
|
172
|
+
const expected = payload.kind === "beforeAfter" ? (["before", "after"] as const).flatMap(side => "asset" in payload[side] ? [{ asset: payload[side].asset, side }] : []) : [];
|
|
173
|
+
if (files.length !== expected.length || new Set(files.map(file => file.asset)).size !== files.length || expected.some(value => value.asset !== `${meta.id}:${value.side}` || !files.some(file => file.asset === value.asset))) fail("record.files", "files must bind exactly the payload's originating visual asset sides");
|
|
174
|
+
if (bytes(JSON.stringify(files)) > VISUAL_LIMITS.filesJson) fail("record.files", "file metadata exceeds its byte bound");
|
|
175
|
+
return { ...meta, payload, files };
|
|
176
|
+
}
|
|
177
|
+
interface Raw { id: unknown; task: unknown; from: unknown; kind: unknown; title: unknown; created: unknown; payload_json?: unknown; files_json?: unknown }
|
|
178
|
+
const metadataColumns = `CASE WHEN typeof(id)='text' AND length(CAST(id AS BLOB))<=36 THEN id END AS id,
|
|
179
|
+
task, CASE WHEN typeof("from")='text' AND length(CAST("from" AS BLOB))<=128 THEN "from" END AS "from",
|
|
180
|
+
CASE WHEN typeof(kind)='text' AND length(CAST(kind AS BLOB))<=32 THEN kind END AS kind,
|
|
181
|
+
CASE WHEN typeof(title)='text' AND length(CAST(title AS BLOB))<=2000 THEN title END AS title, created`;
|
|
182
|
+
const metaOf = (raw: Raw) => summary({ id: raw.id, ...(raw.task === null ? {} : { task: raw.task }), from: raw.from, kind: raw.kind, title: raw.title, created: raw.created });
|
|
183
|
+
const jsonColumns = `CASE WHEN typeof(payload_json)='text' AND length(CAST(payload_json AS BLOB))<=${VISUAL_LIMITS.payload} THEN payload_json END AS payload_json,
|
|
184
|
+
CASE WHEN typeof(files_json)='text' AND length(CAST(files_json AS BLOB))<=${VISUAL_LIMITS.filesJson} THEN files_json END AS files_json`;
|
|
185
|
+
function readRaw(raw: Raw): VisualRecord {
|
|
186
|
+
try {
|
|
187
|
+
if (typeof raw.payload_json !== "string" || typeof raw.files_json !== "string") fail("record", "stored JSON exceeds its bound or is invalid");
|
|
188
|
+
return record({ ...metaOf(raw), payload: JSON.parse(raw.payload_json), files: JSON.parse(raw.files_json) });
|
|
189
|
+
} catch { fail("record", "stored visual is corrupt; inspect hub.db before retrying"); }
|
|
190
|
+
}
|
|
191
|
+
/** Structure is checked again here. Task/private-turn/pattern authority remains the daemon caller's pre-effect boundary. */
|
|
192
|
+
export class VisualStore {
|
|
193
|
+
private readonly db: Database;
|
|
194
|
+
constructor(dbPath: string) {
|
|
195
|
+
this.db = openHubDatabase(dbPath, { create: true });
|
|
196
|
+
try { this.db.transaction(() => {
|
|
197
|
+
this.db.run('CREATE TABLE IF NOT EXISTS visuals (id TEXT PRIMARY KEY,task INTEGER,"from" TEXT NOT NULL,kind TEXT NOT NULL,title TEXT NOT NULL,payload_json TEXT NOT NULL,files_json TEXT NOT NULL,created INTEGER NOT NULL)');
|
|
198
|
+
this.db.run("CREATE INDEX IF NOT EXISTS visuals_created ON visuals(created DESC,id)");
|
|
199
|
+
this.db.run("CREATE INDEX IF NOT EXISTS visuals_task_created ON visuals(task,created DESC,id)");
|
|
200
|
+
}).immediate(); } catch (error) { this.db.close(); throw error; }
|
|
201
|
+
}
|
|
202
|
+
insert(input: VisualRecord): void {
|
|
203
|
+
const value = record(input);
|
|
204
|
+
this.db.query('INSERT INTO visuals(id,task,"from",kind,title,payload_json,files_json,created) VALUES(?,?,?,?,?,?,?,?)').run(value.id, value.task ?? null, value.from, value.kind, value.title, JSON.stringify(value.payload), JSON.stringify(value.files), value.created);
|
|
205
|
+
}
|
|
206
|
+
get(id: string): VisualRecord | undefined {
|
|
207
|
+
const raw = this.db.query<Raw, [string]>(`SELECT ${metadataColumns},${jsonColumns} FROM visuals WHERE id=?`).get(visualId(id));
|
|
208
|
+
return raw ? readRaw(raw) : undefined;
|
|
209
|
+
}
|
|
210
|
+
hasRecords(): boolean { return this.db.query("SELECT 1 AS present FROM visuals LIMIT 1").get() !== null; }
|
|
211
|
+
/** Stream bounded raw rows, not images. Length-prefixed canonical tuples bind every stored field and row loss. */
|
|
212
|
+
integrity(): { count: number; digest: string } {
|
|
213
|
+
const hash = createHash("sha256").update("hub-visual-integrity-v1");
|
|
214
|
+
const statement = this.db.prepare<Raw, []>(`SELECT ${metadataColumns},${jsonColumns} FROM visuals ORDER BY id`);
|
|
215
|
+
let count = 0;
|
|
216
|
+
try {
|
|
217
|
+
for (const raw of statement.iterate()) {
|
|
218
|
+
readRaw(raw); // Corrupt or oversize rows refuse the entire inventory, never disappear from it.
|
|
219
|
+
const tuple = JSON.stringify([raw.id, raw.task, raw.from, raw.kind, raw.title, raw.payload_json, raw.files_json, raw.created]);
|
|
220
|
+
hash.update(`${bytes(tuple)}:`).update(tuple); count++;
|
|
221
|
+
if (!Number.isSafeInteger(count)) fail("integrity", "visual count exceeds its safe range");
|
|
222
|
+
}
|
|
223
|
+
} finally { statement.finalize(); }
|
|
224
|
+
return { count, digest: hash.digest("hex") };
|
|
225
|
+
}
|
|
226
|
+
list(input: { task?: number; limit?: number } = {}, accept?: (summary: VisualSummary, paths: readonly string[]) => boolean): VisualSummary[] {
|
|
227
|
+
const row = object(jsonCopy(input, "list", 1024), [], ["task", "limit"], "list"), limit = Object.hasOwn(row, "limit") ? integer(row.limit, "list.limit", 1) : DEFAULT_LIST_LIMIT;
|
|
228
|
+
if (limit > MAX_LIST_LIMIT) fail("list.limit", "at most 100 metadata rows may be returned");
|
|
229
|
+
const task = Object.hasOwn(row, "task") ? integer(row.task, "list.task", 1) : undefined;
|
|
230
|
+
const columns = metadataColumns + (accept ? `,CASE WHEN typeof(files_json)='text' AND length(CAST(files_json AS BLOB))<=${VISUAL_LIMITS.filesJson} THEN files_json END AS files_json` : "");
|
|
231
|
+
const rows = task === undefined ? this.db.query<Raw, [number]>(`SELECT ${columns} FROM visuals ORDER BY created DESC,id LIMIT ?`).all(limit)
|
|
232
|
+
: this.db.query<Raw, [number, number]>(`SELECT ${columns} FROM visuals WHERE task=? ORDER BY created DESC,id LIMIT ?`).all(task, limit);
|
|
233
|
+
return rows.flatMap(raw => {
|
|
234
|
+
const meta = metaOf(raw);
|
|
235
|
+
if (!accept) return [meta];
|
|
236
|
+
if (typeof raw.files_json !== "string") fail("record.files", "stored file metadata exceeds its bound or is invalid");
|
|
237
|
+
let files: VisualFile[];
|
|
238
|
+
try { files = fileMetadata(JSON.parse(raw.files_json)); }
|
|
239
|
+
catch { fail("record.files", "stored file metadata is corrupt"); }
|
|
240
|
+
return accept(meta, files.map(file => file.path)) ? [meta] : [];
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
prune(retentionDays = DEFAULT_RETENTION_DAYS, now = Date.now(), taskExists?: (task: number) => boolean): number {
|
|
244
|
+
const duration = integer(retentionDays, "retentionDays") * DAY_MS; integer(now, "now");
|
|
245
|
+
if (!Number.isSafeInteger(duration)) fail("retentionDays", "retention duration exceeds its safe range");
|
|
246
|
+
return this.db.transaction(() => {
|
|
247
|
+
let count = this.db.query("DELETE FROM visuals WHERE task IS NULL AND created<?").run(now - duration).changes;
|
|
248
|
+
if (taskExists) for (const row of this.db.query<{ task: number }, []>("SELECT DISTINCT task FROM visuals WHERE task IS NOT NULL").all()) {
|
|
249
|
+
const task = integer(row.task, "record.task", 1), exists = taskExists(task);
|
|
250
|
+
if (typeof exists !== "boolean") fail("taskExists", "task existence check unavailable");
|
|
251
|
+
if (!exists) count += this.db.query("DELETE FROM visuals WHERE task=?").run(task).changes;
|
|
252
|
+
}
|
|
253
|
+
return count;
|
|
254
|
+
}).immediate();
|
|
255
|
+
}
|
|
256
|
+
close(): void { this.db.close(); }
|
|
257
|
+
}
|
package/src/memory/brief.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { buildIntentBrief, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
|
|
1
|
+
import { buildIntentBrief, parseRecordLines, type CaptureRow, type IntentBriefOptions, type IntentBriefSource } from "@staix/agent-mem";
|
|
2
2
|
import { controlWriteSpan } from "../hub/control-write-trace.ts";
|
|
3
3
|
import type { PeerId } from "../hub/envelope.ts";
|
|
4
4
|
import type { MemoryClient } from "./client.ts";
|
|
@@ -34,7 +34,7 @@ export async function related(client: MemoryClient, project: string, query: stri
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/** A native memory line for a brief (#258): `mem:<id8> <kind> <tier>: <title or text>`, fails first. */
|
|
37
|
-
export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[]; tier?: string; peer?: string; machine?: string; at?: string }
|
|
37
|
+
export interface NativeItem { id: string; line: string; kind?: string; about?: readonly string[]; tier?: string; peer?: string; machine?: string; at?: string; anchors?: readonly string[] }
|
|
38
38
|
export type NativeBrief = (task: BriefTask) => NativeItem[] | Promise<NativeItem[]>;
|
|
39
39
|
export interface IntentBriefContext { source: IntentBriefSource; options: IntentBriefOptions }
|
|
40
40
|
/** Host-owned policy and store binding; the library owns relevance, ordering and text. */
|
|
@@ -48,6 +48,87 @@ export interface CorpusBriefPort {
|
|
|
48
48
|
read(task: BriefTask): NativeItem[] | Promise<NativeItem[]>;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export interface RecordPreferenceCounts { beforeBytes: number; afterBytes: number; replacedNotes: number; records: number }
|
|
52
|
+
export interface RecordBriefSnapshot {
|
|
53
|
+
/** Relevant complete rows already authorized and screened by the host, from readBriefRecords. */
|
|
54
|
+
rows: readonly CaptureRow[];
|
|
55
|
+
/** Only known generated done/review notes. Anchors are explicit raw Record anchors, never inferred prose. */
|
|
56
|
+
replaceable: readonly { id: string; anchors: readonly string[] }[];
|
|
57
|
+
partial: boolean;
|
|
58
|
+
/** Re-screen complete rows and any supplied final context against current host policy; false/throw revokes the snapshot. */
|
|
59
|
+
current?(text?: string): boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface RecordBriefPort {
|
|
62
|
+
allowed(task: BriefTask): boolean;
|
|
63
|
+
policyVersion(task?: BriefTask): string;
|
|
64
|
+
read(task: BriefTask): RecordBriefSnapshot | undefined | Promise<RecordBriefSnapshot | undefined>;
|
|
65
|
+
observe?(peer: PeerId, task: number | undefined, counts: RecordPreferenceCounts): void;
|
|
66
|
+
/** Legacy Record addition ceiling. Existing larger context is retained; additions cannot enlarge it. */
|
|
67
|
+
maxBytes?: number | (() => number);
|
|
68
|
+
}
|
|
69
|
+
const RECORD_ROWS = 32, RECORD_BYTES = 2048;
|
|
70
|
+
const LEGACY_KNOWLEDGE_HEADER = "Knowledge brief (advice, not a block):";
|
|
71
|
+
const LEGACY_NATIVE_HEADER = "Memory brief (hub memory first, fails first: ahub memory show <mem id>; amem by mem:id):";
|
|
72
|
+
const LEGACY_CURATED_HEADER = "Memory brief (amem curated notes; fetch details by mem:id):";
|
|
73
|
+
|
|
74
|
+
/** Read-time display preference only; the package parser owns Record syntax and canonical anchors. */
|
|
75
|
+
export function preferRecordItems(items: readonly NativeItem[], snapshot: RecordBriefSnapshot, maximum: number,
|
|
76
|
+
protectedIds: ReadonlySet<string> = new Set(), maximumLineBytes = 2048): { items: NativeItem[]; replacedNotes: number; records: number } {
|
|
77
|
+
const original = () => ({ items: [...items], replacedNotes: 0, records: 0 });
|
|
78
|
+
if (!Number.isSafeInteger(maximumLineBytes) || maximumLineBytes < 0 || snapshot.rows.length > RECORD_ROWS || snapshot.replaceable.length > RECORD_ROWS) return original();
|
|
79
|
+
const records: { item: NativeItem; anchors: readonly string[] }[] = [];
|
|
80
|
+
for (const row of snapshot.rows) {
|
|
81
|
+
if (row.event !== "Record" || row.subagent || !Number.isSafeInteger(row.id) || row.id < 1 || typeof row.excerpt !== "string"
|
|
82
|
+
|| row.excerpt.length > RECORD_BYTES || Buffer.byteLength(row.excerpt) > RECORD_BYTES || !Array.isArray(row.paths)) continue;
|
|
83
|
+
const parsed = parseRecordLines(row.excerpt), record = parsed.records[0];
|
|
84
|
+
if (parsed.incomplete || Object.keys(parsed.drops).length || parsed.records.length !== 1 || !record || record.line !== row.excerpt
|
|
85
|
+
|| record.anchors.length !== row.paths.length || !record.anchors.every((anchor, index) => anchor === row.paths[index])) continue;
|
|
86
|
+
const id = `capture:Record:${row.id}`;
|
|
87
|
+
if (records.some(value => value.item.id === id)) continue;
|
|
88
|
+
records.push({ item: { id, line: `${id} ${record.line}`, kind: record.kind, anchors: record.anchors }, anchors: record.anchors });
|
|
89
|
+
}
|
|
90
|
+
const bytes = (rows: readonly NativeItem[]) => Buffer.byteLength(rows.map(item => item.line).join("\n"));
|
|
91
|
+
// Preexisting priority/baseline context is never deleted merely to satisfy a new optional source cap.
|
|
92
|
+
const ceiling = Math.max(maximumLineBytes, bytes(items));
|
|
93
|
+
const selected = [...items]; let replacedNotes = 0;
|
|
94
|
+
const partialMarker = "[partial Record window: older Records not read] ";
|
|
95
|
+
const marked = (rows: NativeItem[]): NativeItem[] => {
|
|
96
|
+
if (!snapshot.partial) return rows;
|
|
97
|
+
let first = true;
|
|
98
|
+
return rows.map(item => {
|
|
99
|
+
if (!first || !records.some(record => record.item.id === item.id)) return item;
|
|
100
|
+
first = false;
|
|
101
|
+
return item.line.startsWith(partialMarker) ? item : { ...item, line: partialMarker + item.line };
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
// A partial candidate window does not weaken explicit saved-id/anchor proof for its complete known rows.
|
|
105
|
+
for (const note of items) {
|
|
106
|
+
if (note.kind === "fail" || note.kind === "contract" || protectedIds.has(note.id)) continue;
|
|
107
|
+
const permitted = snapshot.replaceable.filter(value => value.id === note.id);
|
|
108
|
+
if (permitted.length !== 1 || !permitted[0]!.anchors.length) continue;
|
|
109
|
+
const anchors = permitted[0]!.anchors;
|
|
110
|
+
const covering = records.filter(record => record.anchors.some(anchor => anchors.includes(anchor)));
|
|
111
|
+
if (!anchors.every(anchor => covering.some(record => record.anchors.includes(anchor)))) continue;
|
|
112
|
+
const additions = covering.filter(record => !selected.some(item => item.id === record.item.id));
|
|
113
|
+
if (selected.length - 1 + additions.length > maximum) continue;
|
|
114
|
+
const position = selected.findIndex(item => item.id === note.id);
|
|
115
|
+
if (position < 0) continue;
|
|
116
|
+
const complete = additions.map(record => record.item);
|
|
117
|
+
if (bytes(complete) > Buffer.byteLength(note.line)) continue;
|
|
118
|
+
const candidate = marked([...selected.slice(0, position), ...complete, ...selected.slice(position + 1)]);
|
|
119
|
+
if (bytes(candidate) > ceiling || bytes(candidate) > bytes(selected)) continue;
|
|
120
|
+
selected.splice(0, selected.length, ...candidate); replacedNotes++;
|
|
121
|
+
}
|
|
122
|
+
// Host relevance is authoritative; spare room can carry Records without suppressing any original item.
|
|
123
|
+
for (const record of records) {
|
|
124
|
+
if (selected.length >= maximum || selected.some(item => item.id === record.item.id)) continue;
|
|
125
|
+
const candidate = marked([...selected, record.item]);
|
|
126
|
+
if (bytes(candidate) > ceiling) continue;
|
|
127
|
+
selected.splice(0, selected.length, ...candidate);
|
|
128
|
+
}
|
|
129
|
+
return { items: selected, replacedNotes, records: selected.filter(item => records.some(record => record.item.id === item.id)).length };
|
|
130
|
+
}
|
|
131
|
+
|
|
51
132
|
/**
|
|
52
133
|
* Production task briefs use the library's intent retrieval and rendering with host policy and bindings.
|
|
53
134
|
* The hub applies its per-peer delivery dedupe and item cap to complete library rows, reserving the front
|
|
@@ -68,6 +149,7 @@ export class Briefs {
|
|
|
68
149
|
private readonly displayNative?: (items: readonly NativeItem[]) => string[],
|
|
69
150
|
private readonly intent?: IntentBriefHost,
|
|
70
151
|
private readonly corpus?: CorpusBriefPort,
|
|
152
|
+
private readonly records?: RecordBriefPort,
|
|
71
153
|
) {}
|
|
72
154
|
|
|
73
155
|
async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
|
|
@@ -100,6 +182,32 @@ export class Briefs {
|
|
|
100
182
|
return items;
|
|
101
183
|
} catch { return current() ? [] : undefined; }
|
|
102
184
|
};
|
|
185
|
+
const recordItems = async (items: readonly NativeItem[], lineBudget: number): Promise<{ items: NativeItem[]; replacedNotes: number; records: number; invalidated?: true; current?: (text?: string) => boolean }> => {
|
|
186
|
+
let version: string | undefined, snapshot: RecordBriefSnapshot | undefined;
|
|
187
|
+
const recordCurrent = (text?: string) => {
|
|
188
|
+
try { return current() && this.records!.allowed(task) && this.records!.policyVersion(task) === version && (snapshot?.current === undefined || snapshot.current(text) === true); } catch { return false; }
|
|
189
|
+
};
|
|
190
|
+
const fallback = () => ({ items: [...items], replacedNotes: 0, records: 0, ...(version === undefined ? {} : { current: recordCurrent }) });
|
|
191
|
+
if (!this.records || !current()) return fallback();
|
|
192
|
+
try {
|
|
193
|
+
if (!this.records.allowed(task)) return fallback();
|
|
194
|
+
version = this.records.policyVersion(task);
|
|
195
|
+
snapshot = await this.records.read(task);
|
|
196
|
+
if (!recordCurrent()) return { ...fallback(), invalidated: true };
|
|
197
|
+
if (!snapshot) return fallback();
|
|
198
|
+
const available = { ...snapshot, rows: snapshot.rows.filter(row => !this.seen.get(peer)?.has(`capture:Record:${row.id}`)) };
|
|
199
|
+
const result = preferRecordItems(items, available, maximum, new Set(priority.map(item => item.id)), lineBudget);
|
|
200
|
+
return { ...result, current: recordCurrent };
|
|
201
|
+
} catch {
|
|
202
|
+
try { if (version !== undefined && !recordCurrent()) return { ...fallback(), invalidated: true }; }
|
|
203
|
+
catch { return { ...fallback(), invalidated: true }; }
|
|
204
|
+
return fallback();
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const observeRecords = (before: string, after: string, result: { replacedNotes: number; records: number }) => {
|
|
208
|
+
try { this.records?.observe?.(peer, task.id, { beforeBytes: Buffer.byteLength(before), afterBytes: Buffer.byteLength(after), replacedNotes: result.replacedNotes, records: result.records }); }
|
|
209
|
+
catch { /* count-only telemetry cannot fail a brief */ }
|
|
210
|
+
};
|
|
103
211
|
if (this.intent) {
|
|
104
212
|
// Serialize only this peer's library reads, so awaited policy cannot duplicate a delivered note.
|
|
105
213
|
const previous = this.libraryRuns.get(peer);
|
|
@@ -129,16 +237,31 @@ export class Briefs {
|
|
|
129
237
|
const corpus = await corpusItems(maximum - selected.length);
|
|
130
238
|
if (corpus === undefined || !current()) return undefined;
|
|
131
239
|
for (const item of corpus) if (selected.length < maximum && !seen.has(item.id) && !selected.some(row => row.id === item.id)) selected.push(item);
|
|
132
|
-
if (!selected.length) return undefined;
|
|
240
|
+
if (!selected.length && !this.records) return undefined;
|
|
133
241
|
// Preserve complete library rows (including graph neighbours after a seen direct bridge).
|
|
134
242
|
if (!current()) return undefined;
|
|
135
243
|
const header = selected.some(item => item.kind === "note") ? "Knowledge brief (advice, not a block):" : lines[0] || "Live fail notes for this plan (advice, not a block):";
|
|
244
|
+
const before = [header, ...selected.map(item => item.line)].join("\n");
|
|
245
|
+
const preferred = this.records ? await recordItems(selected, Math.max(0, (context.options.maxBytes ?? 2048) - Buffer.byteLength(header) - 1)) : { items: [...selected], replacedNotes: 0, records: 0, invalidated: undefined, current: undefined };
|
|
246
|
+
if (preferred.invalidated || preferred.current?.() === false || !current() || context.options.allowed?.() === false || context.options.policyVersion() !== revision) return undefined;
|
|
247
|
+
context.options.assertCurrent?.();
|
|
248
|
+
const representedIds = selected.filter(item => !preferred.items.some(row => row.id === item.id)).map(item => item.id);
|
|
249
|
+
selected.splice(0, selected.length, ...preferred.items);
|
|
250
|
+
if (!selected.length) return undefined;
|
|
136
251
|
const text = [header, ...selected.map(item => item.line)].join("\n");
|
|
137
|
-
if (!current()) return undefined;
|
|
138
|
-
|
|
139
|
-
|
|
252
|
+
if (preferred.current?.(text) === false || !current()) return undefined;
|
|
253
|
+
const claimSeen = () => {
|
|
254
|
+
this.seen.set(peer, seen);
|
|
255
|
+
for (const item of selected) seen.add(item.id);
|
|
256
|
+
for (const id of representedIds) seen.add(id);
|
|
257
|
+
};
|
|
258
|
+
if (!this.records) claimSeen();
|
|
140
259
|
if (!current()) return undefined;
|
|
141
260
|
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: selected.filter(item => item.kind !== "note").length, fails: selected.filter(item => item.kind === "fail").length, claudeMem: 0 }); } catch { /* telemetry never fails a brief */ }
|
|
261
|
+
if (preferred.current?.(text) === false) return undefined;
|
|
262
|
+
observeRecords(before, text, preferred);
|
|
263
|
+
if (preferred.current?.(text) === false) return undefined;
|
|
264
|
+
if (this.records) claimSeen();
|
|
142
265
|
return text;
|
|
143
266
|
} catch { return undefined; }
|
|
144
267
|
})();
|
|
@@ -189,16 +312,48 @@ export class Briefs {
|
|
|
189
312
|
}
|
|
190
313
|
if (!current()) return undefined;
|
|
191
314
|
const notice = this.client?.readNotice();
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
315
|
+
const beforeOwn = [...own, ...corpusRows];
|
|
316
|
+
let legacyBytes = 2048;
|
|
317
|
+
try { legacyBytes = typeof this.records?.maxBytes === "function" ? this.records.maxBytes() : this.records?.maxBytes ?? legacyBytes; }
|
|
318
|
+
catch { legacyBytes = NaN; } // An unreadable optional cap cannot grant additional bytes.
|
|
319
|
+
const indexLines = items.map(item => `${item.id.toString().startsWith("mem:") ? item.id : `#${item.id}`} ${item.time} ${item.type} ${item.title}`);
|
|
320
|
+
const overhead = Math.max(...[LEGACY_KNOWLEDGE_HEADER, LEGACY_NATIVE_HEADER, LEGACY_CURATED_HEADER].map(header => Buffer.byteLength(header))) + 1
|
|
321
|
+
+ (notice ? Buffer.byteLength(notice) + 1 : 0) + indexLines.reduce((sum, line) => sum + Buffer.byteLength(line) + 1, 0);
|
|
322
|
+
const preferred = this.records ? await recordItems(beforeOwn, Math.max(0, legacyBytes - overhead)) : { items: beforeOwn, replacedNotes: 0, records: 0, invalidated: undefined, current: undefined };
|
|
323
|
+
if (preferred.invalidated || preferred.current?.() === false || !current()) return undefined;
|
|
324
|
+
// A Record read is another yield point. Concurrent legacy reads share this same set, so
|
|
325
|
+
// recheck every ordinary row after it before claiming any delivered id.
|
|
326
|
+
items.splice(0, items.length, ...items.filter(item => !seen.has(`#${item.id}`) && !seen.has(String(item.id))));
|
|
327
|
+
// The index/timeline bridge has no anchor metadata and is never removed by a Record preference.
|
|
328
|
+
const room = Math.max(0, maximum - items.length);
|
|
329
|
+
const preferredOwn = preferred.items.length <= room ? preferred : { items: beforeOwn, replacedNotes: 0, records: 0 };
|
|
330
|
+
own.splice(0, own.length, ...preferredOwn.items.filter(item => priorityIds.has(item.id) || !seen.has(item.id))); corpusRows.length = 0;
|
|
331
|
+
if (!own.length && !items.length && !notice) return undefined;
|
|
332
|
+
if (preferred.current?.() === false || !current()) return undefined;
|
|
333
|
+
const claimSeen = () => {
|
|
334
|
+
for (const item of own) seen.add(item.id);
|
|
335
|
+
for (const item of beforeOwn) if (!preferredOwn.items.some(row => row.id === item.id)) seen.add(item.id);
|
|
336
|
+
for (const item of items) seen.add(`#${item.id}`);
|
|
337
|
+
for (const item of corpusRows) seen.add(item.id);
|
|
338
|
+
};
|
|
339
|
+
if (!this.records) claimSeen();
|
|
197
340
|
if (!current()) return undefined;
|
|
198
341
|
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.filter(item => item.kind !== "note").length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
|
|
199
342
|
if (!current()) return undefined;
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
343
|
+
const render = (rows: readonly NativeItem[]) => {
|
|
344
|
+
const native = rows.filter(item => item.kind !== "note"), notes = rows.filter(item => item.kind === "note");
|
|
345
|
+
const header = notes.length ? LEGACY_KNOWLEDGE_HEADER : rows.length ? LEGACY_NATIVE_HEADER : LEGACY_CURATED_HEADER;
|
|
346
|
+
return [header, ...(notice ? [notice] : []), ...(this.displayNative?.(native) ?? native.map(item => item.line)), ...items.map(item => `${item.id.toString().startsWith("mem:") ? item.id : `#${item.id}`} ${item.time} ${item.type} ${item.title}`), ...notes.map(item => item.line)].join("\n");
|
|
347
|
+
};
|
|
348
|
+
const text = render(own);
|
|
349
|
+
if (preferred.current?.(text) === false || !current()) return undefined;
|
|
350
|
+
if (this.records) {
|
|
351
|
+
const before = render(beforeOwn);
|
|
352
|
+
if (preferred.current?.(text) === false || !current()) return undefined;
|
|
353
|
+
observeRecords(before, text, { ...preferredOwn, records: own.filter(item => item.id.startsWith("capture:Record:")).length });
|
|
354
|
+
}
|
|
355
|
+
if (preferred.current?.(text) === false || !current()) return undefined;
|
|
356
|
+
if (this.records) claimSeen();
|
|
357
|
+
return text;
|
|
203
358
|
}
|
|
204
359
|
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { BOUNDED_TASK_TOOLS, CAPABILITY_TOOLS, HANDOFF_TOOLS, QUESTION_TOOLS, ST
|
|
|
5
5
|
import { MEMORY_READ_TOOLS } from "../hub/read-tools.ts";
|
|
6
6
|
import { isDenied } from "../local/deny.ts";
|
|
7
7
|
import type { MemoryClient } from "./client.ts";
|
|
8
|
+
import { stripRecordLines } from "./records.ts";
|
|
8
9
|
|
|
9
10
|
export interface CaptureOptions {
|
|
10
11
|
project: string;
|
|
@@ -62,7 +63,7 @@ export class Capture {
|
|
|
62
63
|
|
|
63
64
|
summarize(lastAssistantMessage: string): void {
|
|
64
65
|
if (!this.sessionId) return;
|
|
65
|
-
void this.client.appendCapture({ project: this.opts.project, cwd: this.opts.cwd, at: new Date().toISOString(), sessionId: this.sessionId, toolUseId: `summary:${++this.sequence}`, agent: this.agentId, event: "Stop", excerpt: lastAssistantMessage, platformSource: this.platformSource });
|
|
66
|
+
void this.client.appendCapture({ project: this.opts.project, cwd: this.opts.cwd, at: new Date().toISOString(), sessionId: this.sessionId, toolUseId: `summary:${++this.sequence}`, agent: this.agentId, event: "Stop", excerpt: stripRecordLines(lastAssistantMessage), platformSource: this.platformSource });
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/** Awaited by the caller: the hub process exits right after stop(), and an unawaited request would die with it. The synchronous published store operation has completed before this promise settles. */
|