@staix/agent-hub 0.34.1 → 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 +47 -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 +12 -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-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/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
|
@@ -7,7 +7,7 @@ import { isDenied } from "../local/deny.ts";
|
|
|
7
7
|
import { isHubWritePath } from "./permission-mode.ts";
|
|
8
8
|
import { realPath } from "./project.ts";
|
|
9
9
|
import { insideRoot } from "./path-policy.ts";
|
|
10
|
-
import { DELIVERABLE_LIMITS, deliverableId, type DeliverableClaim, type DeliverableRecord, type DeliverableState } from "./deliverables.ts";
|
|
10
|
+
import { DELIVERABLE_LIMITS, deliverableId, parseDeliverableClaims, type DeliverableClaim, type DeliverableRecord, type DeliverableState } from "./deliverables.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* The only code that opens a deliverable (#509a). The hub hashes the bytes itself; nothing an agent says about a file is used.
|
|
@@ -67,7 +67,7 @@ function locate(path: string, ports: DeliverablePorts): Located {
|
|
|
67
67
|
const sameFile = (a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">) => a.dev === b.dev && a.ino === b.ino;
|
|
68
68
|
|
|
69
69
|
/** sha256 and size of one vetted file, or a refusal. */
|
|
70
|
-
async function digest(path: string, found: Located): Promise<{ sha256: string; bytes: number }> {
|
|
70
|
+
async function digest(path: string, found: Located, read?: { limit: number; current: () => void }): Promise<{ sha256: string; bytes: number; data?: Uint8Array; identity: { dev: number; ino: number } }> {
|
|
71
71
|
let handle;
|
|
72
72
|
deliverableHooks.beforeOpen?.(path);
|
|
73
73
|
try { handle = await open(found.real, constants.O_RDONLY | NOFOLLOW); }
|
|
@@ -75,21 +75,54 @@ async function digest(path: string, found: Located): Promise<{ sha256: string; b
|
|
|
75
75
|
try {
|
|
76
76
|
const before = await handle.stat();
|
|
77
77
|
if (!before.isFile() || !sameFile(before, found.stat) || before.size !== found.stat.size || before.mtimeMs !== found.stat.mtimeMs) return refuse(path, "the file changed while it was being checked");
|
|
78
|
-
|
|
78
|
+
if (read && before.ctimeMs !== found.stat.ctimeMs) return refuse(path, "the file changed while it was being checked");
|
|
79
|
+
const hash = createHash("sha256"), buffer = Buffer.allocUnsafe(CHUNK), chunks: Uint8Array[] = [];
|
|
80
|
+
const limit = read?.limit ?? DELIVERABLE_LIMITS.fileBytes;
|
|
79
81
|
let bytes = 0;
|
|
80
82
|
for (;;) {
|
|
83
|
+
read?.current();
|
|
81
84
|
const { bytesRead } = await handle.read(buffer, 0, CHUNK, bytes);
|
|
82
85
|
if (!bytesRead) break;
|
|
83
86
|
bytes += bytesRead;
|
|
84
|
-
if (bytes >
|
|
87
|
+
if (bytes > limit) return refuse(path, "larger than the size limit");
|
|
85
88
|
hash.update(buffer.subarray(0, bytesRead));
|
|
89
|
+
if (read) chunks.push(Uint8Array.from(buffer.subarray(0, bytesRead)));
|
|
86
90
|
}
|
|
87
91
|
const after = await handle.stat();
|
|
88
92
|
if (after.size !== bytes || after.size !== before.size || after.mtimeMs !== before.mtimeMs) return refuse(path, "the file changed while it was being read");
|
|
89
|
-
|
|
93
|
+
if (read && (!sameFile(after, before) || after.ctimeMs !== before.ctimeMs)) return refuse(path, "the file changed while it was being read");
|
|
94
|
+
read?.current();
|
|
95
|
+
return { sha256: hash.digest("hex"), bytes, identity: { dev: before.dev, ino: before.ino }, ...(read ? { data: Buffer.concat(chunks, bytes) } : {}) };
|
|
90
96
|
} finally { await handle.close().catch(() => {}); }
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
export interface GuardedDeliverableRead {
|
|
100
|
+
data: Uint8Array;
|
|
101
|
+
sha256: string;
|
|
102
|
+
bytes: number;
|
|
103
|
+
/** Host-only physical identity; never stored as a visual record or used as content proof. */
|
|
104
|
+
identity: { dev: number; ino: number };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Reuse the deliverable guard and opened-descriptor reader; this export refuses private files before reading any bytes. */
|
|
108
|
+
export async function readGuardedDeliverable(path: string, ports: DeliverablePorts, limit = DELIVERABLE_LIMITS.fileBytes): Promise<GuardedDeliverableRead> {
|
|
109
|
+
parseDeliverableClaims([{ path, format: "bin" }]);
|
|
110
|
+
if (!Number.isSafeInteger(limit) || limit < 0 || limit > DELIVERABLE_LIMITS.fileBytes) return refuse(path, "invalid read limit; use the deliverable size limit or a smaller bound");
|
|
111
|
+
const privacy = (real?: string) => {
|
|
112
|
+
try {
|
|
113
|
+
if (ports.private(path) || real !== undefined && ports.private(relative(realPath(ports.cwd), real))) return refuse(path, "private file; choose a public project file and retry");
|
|
114
|
+
} catch { return refuse(path, "private file or unavailable privacy policy; choose a public project file and retry"); }
|
|
115
|
+
};
|
|
116
|
+
privacy();
|
|
117
|
+
const found = locate(path, ports); privacy(found.real);
|
|
118
|
+
if (found.stat.size > limit) return refuse(path, "larger than the read limit; choose a smaller file and retry");
|
|
119
|
+
const result = await digest(path, found, { limit, current: () => privacy(found.real) });
|
|
120
|
+
const current = locate(path, ports); privacy(current.real);
|
|
121
|
+
if (!sameFile(current.stat, result.identity) || current.stat.size !== found.stat.size || current.stat.mtimeMs !== found.stat.mtimeMs || current.stat.ctimeMs !== found.stat.ctimeMs)
|
|
122
|
+
return refuse(path, "the file changed while it was being read; retry with a stable file");
|
|
123
|
+
return { data: result.data!, sha256: result.sha256, bytes: result.bytes, identity: result.identity };
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
/** Refuse a bad claim before any effect, without hashing: the path must be inside the project, allowed, and a regular file of a usable size. */
|
|
94
127
|
export function vetDeliverables(claims: readonly DeliverableClaim[], ports: DeliverablePorts): void {
|
|
95
128
|
let total = 0;
|
|
@@ -26,7 +26,7 @@ const criterion = (s: unknown): s is string => typeof s === "string" && /^[A-Za-
|
|
|
26
26
|
|
|
27
27
|
// Exhaustive maps make producer union changes a compile-time review point.
|
|
28
28
|
const taskStates: Record<TaskState,true> = {backlog:true,proposed:true,in_progress:true,in_review:true,approved:true,changes_requested:true,cancelled:true};
|
|
29
|
-
const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true,jcode_acp:true};
|
|
29
|
+
const usageSources: Record<Extract<StampedEvent,{type:"usage"}>["source"],true> = {omniroute:true,claude_transcript:true,claude_result:true,codex_appserver:true,kimi_wire:true,jcode_acp:true,agy_transcript:true};
|
|
30
30
|
const attributions: Record<Attribution,true> = {delivery:true,single_open:true,unattributed:true};
|
|
31
31
|
const screenItems: Record<ScreenItem,true> = {task:true,summary:true,"review note":true,handoff:true,note:true};
|
|
32
32
|
const screenMisses: Record<ScreenMiss,true> = {"too long":true,"off campus":true,timeout:true,unreadable:true,failed:true};
|
package/src/hub/envelope.ts
CHANGED
|
@@ -16,7 +16,7 @@ export interface Envelope {
|
|
|
16
16
|
kind: Kind;
|
|
17
17
|
priority: Priority;
|
|
18
18
|
body: string; // agent conclusions only, never tool noise
|
|
19
|
-
refs?: { repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; reviewPackage?: string; reviewResponse?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true; question?: string; handoff?: { project: string; task: number } };
|
|
19
|
+
refs?: { visual?: string; repo?: string; branch?: string; commit?: string; paths?: string[]; task?: string; reviewRevision?: string; reviewPackage?: string; reviewResponse?: string; taskOffer?: number; taskCancelled?: number; taskRecipient?: "owner" | "reviewer" | "planner"; planReview?: number; planReviewFlow?: number; planAuthor?: string; backlog?: number[]; supervision?: boolean; supervisionKey?: string; completedFor?: number; boundedCheck?: string; budgetResume?: string; budgetResumeMoves?: { id: number; title: string; to: PeerId | null; role: "owner" | "reviewer" }[]; budgetResumeEarlier?: string[]; invocation?: string; invocationRemote?: true; question?: string; handoff?: { project: string; task: number } };
|
|
20
20
|
/** The body must not be shown outside its recipients: console tail and log print a stub instead (PII tasks). */
|
|
21
21
|
private?: boolean;
|
|
22
22
|
/** Sent by a distinct session (`<peer>~<origin>`, agent-hub#1036): a connection that named a peer id but is not that peer's current session. It grants nothing; it marks the sender for the log. */
|
package/src/hub/events.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const EVENTS_SCHEMA = 1;
|
|
|
14
14
|
* ids, routing and sizes, tasks carry ids and states, so the file can be exported without leaking what agents wrote.
|
|
15
15
|
*/
|
|
16
16
|
export type HubEvent =
|
|
17
|
+
| { type: "model_change"; peer: string; previousModel: string | null; model: string | null; by: "person"; source: "cli" | "dashboard"; appliesFrom: "next_turn"; afterTurn?: string }
|
|
17
18
|
| { type: "release_authorization"; by: PeerId; authorization: string; commit: string; version: string; docsDigest: string; documents: number }
|
|
18
19
|
| { type: "permission_mode"; peer: string; from: import("./permission-mode.ts").PermissionMode; to: import("./permission-mode.ts").PermissionMode }
|
|
19
20
|
| { type: "role"; peer?: string; task?: number; actor: string; stage: "prepared" | "assigned"; roles: { id: string; layer: "shipped" | "project"; hash: string }[] }
|
|
@@ -27,12 +28,14 @@ export type HubEvent =
|
|
|
27
28
|
| { type: "envelope"; id: string; from: string; to?: string[]; priority: string; hop: number; kind?: string; task?: string; bytes?: number; private?: boolean; dropped?: string; origin?: import("./provenance.ts").Origin }
|
|
28
29
|
/** #604 PR B: one per peer-role or tools-role hello, with the same fields an envelope's `origin` carries. No cwd, root or token. */
|
|
29
30
|
| ({ type: "attach" } & import("./provenance.ts").Origin)
|
|
31
|
+
/** Visual payloads and paths stay local; observer events carry counts only. */
|
|
32
|
+
| { type: "visual"; id: string; kind: "mermaid" | "comparison" | "table" | "beforeAfter"; bytes: number; files: number; task?: number }
|
|
30
33
|
| { type: "overflow" | "undeliverable"; id: string; from: string; peer: string }
|
|
31
34
|
| { type: "stale"; id: string; from: string; peer: string; task?: string }
|
|
32
35
|
| { type: "quiet"; id: string; from: string; peers: string[] }
|
|
33
36
|
| { type: "fact"; peer: string; id: string; files: number; plans: number; unknown: number; named: number; bytes: number; via: "hook" | "steer" | "done"; ms?: number; hookMs?: number; rttMs?: number; accepted?: boolean; unanswered?: boolean; probe?: boolean; coverage?: boolean }
|
|
34
37
|
| { type: "fact_ack"; peer: string; id: string; via: string; ms: number }
|
|
35
|
-
| ({ type: "route"; peer: string; route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin"; score: number; ms: number; stateless?: boolean; decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number } & Partial<SwitchTrace>)
|
|
38
|
+
| ({ type: "route"; peer: string; route: string; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin" | "decision"; score: number; ms: number; decisionMode?: "off" | "shadow"; chosenAlias?: string | null; chosenEffort?: string | null; margin?: number; decisionLatencyMs?: number; decisionReason?: string; fallbackApplied?: boolean; decider?: { provider: string; model: string; version?: string; latencyMs: number } | null; stateless?: boolean; decision?: string; turn?: string; task?: number; pii?: boolean; severity?: number; spinning?: number; exploring?: number; production?: number } & Partial<SwitchTrace>)
|
|
36
39
|
| { type: "cooldown"; peer: string; alias: string; event: "start" | "end"; failures: number; ms?: number }
|
|
37
40
|
| { type: "route_outcome"; peer: string; decision: string; turnId: string; turn: "completed" | "failed"; task?: number; pii: boolean; latched: boolean; next?: { severity: number; tests: "pass" | "fail" | "none"; repeat: boolean }; advisor?: "approve" | "redo" | "failed" }
|
|
38
41
|
| { type: "advisor"; peer: string; route: string; trigger: string; verdict: "approve" | "redo" | "failed"; discardedChars: number }
|
|
@@ -40,7 +43,8 @@ export type HubEvent =
|
|
|
40
43
|
| { type: "stuck"; peer: string; task: number; category: "repetition" | "false_progress" | "drift" | "desperation" | "capability_gap"; streak: number; latched: boolean }
|
|
41
44
|
| { type: "capability"; peer: string; state: "verified" | "lost"; via?: string }
|
|
42
45
|
/** A hub run began: every peer is detached until its next state event (#248: attachments never span a crash). */
|
|
43
|
-
|
|
46
|
+
/** `personAttribution`: this run writes `task` and `attribution` on permission, question, completion and agent_cli events (#1278). A log with no such start says nothing about which task a person interaction belonged to. */
|
|
47
|
+
| { type: "hub_start"; instance: string; personAttribution?: true }
|
|
44
48
|
/** #248: the add-ons a peer works with, names and versions from a closed list, at each attach. */
|
|
45
49
|
| { type: "tooling"; peer: string; addons: import("./tooling.ts").Addon[] }
|
|
46
50
|
/** #258: a task brief handed to its owner: how many hub-memory items (and fails among them) and supplemental items (legacy claudeMem field), never their text. New local-library records identify source amem. */
|
|
@@ -50,22 +54,22 @@ export type HubEvent =
|
|
|
50
54
|
| { type: "turn_start"; peer: string; turn: string }
|
|
51
55
|
| { type: "native_thread"; peer: string; thread: string; fresh: boolean }
|
|
52
56
|
| ({ type: "turn_end"; peer: string; turn: string; ms: number; tokens?: number; files?: number; snapshotMs?: number; preparedRoles?: RoleIdentity[] | null } & Partial<TaskAttribution>)
|
|
53
|
-
| { type: "native_turn_end"; peer: string; id?: string }
|
|
57
|
+
| { type: "native_turn_end"; peer: string; id?: string; reason?: "user_canceled" | "error" | "max_invocations" | "max_forced_invocations" | "max_token_budget_exceeded" | "terminal_step_type" | "terminal_custom_hook" }
|
|
54
58
|
| { type: "conduct"; peer: string; action: string; task?: number; target?: string }
|
|
55
|
-
| { type: "agent_cli"; peer: string; command: string; refused: boolean; delegated?: true }
|
|
56
|
-
| { 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" }
|
|
59
|
+
| ({ type: "agent_cli"; peer: string; command: string; refused: boolean; delegated?: true } & Partial<TaskAttribution>)
|
|
60
|
+
| ({ 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" } & Partial<TaskAttribution>)
|
|
57
61
|
/** #1053: one question a peer asked the person, and how it ended. Counts, ids and a closed reason only: never a prompt, an option, an answer or other text. */
|
|
58
|
-
| { type: "question"; set: string; peer: string; event: "asked" | "answered" | "pending" | "superseded" | "cancelled" | "expired" | "unavailable"; latencyMs?: number; surface?: "terminal" | "console" | "dashboard" | "acon"; person?: string; reason?: "superseded" | "stopped" | "turn_aborted" | "session_changed" | "expired" | "unattended"; questions?: number; private?: boolean }
|
|
62
|
+
| ({ type: "question"; set: string; peer: string; event: "asked" | "answered" | "pending" | "superseded" | "cancelled" | "expired" | "unavailable"; latencyMs?: number; surface?: "terminal" | "console" | "dashboard" | "acon"; person?: string; reason?: "superseded" | "stopped" | "turn_aborted" | "session_changed" | "expired" | "unattended"; questions?: number; private?: boolean } & Partial<TaskAttribution>)
|
|
59
63
|
/** Completed native turns that received supervision, not feed enqueues or inferred costs. */
|
|
60
64
|
| { type: "supervision_turn"; peer: string; turn: string; tokens?: number; ms?: number }
|
|
61
65
|
/** #1054: a run a person started settled (or a restart cut it short). Ids, a closed outcome and a duration only: never the prompt or the reply. */
|
|
62
66
|
/** #1054: a person (or the hub on a person's command) marked a result read. A receipt only: never read from a snapshot. */
|
|
63
67
|
| { type: "completion_seen"; id: string; surface: "terminal" | "console" | "dashboard" | "acon" }
|
|
64
|
-
| { type: "completion"; id: string; peer: string; outcome: "done" | "failed" | "limit" | "interrupted"; ms: number }
|
|
68
|
+
| ({ type: "completion"; id: string; peer: string; outcome: "done" | "failed" | "limit" | "interrupted"; ms: number } & Partial<TaskAttribution>)
|
|
65
69
|
| { type: "hook_stats"; peer: string; n: number; startupMs: number; hubMs: number; maxStartupMs: number }
|
|
66
70
|
| { type: "cohort"; id: number; event: "formed" | "joined" | "lifted"; silent: boolean; tasks: number[]; owners: string[] }
|
|
67
|
-
| ({ type: "tokens"; peer: string; n: number } & Partial<TaskAttribution>)
|
|
68
|
-
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire" | "jcode_acp"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
71
|
+
| ({ type: "tokens"; peer: string; n: number; source?: "kimi_wire"; id?: string } & Partial<TaskAttribution>)
|
|
72
|
+
| ({ type: "usage"; peer: string; source: "omniroute" | "claude_transcript" | "claude_result" | "codex_appserver" | "kimi_wire" | "jcode_acp" | "agy_transcript"; id: string; reasoningTokens?: number; measuredAt?: string; requestedModel?: string; servedModel?: string; provider?: string; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; totalTokens?: number } & Partial<TaskAttribution>)
|
|
69
73
|
| { type: "planning"; by: string; task?: number; release?: number; fields: string[]; revision: number }
|
|
70
74
|
| { type: "task"; id: number; event: string; by: string; state: string; owner: string | null; reviewer: string | null; class: string; profile?: string; profileClass?: string; role?: string; roleIdentity?: { id: string; layer: "shipped" | "project"; hash: string }; pii: boolean; reason?: string; release?: number | null; priority?: number | null; plannedStart?: number | null; due?: number | null }
|
|
71
75
|
| { type: "overlap"; task: number; owner: string; others: { task: number; owner: string; paths: string[]; symbols?: string[] }[] }
|
|
@@ -139,18 +143,24 @@ export function tokenDeltas(): (peer: string, total: number, session: string) =>
|
|
|
139
143
|
*/
|
|
140
144
|
export function readEvents(file: string, since = 0): StampedEvent[] {
|
|
141
145
|
if (!existsSync(file)) return [];
|
|
142
|
-
return readFileSync(file, "utf8").split("\n").flatMap(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const e = JSON.parse(line) as StampedEvent;
|
|
146
|
-
const measuredAt = e.type === "usage" && typeof e.measuredAt === "string" ? e.measuredAt : e.at;
|
|
147
|
-
return Date.parse(measuredAt) >= since ? [e] : [];
|
|
148
|
-
} catch {
|
|
149
|
-
return [];
|
|
150
|
-
}
|
|
146
|
+
return readFileSync(file, "utf8").split("\n").flatMap(line => {
|
|
147
|
+
const event = parseEventLine(line, since);
|
|
148
|
+
return event === undefined ? [] : [event];
|
|
151
149
|
});
|
|
152
150
|
}
|
|
153
151
|
|
|
152
|
+
/** The historical event-reader predicate, shared without adding shape or line-size restrictions. */
|
|
153
|
+
export function parseEventLine(line: string, since = 0): StampedEvent | undefined {
|
|
154
|
+
if (!line.trim()) return undefined;
|
|
155
|
+
try {
|
|
156
|
+
const e = JSON.parse(line) as StampedEvent;
|
|
157
|
+
const measuredAt = e.type === "usage" && typeof e.measuredAt === "string" ? e.measuredAt : e.at;
|
|
158
|
+
return Date.parse(measuredAt) >= since ? e : undefined;
|
|
159
|
+
} catch {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
/** `7d`, `24h`, `90m` or an ISO date, as epoch ms; undefined when it is neither. */
|
|
155
165
|
export function parseSince(text: string, now = Date.now()): number | undefined {
|
|
156
166
|
const m = /^(\d+)([dhm])$/.exec(text);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { EXECUTION_REFERENCE_LIMITS, executionReferenceKey, executionText, parseExecutionReference, type ExecutionReferenceV1 } from "./execution-reference.ts";
|
|
3
|
+
|
|
4
|
+
/** Snapshot admission is host-owned; never accept these authority flags from a caller or producer. */
|
|
5
|
+
export const LINEAGE_LIMITS = Object.freeze({ nodes: 100, edges: 200, bytes: 256 * 1024, sourceNodes: 2048, sourceEdges: 4096, sourceBytes: 4 * 1024 * 1024, token: 128, state: 64, cursor: 512, minimumBytes: 4096 });
|
|
6
|
+
export type LineageStrength = "declared" | "host-observed" | "runner-attested" | "unknown";
|
|
7
|
+
export type LineageRelation = "dispatched" | "attempted" | "produced" | "reviewed" | "declared-external";
|
|
8
|
+
export interface LineageNode { ref: ExecutionReferenceV1; state: string; strength: LineageStrength }
|
|
9
|
+
export interface LineageEdge { from: ExecutionReferenceV1; to: ExecutionReferenceV1; relation: LineageRelation; strength: LineageStrength }
|
|
10
|
+
export interface ExecutionLineageSource {
|
|
11
|
+
id: string; revision: string; projectKey: string; scope: string;
|
|
12
|
+
/** Host-owned ephemeral signing key, never supplied by a query or persisted by this reader. */
|
|
13
|
+
cursorKey: string;
|
|
14
|
+
authorized: boolean; diagnostics: boolean;
|
|
15
|
+
health: "ready" | "missing" | "corrupt" | "unsupported" | "withheld";
|
|
16
|
+
/** Owner-authorized PUBLIC root neighborhood, complete at this revision. Apply privacy in the
|
|
17
|
+
* owner's indexed read before these arrays or their bounds exist; never pass hidden rows here. */
|
|
18
|
+
nodes: readonly LineageNode[];
|
|
19
|
+
edges: readonly LineageEdge[];
|
|
20
|
+
}
|
|
21
|
+
export interface LineageQuery { root: ExecutionReferenceV1; cursor?: string; nodes?: number; edges?: number; bytes?: number }
|
|
22
|
+
export type LineageResult = { state: "unavailable"; reason?: "missing" | "corrupt" | "unsupported" | "withheld" | "cursor_invalid" | "invalid" }
|
|
23
|
+
| { state: "available"; nodes: LineageNode[]; edges: LineageEdge[]; truncated: boolean; cursor?: string };
|
|
24
|
+
const STRENGTHS: readonly string[] = ["declared", "host-observed", "runner-attested", "unknown"];
|
|
25
|
+
const RELATIONS: readonly string[] = ["dispatched", "attempted", "produced", "reviewed", "declared-external"];
|
|
26
|
+
const bytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
27
|
+
const hash = (value: unknown): string => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
28
|
+
const limit = (value: number | undefined, cap: number, min = 1): number => {
|
|
29
|
+
if (value === undefined) return cap;
|
|
30
|
+
if (!Number.isSafeInteger(value) || value < min || value > cap) throw new Error("invalid limit");
|
|
31
|
+
return value;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Pure bounded projection. No IO, clocks, probes, model calls, persistence or inference. */
|
|
35
|
+
export function readExecutionLineage(source: ExecutionLineageSource, query: LineageQuery): LineageResult {
|
|
36
|
+
const unavailable = (reason: NonNullable<Extract<LineageResult, { state: "unavailable" }>["reason"]>): LineageResult =>
|
|
37
|
+
({ state: "unavailable", ...(source.authorized && source.diagnostics ? { reason } : {}) });
|
|
38
|
+
// Authority precedes even a source-health or root read: absent and denied are indistinguishable.
|
|
39
|
+
if (!source.authorized) return { state: "unavailable" };
|
|
40
|
+
if (source.health !== "ready") return unavailable(source.health);
|
|
41
|
+
let root: ExecutionReferenceV1, nodeLimit: number, edgeLimit: number, byteLimit: number;
|
|
42
|
+
try {
|
|
43
|
+
root = parseExecutionReference(query.root);
|
|
44
|
+
nodeLimit = limit(query.nodes, LINEAGE_LIMITS.nodes); edgeLimit = limit(query.edges, LINEAGE_LIMITS.edges);
|
|
45
|
+
byteLimit = limit(query.bytes, LINEAGE_LIMITS.bytes, LINEAGE_LIMITS.minimumBytes);
|
|
46
|
+
} catch { return unavailable("invalid"); }
|
|
47
|
+
try {
|
|
48
|
+
const rootKey = executionReferenceKey(root);
|
|
49
|
+
if (![source.id, source.revision, source.scope].every(value => executionText(value, LINEAGE_LIMITS.token)) ||
|
|
50
|
+
!executionText(source.cursorKey, LINEAGE_LIMITS.token) || Buffer.byteLength(source.cursorKey) < 32 ||
|
|
51
|
+
!executionText(source.projectKey, EXECUTION_REFERENCE_LIMITS.projectKey) || root.projectKey !== source.projectKey) return unavailable("invalid");
|
|
52
|
+
// ponytail: oversized neighborhoods need source-owner indexed paging, never a larger inbox scan.
|
|
53
|
+
if (source.nodes.length > LINEAGE_LIMITS.sourceNodes || source.edges.length > LINEAGE_LIMITS.sourceEdges) return unavailable("unsupported");
|
|
54
|
+
const nodes = new Map<string, LineageNode>();
|
|
55
|
+
let size = 0;
|
|
56
|
+
for (const raw of source.nodes) {
|
|
57
|
+
const ref = parseExecutionReference(raw.ref), key = executionReferenceKey(ref);
|
|
58
|
+
if (ref.projectKey !== source.projectKey || !executionText(raw.state, LINEAGE_LIMITS.state) ||
|
|
59
|
+
!/^[a-z][a-z0-9_-]*$/.test(raw.state) || !STRENGTHS.includes(raw.strength) || nodes.has(key)) return unavailable("corrupt");
|
|
60
|
+
const node = { ref, state: raw.state, strength: raw.strength };
|
|
61
|
+
nodes.set(key, node); size += bytes(node);
|
|
62
|
+
}
|
|
63
|
+
if (!nodes.has(rootKey)) return unavailable("missing");
|
|
64
|
+
const edges: LineageEdge[] = [], edgeKeys = new Set<string>();
|
|
65
|
+
const adjacent = new Map<string, string[]>();
|
|
66
|
+
for (const raw of source.edges) {
|
|
67
|
+
const from = parseExecutionReference(raw.from), to = parseExecutionReference(raw.to);
|
|
68
|
+
const fromKey = executionReferenceKey(from), toKey = executionReferenceKey(to);
|
|
69
|
+
if (!nodes.has(fromKey) || !nodes.has(toKey)) return unavailable("corrupt");
|
|
70
|
+
if (!RELATIONS.includes(raw.relation) || !STRENGTHS.includes(raw.strength) ||
|
|
71
|
+
(raw.relation === "declared-external" && raw.strength !== "declared")) return unavailable("corrupt");
|
|
72
|
+
const edge = { from, to, relation: raw.relation, strength: raw.strength }, key = JSON.stringify(edge);
|
|
73
|
+
if (edgeKeys.has(key)) continue;
|
|
74
|
+
edgeKeys.add(key); edges.push(edge); size += bytes(edge);
|
|
75
|
+
for (const [a, b] of [[fromKey, toKey], [toKey, fromKey]] as const) {
|
|
76
|
+
const neighbors = adjacent.get(a) ?? []; neighbors.push(b); adjacent.set(a, neighbors);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (size > LINEAGE_LIMITS.sourceBytes) return unavailable("unsupported");
|
|
80
|
+
const seen = new Set([rootKey]), queue = [rootKey];
|
|
81
|
+
for (let i = 0; i < queue.length; i++) for (const key of adjacent.get(queue[i]!) ?? []) {
|
|
82
|
+
if (!seen.has(key)) { seen.add(key); queue.push(key); }
|
|
83
|
+
}
|
|
84
|
+
const visibleNodes = queue.map(key => nodes.get(key)!);
|
|
85
|
+
const visibleEdges = edges.filter(edge => seen.has(executionReferenceKey(edge.from)) && seen.has(executionReferenceKey(edge.to)));
|
|
86
|
+
const binding = hash([source.id, source.revision, source.scope, root, nodeLimit, edgeLimit, byteLimit, visibleNodes, visibleEdges]);
|
|
87
|
+
const sign = (n: number, e: number): string => createHmac("sha256", source.cursorKey).update(JSON.stringify([binding, n, e])).digest("hex");
|
|
88
|
+
let n = 0, e = 0;
|
|
89
|
+
if (query.cursor !== undefined) {
|
|
90
|
+
if (typeof query.cursor !== "string" || query.cursor.length > LINEAGE_LIMITS.cursor || !/^[A-Za-z0-9_-]+$/.test(query.cursor)) return unavailable("cursor_invalid");
|
|
91
|
+
let cursor: unknown;
|
|
92
|
+
try { cursor = JSON.parse(Buffer.from(query.cursor, "base64url").toString("utf8")); }
|
|
93
|
+
catch { return unavailable("cursor_invalid"); }
|
|
94
|
+
if (!Array.isArray(cursor) || cursor.length !== 4) return unavailable("cursor_invalid");
|
|
95
|
+
const [cursorBinding, nextN, nextE, signature] = cursor;
|
|
96
|
+
if (cursorBinding !== binding || !Number.isSafeInteger(nextN) || !Number.isSafeInteger(nextE) || nextN < 0 || nextE < 0 ||
|
|
97
|
+
nextN > visibleNodes.length || nextE > visibleEdges.length || typeof signature !== "string" ||
|
|
98
|
+
!/^[0-9a-f]{64}$/.test(signature) || !timingSafeEqual(Buffer.from(signature, "hex"), Buffer.from(sign(nextN, nextE), "hex"))) return unavailable("cursor_invalid");
|
|
99
|
+
n = nextN; e = nextE;
|
|
100
|
+
}
|
|
101
|
+
const pageNodes: LineageNode[] = [], pageEdges: LineageEdge[] = [];
|
|
102
|
+
const page = (nextN: number, nextE: number): Extract<LineageResult, { state: "available" }> => {
|
|
103
|
+
const truncated = nextN < visibleNodes.length || nextE < visibleEdges.length;
|
|
104
|
+
return { state: "available", nodes: pageNodes, edges: pageEdges, truncated,
|
|
105
|
+
...(truncated ? { cursor: Buffer.from(JSON.stringify([binding, nextN, nextE, sign(nextN, nextE)])).toString("base64url") } : {}) };
|
|
106
|
+
};
|
|
107
|
+
while (n < visibleNodes.length && pageNodes.length < nodeLimit) {
|
|
108
|
+
pageNodes.push(visibleNodes[n]!);
|
|
109
|
+
if (bytes(page(n + 1, e)) > byteLimit) { pageNodes.pop(); break; }
|
|
110
|
+
n++;
|
|
111
|
+
}
|
|
112
|
+
while (e < visibleEdges.length && pageEdges.length < edgeLimit) {
|
|
113
|
+
pageEdges.push(visibleEdges[e]!);
|
|
114
|
+
if (bytes(page(n, e + 1)) > byteLimit) { pageEdges.pop(); break; }
|
|
115
|
+
e++;
|
|
116
|
+
}
|
|
117
|
+
if (!pageNodes.length && !pageEdges.length && (n < visibleNodes.length || e < visibleEdges.length)) return unavailable("unsupported");
|
|
118
|
+
return page(n, e);
|
|
119
|
+
} catch { return unavailable("corrupt"); }
|
|
120
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
/** ExecutionReferenceV1 is an opaque address, never authority or a resolver instruction. */
|
|
3
|
+
export const EXECUTION_PRODUCERS = ["ahub", "ares", "amem", "vmem"] as const;
|
|
4
|
+
export interface ExecutionReferenceV1 {
|
|
5
|
+
v: 1;
|
|
6
|
+
tool: typeof EXECUTION_PRODUCERS[number];
|
|
7
|
+
projectKey: string;
|
|
8
|
+
kind: string;
|
|
9
|
+
id: string;
|
|
10
|
+
revision?: string;
|
|
11
|
+
sha256?: string;
|
|
12
|
+
}
|
|
13
|
+
export const EXECUTION_REFERENCE_LIMITS = Object.freeze({ id: 256, projectKey: 256, revision: 128, kind: 64 });
|
|
14
|
+
const invalid = (): never => { throw new Error("execution reference has invalid fields or bounds; use the ExecutionReferenceV1 contract"); };
|
|
15
|
+
export function executionText(value: unknown, max: number): value is string {
|
|
16
|
+
return typeof value === "string" && value.length > 0 && value.length <= max &&
|
|
17
|
+
Buffer.byteLength(value, "utf8") <= max && !/[\p{Cc}\p{Cs}]/u.test(value);
|
|
18
|
+
}
|
|
19
|
+
export function parseExecutionReference(input: unknown): ExecutionReferenceV1 {
|
|
20
|
+
if (!input || typeof input !== "object" || types.isProxy(input) || Array.isArray(input) ||
|
|
21
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length) return invalid();
|
|
22
|
+
const fields = Object.getOwnPropertyDescriptors(input);
|
|
23
|
+
const keys = ["v", "tool", "projectKey", "kind", "id", "revision", "sha256"];
|
|
24
|
+
if (Object.entries(fields).some(([key, field]) => !keys.includes(key) || !("value" in field))) return invalid();
|
|
25
|
+
const get = (key: string): unknown => fields[key]?.value;
|
|
26
|
+
const tool = get("tool"), projectKey = get("projectKey"), kind = get("kind"), id = get("id");
|
|
27
|
+
if (get("v") !== 1 || !EXECUTION_PRODUCERS.includes(tool as ExecutionReferenceV1["tool"]) ||
|
|
28
|
+
!executionText(projectKey, EXECUTION_REFERENCE_LIMITS.projectKey) || !executionText(id, EXECUTION_REFERENCE_LIMITS.id) ||
|
|
29
|
+
!executionText(kind, EXECUTION_REFERENCE_LIMITS.kind) || !/^[a-z][a-z0-9._-]*$/.test(kind)) return invalid();
|
|
30
|
+
const revision = get("revision"), sha256 = get("sha256");
|
|
31
|
+
if ((Object.hasOwn(fields, "revision") && !executionText(revision, EXECUTION_REFERENCE_LIMITS.revision)) ||
|
|
32
|
+
(Object.hasOwn(fields, "sha256") && (typeof sha256 !== "string" || !/^[0-9a-f]{64}$/.test(sha256)))) return invalid();
|
|
33
|
+
return { v: 1, tool: tool as ExecutionReferenceV1["tool"], projectKey, kind, id,
|
|
34
|
+
...(revision === undefined ? {} : { revision: revision as string }), ...(sha256 === undefined ? {} : { sha256: sha256 as string }) };
|
|
35
|
+
}
|
|
36
|
+
export const executionReferenceKey = (ref: ExecutionReferenceV1): string => JSON.stringify(parseExecutionReference(ref));
|
package/src/hub/hub-tools.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { NOTE_KINDS } from "./envelope.ts";
|
|
|
8
8
|
|
|
9
9
|
/** Restricted to the fixed current-child tools server, never an ordinary peer tool. */
|
|
10
10
|
/** The fixed MCP surface of a current owned implementer child; independent of broader project roles. */
|
|
11
|
-
export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff", "hub_specs", "hub_delivery_resolve", "hub_evidence_record"]);
|
|
11
|
+
export const IMPLEMENTER_HUB_TOOL_NAMES: readonly string[] = Object.freeze(["hub_task_accept", "hub_task_decline", "hub_task_done", "hub_checkpoint", "hub_send", "hub_task_show", "hub_task_propose", "hub_remember", "hub_visual", "hub_visual_show", "hub_inbox", "hub_permission_request", "hub_study_show", "hub_experiment_show", "hub_experiment_record", "hub_study_handoff", "hub_specs", "hub_delivery_resolve", "hub_evidence_record"]);
|
|
12
12
|
|
|
13
13
|
export const IMPLEMENTER_PERMISSION_TOOL = {
|
|
14
14
|
name: "hub_permission_request",
|
|
@@ -22,6 +22,23 @@ export const ACTIONABLE_MESSAGE_INSTRUCTION = "Do not use [FYI] for PR ready, do
|
|
|
22
22
|
const str = { type: "string" } as const;
|
|
23
23
|
const id = { type: "integer", description: "task id" } as const;
|
|
24
24
|
const toolWriteRequestId = { type: "string", minLength: 1, maxLength: 256, pattern: "^v1/[A-Za-z0-9._-]{1,128}/[1-9][0-9]{0,15}/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", description: "control-transport-only request_id returned by an outcome-unknown write; inspect first and retry with the same key and identical arguments only if absent. Direct in-process tool calls cannot use request_id" } as const;
|
|
25
|
+
const visualSideSchema = { oneOf: [
|
|
26
|
+
{ type: "object", properties: { text: { type: "string", maxLength: 20 * 1024 } }, required: ["text"], additionalProperties: false },
|
|
27
|
+
{ type: "object", properties: { path: { type: "string", minLength: 1, maxLength: 4096 }, alt: { type: "string", maxLength: 1000 } }, required: ["path", "alt"], additionalProperties: false },
|
|
28
|
+
] } as const;
|
|
29
|
+
const visualTitle = { type: "string", maxLength: 300 } as const;
|
|
30
|
+
const visualCaption = { type: "string", maxLength: 2000 } as const;
|
|
31
|
+
const visualInputSchema = { oneOf: [
|
|
32
|
+
{ type: "object", properties: { kind: { const: "mermaid" }, source: { type: "string", minLength: 1, maxLength: 20 * 1024 }, title: visualTitle }, required: ["kind", "source"], additionalProperties: false },
|
|
33
|
+
{ type: "object", properties: { kind: { const: "comparison" }, title: visualTitle, options: { type: "array", minItems: 2, maxItems: 6, items: {
|
|
34
|
+
type: "object", properties: { title: visualTitle, summary: { type: "string", maxLength: 4000 }, recommended: { type: "boolean" },
|
|
35
|
+
pros: { type: "array", maxItems: 10, items: { type: "string", maxLength: 2000 } }, cons: { type: "array", maxItems: 10, items: { type: "string", maxLength: 2000 } } }, required: ["title"], additionalProperties: false,
|
|
36
|
+
} } }, required: ["kind", "options"], additionalProperties: false },
|
|
37
|
+
{ type: "object", properties: { kind: { const: "table" }, caption: visualCaption, columns: { type: "array", maxItems: 12, items: {
|
|
38
|
+
type: "object", properties: { key: { type: "string", minLength: 1, maxLength: 128 }, label: visualTitle, align: { enum: ["start", "end"] }, format: { enum: ["text", "number", "percent"] } }, required: ["key", "label"], additionalProperties: false,
|
|
39
|
+
} }, rows: { type: "array", maxItems: 500, items: { type: "object", additionalProperties: { oneOf: [{ type: "string", maxLength: 4000 }, { type: "number" }] } } } }, required: ["kind", "columns", "rows"], additionalProperties: false },
|
|
40
|
+
{ type: "object", properties: { kind: { const: "beforeAfter" }, caption: visualCaption, before: visualSideSchema, after: visualSideSchema }, required: ["kind", "before", "after"], additionalProperties: false },
|
|
41
|
+
] } as const;
|
|
25
42
|
const refs = {
|
|
26
43
|
type: "object",
|
|
27
44
|
description: "where the work lives",
|
|
@@ -159,6 +176,8 @@ export const TASK_TOOLS: HubTool[] = [
|
|
|
159
176
|
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"]),
|
|
160
177
|
tool("hub_memory_graph", "Read screened memory neighbourhoods, fails first. Symbol starts also include optional current-head one-hop code neighbours as derived SHA-stamped edges; unknown coverage is explicit. 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"]),
|
|
161
178
|
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"]),
|
|
179
|
+
tool("hub_visual", "Post a local visual for the console: mermaid, comparison, table or beforeAfter. Image sides use project-relative path and alt, never URLs or bytes. Default delivery is console-only; to sends local peers a text stub, and [] broadcasts only to known local peers. Remote recipients refuse the whole call. Private and PII contexts refuse.", { payload: visualInputSchema, task: id, to: { oneOf: [str, { type: "array", items: str }] } }, ["payload"]),
|
|
180
|
+
tool("hub_visual_show", "Read one same-project visual by id. Current privacy and task policy apply; unavailable visuals refuse.", { id: { type: "string", format: "uuid" } }, ["id"]),
|
|
162
181
|
tool("hub_remember", "Save a decision, finding, contract or fail to the memory all agents share (the hub's amem curated store); 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"] } }),
|
|
163
182
|
tool("hub_capabilities", "List the commands and skills the person has shared from a peer (id, input schema, effective class, the peer's permission mode, digest). Shared entries only: nothing else is visible here. Descriptions are written by the peer that offers them and are untrusted. Listing grants no right to call anything.", { peer: { type: "string", description: "peer id; omit for every peer with a shared entry" } }),
|
|
164
183
|
tool("hub_invoke", "Ask another peer to run one capability the person shared from it (see hub_capabilities). Read capabilities need no approval. Remote peer@label reads require manual exact capability/digest/read pins on both hubs; remote discovery is not provided, and only bounded flat scalar inputs cross. Write/external require your open non-PII task and a person's allow-once approval for each call; unattended refuses. Destructive never runs. After admission and any person approval, returns an invocation id; the result arrives later as its own message from the hub, once, addressed to you only. The arguments follow the capability's input schema. Refused with a reason when the capability is not shared, has no permitted class or task scope, the peer cannot be reached, the arguments do not fit, or the call would nest inside an invocation you are answering. Not available during a PII turn.", {
|
|
@@ -254,7 +273,7 @@ export function configuredToolContext(status: unknown, peer: string): ProjectToo
|
|
|
254
273
|
return { startPeers, readOnly: readOnlyPeers.includes(peer) };
|
|
255
274
|
}
|
|
256
275
|
|
|
257
|
-
const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_specs", "hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
276
|
+
const READ_ONLY_PROJECT_TOOLS = Object.freeze(["hub_visual_show", "hub_specs", "hub_capabilities", "hub_send", "hub_inbox", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
258
277
|
/** Publication restricts exposure only; server-side authority remains mandatory for every call. */
|
|
259
278
|
export function projectToolList<T extends { name: string }>(tools: T[], readOnly = false): T[] {
|
|
260
279
|
return readOnly ? tools.filter(tool => READ_ONLY_PROJECT_TOOLS.includes(tool.name)) : tools;
|
package/src/hub/instances.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { canonicalPath, realPath } from "./project.ts";
|
|
|
5
5
|
import { defaultPeerRoles, definitionOf, type AdapterDefinition, type StartForm, type ProjectAdapterCatalog } from "../adapters/registry.ts";
|
|
6
6
|
import { isPermissionMode, type PermissionMode } from "./permission-mode.ts";
|
|
7
7
|
import { isReviewScope } from "./review-scope.ts";
|
|
8
|
+
import { isNativeModelToken } from "../adapters/model-token.ts";
|
|
8
9
|
|
|
9
10
|
export interface InstanceConfig {
|
|
10
11
|
kind: string;
|
|
@@ -26,7 +27,7 @@ const JCODE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as
|
|
|
26
27
|
/** At most this many extra Jcode instances per project (#685). */
|
|
27
28
|
export const MAX_JCODE_INSTANCES = 8;
|
|
28
29
|
/** A flag value jcode takes as one argv word: bounded, printable, no spaces. */
|
|
29
|
-
|
|
30
|
+
export { isNativeModelToken } from "../adapters/model-token.ts";
|
|
30
31
|
export type InstancesConfig = Readonly<Record<string, InstanceConfig>>;
|
|
31
32
|
export interface InstanceCatalogEntry { id: string; kind: string; definition: AdapterDefinition; config?: InstanceConfig }
|
|
32
33
|
const MAX_INSTANCES = 64;
|
|
@@ -66,7 +67,7 @@ function jcodeInstanceFields(entry: Record<string, PropertyDescriptor>, path: st
|
|
|
66
67
|
const present = entry[key];
|
|
67
68
|
if (!present) continue;
|
|
68
69
|
const word: unknown = present.value;
|
|
69
|
-
if (
|
|
70
|
+
if (!isNativeModelToken(word)) return fail(`${path}.${key}`, "must be one bounded printable word without spaces");
|
|
70
71
|
out[key] = word;
|
|
71
72
|
}
|
|
72
73
|
if (entry.start_mode && entry.start_mode.value !== "headless") return fail(`${path}.start_mode`, "a Jcode instance starts headless");
|
|
@@ -215,7 +216,7 @@ export function validateInstances(raw: unknown, catalog: ProjectAdapterCatalog):
|
|
|
215
216
|
if (entry.model && modelSelect) {
|
|
216
217
|
if (!headlessWriter) return fail(`${path}.model`, "only a headless implementer takes a model");
|
|
217
218
|
const word: unknown = entry.model.value;
|
|
218
|
-
if (
|
|
219
|
+
if (!isNativeModelToken(word)) return fail(`${path}.model`, "must be one bounded printable word without spaces");
|
|
219
220
|
value.model = word;
|
|
220
221
|
}
|
|
221
222
|
if (entry.review_scope) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { KimiWireRow } from "../adapters/kimi-wire-usage.ts";
|
|
2
|
+
import type { HubEvent } from "./events.ts";
|
|
3
|
+
import { kimiRowTotal } from "../adapters/kimi-wire-usage.ts";
|
|
4
|
+
import type { KimiRowAttribution } from "./kimi-usage.ts";
|
|
5
|
+
|
|
6
|
+
export interface KimiEventPorts {
|
|
7
|
+
append(event: HubEvent): boolean;
|
|
8
|
+
read(): readonly HubEvent[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Exactly-once Kimi usage events, including a retry after only one of the two records was appended. */
|
|
12
|
+
export class KimiEventWriter {
|
|
13
|
+
private readonly tokens = new Set<string>();
|
|
14
|
+
private readonly usage = new Set<string>();
|
|
15
|
+
private readonly attributions = new Map<string, KimiRowAttribution>();
|
|
16
|
+
private dirty = false;
|
|
17
|
+
|
|
18
|
+
constructor(private readonly ports: KimiEventPorts, events: readonly HubEvent[] = []) { this.seed(events); }
|
|
19
|
+
|
|
20
|
+
private seed(events: readonly HubEvent[]): void {
|
|
21
|
+
for (const event of events) {
|
|
22
|
+
if (event.type === "tokens" && event.source === "kimi_wire" && event.id) this.tokens.add(event.id);
|
|
23
|
+
else if (event.type === "usage" && event.source === "kimi_wire") this.usage.add(event.id);
|
|
24
|
+
else continue;
|
|
25
|
+
if (event.id && event.attribution && !this.attributions.has(event.id)) this.attributions.set(event.id, { attribution: { task: event.task, attribution: event.attribution, ...(event.pii ? { pii: true } : {}) } });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
write(peer: string, row: KimiWireRow, counted: number, attributed: KimiRowAttribution): void {
|
|
30
|
+
if (this.dirty) { this.seed(this.ports.read()); this.dirty = false; }
|
|
31
|
+
const stable = this.attributions.get(row.id) ?? attributed;
|
|
32
|
+
this.attributions.set(row.id, stable);
|
|
33
|
+
if (!this.tokens.has(row.id)) {
|
|
34
|
+
const committed = this.ports.append({ type: "tokens", peer, source: "kimi_wire", id: row.id, n: counted, ...stable.attribution });
|
|
35
|
+
if (!committed) { this.dirty = true; throw new Error("Kimi tokens event commit failed"); }
|
|
36
|
+
this.tokens.add(row.id);
|
|
37
|
+
}
|
|
38
|
+
if (!this.usage.has(row.id)) {
|
|
39
|
+
const committed = this.ports.append({ type: "usage", peer, source: "kimi_wire", id: row.id, measuredAt: new Date(row.at).toISOString(), inputTokens: row.inputOther, outputTokens: row.output,
|
|
40
|
+
cacheReadTokens: row.inputCacheRead, cacheWriteTokens: row.inputCacheCreation, totalTokens: kimiRowTotal(row),
|
|
41
|
+
...stable.attribution, ...(row.model ? { servedModel: row.model } : {}) });
|
|
42
|
+
if (!committed) { this.dirty = true; throw new Error("Kimi usage event commit failed"); }
|
|
43
|
+
this.usage.add(row.id);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|