@staix/agent-hub 0.19.0 → 0.21.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 +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
package/src/hub/config-trust.ts
CHANGED
|
@@ -26,10 +26,16 @@ export const MACHINE_LOCAL = [
|
|
|
26
26
|
"local.read_allow",
|
|
27
27
|
"local.bash_network",
|
|
28
28
|
"local.network_allow", // the hosts the egress proxy opens (#65)
|
|
29
|
+
"local.doc_read", // #515: gives the local worker a document reader; a committed config may not hand a model more tools on someone else's machine
|
|
30
|
+
"local.web_fetch", // #515b: lets a model request pages from listed hosts; a committed config may not choose where a model's requests go on someone else's machine
|
|
29
31
|
"terminal.open", // the command a hub-made TUI start runs to open a terminal (#269)
|
|
32
|
+
"tasks.auto_pull", // #785 P1c: lets the hub start work on its own; a committed config may not turn it on for someone else's machine
|
|
33
|
+
"folder_triggers", // #512c: entries make the hub read a folder and propose a task per new file; a committed config may not point the hub at a folder on someone else's machine
|
|
34
|
+
"schedules", // #512: entries make the hub propose tasks on a timer; a committed config may not create work on someone else's machine
|
|
35
|
+
"corpus.paths", // #510: folders the hub reads as evidence; a committed config may not point the hub at a folder on someone else's machine
|
|
30
36
|
] as const;
|
|
31
37
|
/** Legacy exported field list stays compatible; new policies use the complete list below. */
|
|
32
|
-
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
38
|
+
export const ALL_MACHINE_LOCAL = [...MACHINE_LOCAL, "rooms", "tasks.queue_backpressure", "context.max_assignment_used", "action_policy", "protect_instruction_files", "qwen_cmd", "jcode_cmd", "jcode_home", "jcode_provider_profile", "adapters", "adapter_modules", "text_projection", "instances"] as const;
|
|
33
39
|
|
|
34
40
|
/** A known repository, including an unreadable marker, must never be mistaken for a plain directory. */
|
|
35
41
|
export function configRepositoryPresent(cwd: string): boolean {
|
|
@@ -95,6 +101,7 @@ const drop = (file: Record<string, unknown>, path: string) => {
|
|
|
95
101
|
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
96
102
|
*/
|
|
97
103
|
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
104
|
+
if (name !== "config.local.json") delete file.rooms;
|
|
98
105
|
const localOnly = name !== "config.local.json" && file.action_policy !== undefined;
|
|
99
106
|
if (localOnly) delete file.action_policy;
|
|
100
107
|
const originNotice = localOnly ? "action_policy ignored: only .agenthub/config.local.json may set it" : undefined;
|
|
@@ -9,7 +9,7 @@ export function readNativeContext(definition: AdapterDefinition | undefined, val
|
|
|
9
9
|
|
|
10
10
|
/** Native active-context occupancy, separate from accumulated billable usage (#185). */
|
|
11
11
|
export interface ContextReading {
|
|
12
|
-
source: "claude_statusline" | "codex_token_usage" | "acp_usage_update";
|
|
12
|
+
source: "claude_statusline" | "codex_token_usage" | "acp_usage_update" | "jcode_usage_update";
|
|
13
13
|
sessionId: string;
|
|
14
14
|
measuredAt: number;
|
|
15
15
|
tokens: number | null;
|
|
@@ -24,8 +24,8 @@ export interface ContextView {
|
|
|
24
24
|
used: number | null;
|
|
25
25
|
freshness: "fresh" | "stale" | "unknown";
|
|
26
26
|
}
|
|
27
|
-
export interface ContextConfig { gate: number; stale_min: number }
|
|
28
|
-
export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30 };
|
|
27
|
+
export interface ContextConfig { gate: number; stale_min: number; max_assignment_used?: number }
|
|
28
|
+
export const DEFAULT_CONTEXT: ContextConfig = { gate: 0, stale_min: 30, max_assignment_used: 0 };
|
|
29
29
|
export const unknownContext = (): ContextView => ({ source: null, measuredAt: null, tokens: null, window: null, used: null, freshness: "unknown" });
|
|
30
30
|
/** Invalid/stale readings hide the value but never rearm a threshold already crossed. */
|
|
31
31
|
export class ContextWindows {
|
|
@@ -46,6 +46,11 @@ export class ContextWindows {
|
|
|
46
46
|
if (over && !crossed && this.crossing(peer, reading) === false) return;
|
|
47
47
|
this.high.set(peer, { sessionId: reading.sessionId, crossed: over });
|
|
48
48
|
}
|
|
49
|
+
/** Copy native evidence without changing the checkpoint latch or collecting new telemetry. */
|
|
50
|
+
admission(peer: string, currentSession: string | undefined, attached: boolean): ContextAdmissionSnapshot {
|
|
51
|
+
const reading = this.readings.get(peer);
|
|
52
|
+
return { ...(reading ? { reading: { ...reading } } : {}), sessionId: currentSession, attached };
|
|
53
|
+
}
|
|
49
54
|
/** Reconsider a held crossing after release, using only the still-fresh reading and current native session. */
|
|
50
55
|
retry(peer: string, currentSession: string | undefined): void {
|
|
51
56
|
const reading = this.readings.get(peer);
|
|
@@ -59,3 +64,28 @@ export class ContextWindows {
|
|
|
59
64
|
return { source: r.source, measuredAt: positive(r.measuredAt) ? r.measuredAt : null, tokens: valid ? r.tokens : null, window: r.window, used: valid ? r.used : null, freshness: !valid ? stale ? "stale" : "unknown" : r.used === null ? "unknown" : "fresh" };
|
|
60
65
|
}
|
|
61
66
|
}
|
|
67
|
+
|
|
68
|
+
/** Host-only snapshot: session identifiers never enter routing traces or task history. */
|
|
69
|
+
export interface ContextAdmissionSnapshot {
|
|
70
|
+
reading?: ContextReading;
|
|
71
|
+
sessionId?: string;
|
|
72
|
+
attached: boolean;
|
|
73
|
+
}
|
|
74
|
+
export interface ContextAdmissionPolicy { max_used: number; stale_min: number }
|
|
75
|
+
export interface ContextAdmission {
|
|
76
|
+
policy: ContextAdmissionPolicy;
|
|
77
|
+
peers: Readonly<Record<string, ContextAdmissionSnapshot>>;
|
|
78
|
+
}
|
|
79
|
+
/** Pure new-work admission; unknown supplies no positive or negative context evidence. */
|
|
80
|
+
export function contextHeadroom(policy: ContextAdmissionPolicy, snapshot: ContextAdmissionSnapshot | undefined, now: number): { blocked: boolean; note: string } | undefined {
|
|
81
|
+
if (!Number.isFinite(policy.max_used) || policy.max_used <= 0 || policy.max_used > 1) return undefined;
|
|
82
|
+
const r = snapshot?.reading;
|
|
83
|
+
if (!r || !snapshot.attached || !snapshot.sessionId || r.sessionId !== snapshot.sessionId
|
|
84
|
+
|| !positive(now) || !positive(r.measuredAt) || r.measuredAt > now
|
|
85
|
+
|| !positive(policy.stale_min) || now - r.measuredAt > policy.stale_min * 60_000
|
|
86
|
+
|| r.used === null || !nonnegative(r.used) || r.used > 1) {
|
|
87
|
+
return { blocked: false, note: "context headroom: unknown (no fresh current-session reading)" };
|
|
88
|
+
}
|
|
89
|
+
const blocked = r.used >= policy.max_used;
|
|
90
|
+
return { blocked, note: `context headroom: used ${r.used} ${blocked ? "at or above" : "below"} max ${policy.max_used}` };
|
|
91
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { projectContext } from "./project.ts";
|
|
4
|
-
import { externalAdapterMetadata, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
4
|
+
import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
5
5
|
|
|
6
6
|
export function stateDirFor(cwd: string): string {
|
|
7
7
|
return projectContext(cwd).stateDir;
|
|
@@ -9,7 +9,7 @@ export function stateDirFor(cwd: string): string {
|
|
|
9
9
|
|
|
10
10
|
/** Control WS wire version. 2 = `deliver` carries `envs` (digests); 3 = `tools` role and task messages; 4 = budget messages and `hub_checkpoint`; 5 = `ask`; 6 = console-only `ui` session bootstrap; 8 = controlled recovery; 9 = Pi bridge metadata; 10 = durable delivery receipts; 11 = queue hold diagnostics; 12 = generation-bound channel settlement and execution budgets; 13 = turn-free facts (`facts` with tool, session and transcript binding, `silenced`) and per-recipient send results (issue #108). 14 = native context-window status and session-bound context checkpoint request ids (issue #185). 15 = conductor tools and permission deadlines/closed events (issues #190, #194). 16 = a Claude session without channel evidence: hello `channel: false` and `inbox` (issue #205). The plugin is installed apart from the daemon, so they can drift. */
|
|
11
11
|
export const PROTOCOL = 16;
|
|
12
|
-
export const DAEMON_FEATURES = ["external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
12
|
+
export const DAEMON_FEATURES = ["conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated"] as const;
|
|
13
13
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
14
14
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, PROTOCOL] as const;
|
|
15
15
|
|
|
@@ -166,6 +166,18 @@ export class ControlClient {
|
|
|
166
166
|
});
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
/** Read inert instance policy only from this authenticated, still-current daemon connection. */
|
|
170
|
+
async readExternalInstanceCatalog(projectRoot: string, timeoutMs = 3000): Promise<ExternalInstanceCatalog> {
|
|
171
|
+
const epoch = this.instanceId;
|
|
172
|
+
if (!epoch || !this.isOpen || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => this.features.includes(feature))) throw new Error("running hub lacks a live external instance catalog");
|
|
173
|
+
const reply = await this.request({ t: "status" }, timeoutMs);
|
|
174
|
+
const status = reply.status;
|
|
175
|
+
if (!this.isOpen || this.instanceId !== epoch || reply.ok === false || !status || status.instanceId !== epoch || status.cwd !== projectRoot) throw new Error("external instance catalog observation changed");
|
|
176
|
+
const catalog = externalInstanceCatalog(status.externalInstanceCatalog);
|
|
177
|
+
if (catalog.epoch !== epoch || catalog.projectRoot !== projectRoot) throw new Error("external instance catalog binding changed");
|
|
178
|
+
return catalog;
|
|
179
|
+
}
|
|
180
|
+
|
|
169
181
|
/** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
|
|
170
182
|
request(msg: Record<string, unknown>, timeoutMs = 30_000): Promise<any> {
|
|
171
183
|
if (this.ws.readyState !== WebSocket.OPEN) return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// agent-hub #510 (hub task #331): the `corpus` config block. Pure on purpose: it is read by the config schema, which the Claude channel bundle reaches, so it
|
|
2
|
+
// imports nothing that touches the file system. The index and the vault refusal are in `corpus.ts`.
|
|
3
|
+
|
|
4
|
+
export const MAX_CORPUS_ROOTS = 16;
|
|
5
|
+
export const MAX_CORPUS_PATH_CHARS = 1024;
|
|
6
|
+
|
|
7
|
+
export interface CorpusConfig { paths: string[] }
|
|
8
|
+
|
|
9
|
+
/** Why a path is not accepted, or undefined. A path is absolute or project-relative; `~` is never expanded, so what the person typed is what is read. */
|
|
10
|
+
export function corpusPathProblem(value: unknown): string | undefined {
|
|
11
|
+
if (typeof value !== "string" || !value.trim()) return "a corpus path is a non-empty string";
|
|
12
|
+
if (value.length > MAX_CORPUS_PATH_CHARS) return `a corpus path is at most ${MAX_CORPUS_PATH_CHARS} characters`;
|
|
13
|
+
if (/[\x00-\x1f\x7f]/.test(value)) return "a corpus path has no control characters";
|
|
14
|
+
if (value.startsWith("~")) return "write the absolute path: ~ is not expanded";
|
|
15
|
+
if (value !== value.trim()) return "a corpus path has no leading or trailing spaces";
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The checked block. `corpus.paths` names read-only folders of plain notes; nothing here reads the disk (the vault check does, per index refresh). */
|
|
20
|
+
export function corpusConfig(input: unknown): CorpusConfig {
|
|
21
|
+
if (input === undefined) return { paths: [] };
|
|
22
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("corpus must be an object");
|
|
23
|
+
const block = input as Record<string, unknown>;
|
|
24
|
+
for (const key of Object.keys(block)) if (key !== "paths") throw new Error(`corpus.${key}: unknown key`);
|
|
25
|
+
if (block.paths === undefined) return { paths: [] };
|
|
26
|
+
if (!Array.isArray(block.paths) || block.paths.length > MAX_CORPUS_ROOTS) throw new Error(`corpus.paths takes a list of at most ${MAX_CORPUS_ROOTS} folders`);
|
|
27
|
+
const out: string[] = [];
|
|
28
|
+
block.paths.forEach((path, index) => {
|
|
29
|
+
const problem = corpusPathProblem(path);
|
|
30
|
+
if (problem) throw new Error(`corpus.paths[${index}]: ${problem}`);
|
|
31
|
+
if (!out.includes(path as string)) out.push(path as string);
|
|
32
|
+
});
|
|
33
|
+
return { paths: out };
|
|
34
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { sanitize } from "./envelope.ts";
|
|
2
|
+
import { CorpusIndex, MAX_CORPUS_READS, MAX_FRONTMATTER_BYTES, MAX_NOTE_BYTES, MAX_HEADINGS, parseNote, type NoteRecord } from "./corpus.ts";
|
|
3
|
+
|
|
4
|
+
export const MAX_CORPUS_EVIDENCE = 6;
|
|
5
|
+
export const MAX_CORPUS_EVIDENCE_ITEM_BYTES = 768;
|
|
6
|
+
export const MAX_CORPUS_EVIDENCE_BYTES = 4096;
|
|
7
|
+
|
|
8
|
+
export interface CorpusEvidencePolicy {
|
|
9
|
+
allowed(): boolean;
|
|
10
|
+
version(): string;
|
|
11
|
+
/** Screen the complete, fresh note plus its raw metadata before any excerpt is produced. */
|
|
12
|
+
safe(text: string, paths: readonly string[]): boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CorpusEvidence { id: string; kind: "note"; text: string }
|
|
16
|
+
|
|
17
|
+
const normalize = (text: string): string => text.normalize("NFKC").toLowerCase();
|
|
18
|
+
const words = (text: string): string[] => [...new Set(normalize(text).match(/[\p{L}\p{N}]{2,}/gu) ?? [])];
|
|
19
|
+
const score = (query: readonly string[], text: string): number => {
|
|
20
|
+
const haystack = normalize(text);
|
|
21
|
+
return query.reduce((total, word) => total + (haystack.includes(word) ? 1 : 0), 0);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Slugs retain Unicode letters and numbers, fold case, and use hyphens for punctuation or whitespace. */
|
|
25
|
+
export function headingSlug(heading: string): string {
|
|
26
|
+
return normalize(heading).replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Escape only the delimiters in the citation grammar; Unicode and its spelling remain literal. */
|
|
30
|
+
function escapeCitationPart(value: string): string {
|
|
31
|
+
return value.replace(/[\\,#\[\]]/g, char => `\\${char}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function noteCitationId(baseId: string, slug: string): string {
|
|
35
|
+
const id = `${escapeCitationPart(baseId)}#${escapeCitationPart(slug)}`;
|
|
36
|
+
if (!isNoteCitation(id)) throw new Error("unsupported note citation id");
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A syntactic guard for note citations. Callers still need to match the full ID against evidence they actually supplied. */
|
|
41
|
+
export function isNoteCitation(id: string): boolean {
|
|
42
|
+
if (!id.startsWith("note:") || Buffer.byteLength(id) > 1024 || Array.from(id).some(char => { const point = char.codePointAt(0)!; return point <= 0x1f || point === 0x7f; })) return false;
|
|
43
|
+
let separator = -1, escaped = false;
|
|
44
|
+
for (let i = 5; i < id.length; i++) {
|
|
45
|
+
const char = id[i]!;
|
|
46
|
+
if (escaped) { if (!["\\", ",", "[", "]", "#"].includes(char)) return false; escaped = false; continue; }
|
|
47
|
+
if (char === "\\") { escaped = true; continue; }
|
|
48
|
+
if (char === "#") { if (separator >= 0) return false; separator = i; continue; }
|
|
49
|
+
if (char === "," || char === "[" || char === "]") return false;
|
|
50
|
+
}
|
|
51
|
+
return !escaped && separator > 5 && separator < id.length - 1 && id.slice(5, separator).includes("/");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface Section { slug: string; heading?: string; body: string }
|
|
55
|
+
|
|
56
|
+
function contentStart(lines: readonly string[]): number {
|
|
57
|
+
if (lines[0]?.trimEnd() !== "---") return 0;
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
for (let i = 1; i < lines.length; i++) {
|
|
60
|
+
bytes += Buffer.byteLength(lines[i]!) + 1;
|
|
61
|
+
if (bytes > MAX_FRONTMATTER_BYTES) return lines.length;
|
|
62
|
+
if (/^(---|\.\.\.)\s*$/.test(lines[i]!)) return i + 1;
|
|
63
|
+
}
|
|
64
|
+
return lines.length;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Split prose using the same Markdown heading expression and fenced-code rules as parseNote. */
|
|
68
|
+
function sections(text: string): Section[] {
|
|
69
|
+
const lines = text.split(/\r?\n/), start = contentStart(lines), bodyLines = lines.slice(start);
|
|
70
|
+
let endOfSections = bodyLines.length;
|
|
71
|
+
const boundaries: { line: number; text: string }[] = [];
|
|
72
|
+
let fence: string | undefined;
|
|
73
|
+
for (let i = 0; i < bodyLines.length; i++) {
|
|
74
|
+
const line = bodyLines[i]!;
|
|
75
|
+
const marker = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
|
|
76
|
+
if (marker) { const mark = marker[1]![0]!; if (!fence) fence = mark; else if (fence === mark) fence = undefined; continue; }
|
|
77
|
+
if (fence) continue;
|
|
78
|
+
const heading = /^ {0,3}(#{1,6})\s+(.+?)(?:\s+#+)?\s*$/.exec(line);
|
|
79
|
+
const cleanHeading = heading ? parseNote(line).headings[0]?.text : undefined;
|
|
80
|
+
if (cleanHeading) {
|
|
81
|
+
// A heading beyond the metadata cap is still a section boundary, never content of the preceding citation.
|
|
82
|
+
if (boundaries.length >= MAX_HEADINGS) { endOfSections = i; break; }
|
|
83
|
+
boundaries.push({ line: i, text: cleanHeading });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const occupied = new Set<string>();
|
|
88
|
+
const allocate = (raw: string): string => {
|
|
89
|
+
const base = headingSlug(raw);
|
|
90
|
+
let candidate = base, suffix = 2;
|
|
91
|
+
while (occupied.has(candidate)) candidate = `${base}-${suffix++}`;
|
|
92
|
+
occupied.add(candidate);
|
|
93
|
+
return candidate;
|
|
94
|
+
};
|
|
95
|
+
const hasIntro = boundaries.length === 0 || bodyLines.slice(0, boundaries[0]!.line).some(line => line.trim());
|
|
96
|
+
const result: Section[] = [];
|
|
97
|
+
if (hasIntro) {
|
|
98
|
+
occupied.add("top");
|
|
99
|
+
const end = boundaries[0]?.line ?? bodyLines.length;
|
|
100
|
+
result.push({ slug: "top", body: bodyLines.slice(0, end).join("\n").trim() });
|
|
101
|
+
}
|
|
102
|
+
for (let i = 0; i < boundaries.length; i++) {
|
|
103
|
+
const heading = boundaries[i]!;
|
|
104
|
+
const end = boundaries[i + 1]?.line ?? endOfSections;
|
|
105
|
+
result.push({ slug: allocate(heading.text), heading: heading.text, body: bodyLines.slice(heading.line + 1, end).join("\n").trim() });
|
|
106
|
+
}
|
|
107
|
+
if (!result.length) result.push({ slug: "top", body: bodyLines.join("\n").trim() });
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function metadata(record: NoteRecord): string {
|
|
112
|
+
return JSON.stringify({ id: record.id, root: record.root, path: record.path, title: record.title, frontmatter: record.frontmatter, tags: record.tags, aliases: record.aliases, headings: record.headings });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
116
|
+
let out = "", bytes = 0;
|
|
117
|
+
for (const char of text) {
|
|
118
|
+
const size = Buffer.byteLength(char);
|
|
119
|
+
if (bytes + size > maxBytes) break;
|
|
120
|
+
out += char; bytes += size;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function truncateExcerpt(text: string, maxBytes: number): string {
|
|
126
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
127
|
+
const marker = " [cut]", markerBytes = Buffer.byteLength(marker);
|
|
128
|
+
if (maxBytes < markerBytes) return "";
|
|
129
|
+
return `${truncateUtf8(text, maxBytes - markerBytes)}${marker}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function permitted(policy: CorpusEvidencePolicy): boolean {
|
|
133
|
+
try { return policy.allowed(); } catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Metadata-first retrieval with a fresh, whole-note privacy screen before any excerpt leaves this module. */
|
|
137
|
+
export function corpusEvidence(query: string, index: CorpusIndex, policy: CorpusEvidencePolicy): CorpusEvidence[] {
|
|
138
|
+
if (!query.trim() || !permitted(policy)) return [];
|
|
139
|
+
let version: string;
|
|
140
|
+
try { version = policy.version(); } catch { return []; }
|
|
141
|
+
index.refresh();
|
|
142
|
+
if (!permitted(policy)) return [];
|
|
143
|
+
const terms = words(query);
|
|
144
|
+
if (!terms.length) return [];
|
|
145
|
+
// ponytail: ceiling is 32 metadata-ranked candidates; upgrade by adding a query index over approved note text, never by opening every note body.
|
|
146
|
+
const candidates = index.notes().map(record => ({ record, score: score(terms, [record.title, record.path, ...record.tags, ...record.aliases, ...record.headings.map(h => h.text)].filter(Boolean).join(" ")) }))
|
|
147
|
+
.filter(item => item.score > 0).sort((a, b) => b.score - a.score || a.record.id.localeCompare(b.record.id)).slice(0, MAX_CORPUS_READS);
|
|
148
|
+
const reads = index.readMany(candidates.map(item => item.record.id));
|
|
149
|
+
if (!permitted(policy)) return [];
|
|
150
|
+
let currentVersion: string;
|
|
151
|
+
try { currentVersion = policy.version(); } catch { return []; }
|
|
152
|
+
if (currentVersion !== version) return [];
|
|
153
|
+
|
|
154
|
+
const out: CorpusEvidence[] = [];
|
|
155
|
+
let aggregate = 0;
|
|
156
|
+
for (const read of reads) {
|
|
157
|
+
const record = read.record;
|
|
158
|
+
const currentMetadata = metadata(record);
|
|
159
|
+
if (Buffer.byteLength(read.text) > MAX_NOTE_BYTES) continue;
|
|
160
|
+
try { if (!policy.safe(`${read.text}\n${currentMetadata}`, [read.path, record.path, record.id])) continue; }
|
|
161
|
+
catch { continue; }
|
|
162
|
+
const currentScore = score(terms, [record.title, record.path, ...record.tags, ...record.aliases, ...record.headings.map(h => h.text)].filter(Boolean).join(" "));
|
|
163
|
+
if (!currentScore) continue;
|
|
164
|
+
const noteSections = sections(read.text);
|
|
165
|
+
const candidatesForNote = noteSections.map(section => ({ section, score: score(terms, `${section.heading ?? ""} ${section.body}`) }))
|
|
166
|
+
.sort((a, b) => b.score - a.score);
|
|
167
|
+
const best = candidatesForNote[0]?.section;
|
|
168
|
+
if (!best) continue;
|
|
169
|
+
const rawExcerpt = [best.heading ? `# ${best.heading}` : "", best.body].filter(Boolean).join("\n");
|
|
170
|
+
const safeText = sanitize(rawExcerpt);
|
|
171
|
+
let id: string;
|
|
172
|
+
try { id = noteCitationId(record.id, best.slug); } catch { continue; }
|
|
173
|
+
const remainingText = MAX_CORPUS_EVIDENCE_BYTES - aggregate - Buffer.byteLength(id);
|
|
174
|
+
if (!id || remainingText <= 0) continue;
|
|
175
|
+
const text = truncateExcerpt(safeText, Math.min(MAX_CORPUS_EVIDENCE_ITEM_BYTES, remainingText));
|
|
176
|
+
if (!text) continue;
|
|
177
|
+
const cost = Buffer.byteLength(id) + Buffer.byteLength(text);
|
|
178
|
+
out.push({ id, kind: "note", text });
|
|
179
|
+
aggregate += cost;
|
|
180
|
+
if (out.length >= MAX_CORPUS_EVIDENCE) break;
|
|
181
|
+
}
|
|
182
|
+
try { if (policy.version() !== version || !permitted(policy)) return []; } catch { return []; }
|
|
183
|
+
return out;
|
|
184
|
+
}
|