@ixo/oracle-runtime-workers 0.13.0 → 0.16.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/README.md +32 -10
- package/package.json +13 -8
- package/src/artifacts/config.ts +158 -0
- package/src/artifacts/crypto.ts +94 -0
- package/src/artifacts/routes.ts +73 -0
- package/src/artifacts/store.ts +238 -0
- package/src/artifacts/sweep.ts +200 -0
- package/src/artifacts/tool.ts +103 -0
- package/src/artifacts/viewer-page.ts +614 -0
- package/src/attachments/download.ts +168 -45
- package/src/attachments/index.ts +9 -0
- package/src/attachments/magic.ts +33 -0
- package/src/attachments/pipeline.ts +105 -49
- package/src/attachments/sandbox-archive.ts +9 -8
- package/src/attachments/view-cache.ts +142 -0
- package/src/attachments/view.ts +53 -12
- package/src/channels/auth.ts +310 -0
- package/src/channels/contract.ts +246 -0
- package/src/channels/turns.ts +280 -0
- package/src/core/budgeted-llm.ts +223 -0
- package/src/core/capability-router.ts +273 -0
- package/src/core/context-budget.ts +97 -10
- package/src/core/env.ts +111 -0
- package/src/core/execution-profile.ts +17 -0
- package/src/core/index.ts +267 -9
- package/src/core/llm.ts +30 -9
- package/src/core/main-agent-types.ts +41 -1
- package/src/core/main-agent.ts +367 -65
- package/src/core/manifest.ts +34 -0
- package/src/core/meta-tools.ts +128 -18
- package/src/core/middlewares/capability-gate.ts +130 -12
- package/src/core/middlewares/context-guard.ts +55 -24
- package/src/core/middlewares/index.ts +20 -2
- package/src/core/middlewares/page-context.ts +46 -2
- package/src/core/middlewares/return-direct-first.ts +47 -0
- package/src/core/middlewares/summarization.ts +152 -10
- package/src/core/middlewares/tool-execution.ts +323 -0
- package/src/core/middlewares/tool-repetition-guard.ts +202 -35
- package/src/core/middlewares/turn-boundary.ts +162 -0
- package/src/core/openrouter-pricing.ts +155 -22
- package/src/core/prompt-composer.ts +112 -7
- package/src/core/registries.ts +253 -48
- package/src/core/request-admission.ts +159 -0
- package/src/core/runtime-context.ts +107 -8
- package/src/core/state.ts +1 -1
- package/src/core/sub-agent-fallback.ts +21 -2
- package/src/core/subagent-as-tool.ts +78 -43
- package/src/core/tool-access.ts +189 -0
- package/src/core/tool-scheduler.ts +125 -0
- package/src/core/turn-budget.ts +205 -0
- package/src/core/turn-time-note.ts +43 -0
- package/src/core/user-kv.ts +446 -0
- package/src/core/wrap-plugin-tool.ts +20 -0
- package/src/delivery/plan.ts +278 -0
- package/src/delivery/profile.ts +161 -0
- package/src/delivery/prompt.ts +27 -0
- package/src/delivery/schema.ts +61 -0
- package/src/delivery/shaper.ts +389 -0
- package/src/delivery/types.ts +84 -0
- package/src/do/ambient.ts +51 -15
- package/src/do/contracts.ts +170 -6
- package/src/do/idle-eviction.ts +38 -0
- package/src/do/matrix-turn-ledger.ts +24 -5
- package/src/do/result-store-test-do.ts +137 -3
- package/src/do/result-store.ts +239 -58
- package/src/do/room-mirror.ts +29 -0
- package/src/do/run-buffer.ts +64 -9
- package/src/do/run-coordinator.ts +451 -76
- package/src/do/run-request.ts +2 -0
- package/src/do/run-store-test-do.ts +301 -1
- package/src/do/run-store.ts +385 -43
- package/src/do/secrets-adapter.ts +22 -3
- package/src/do/sse-stream.ts +60 -9
- package/src/do/transcript.ts +48 -5
- package/src/do/turn-body.ts +105 -5
- package/src/do/ucan-service.ts +204 -34
- package/src/do/user-oracle-do.ts +2003 -274
- package/src/feedback/config.ts +127 -0
- package/src/feedback/contract.ts +148 -0
- package/src/feedback/linear-sink.ts +285 -0
- package/src/feedback/privacy.ts +150 -0
- package/src/feedback/reservation.ts +298 -0
- package/src/feedback/submit.ts +247 -0
- package/src/index.ts +138 -6
- package/src/llm/byo-adapter.ts +27 -6
- package/src/llm/byo-catalog.ts +13 -0
- package/src/llm/byo-model-fallback.ts +248 -0
- package/src/llm/byo-service.ts +45 -8
- package/src/llm/provider-error.ts +139 -2
- package/src/matrix/gateway-do.ts +504 -108
- package/src/matrix/group-chat.ts +66 -23
- package/src/matrix/inbox-store.ts +16 -1
- package/src/matrix/ingest.ts +79 -14
- package/src/matrix/media-deadline.ts +130 -0
- package/src/matrix/replay-format.ts +5 -3
- package/src/matrix/reply-parts.ts +92 -0
- package/src/matrix/room-alias.ts +96 -0
- package/src/matrix/user-homeserver.ts +166 -7
- package/src/owner-store/ixo-vfs-store.ts +170 -43
- package/src/owner-store/migrating-store.ts +31 -15
- package/src/owner-store/types.ts +18 -3
- package/src/plugin-api/index.ts +43 -2
- package/src/plugin-api/oracle-plugin.ts +35 -0
- package/src/plugin-api/request-admission.ts +41 -0
- package/src/plugin-api/tool-helper.ts +25 -1
- package/src/plugin-api/tool-plane.ts +92 -0
- package/src/plugin-api/types.ts +194 -6
- package/src/plugins/agui/agui.plugin.ts +31 -16
- package/src/plugins/composio/composio-tools.ts +57 -6
- package/src/plugins/composio/composio-ucan.ts +3 -2
- package/src/plugins/composio/composio.plugin.ts +2 -0
- package/src/plugins/delegated-capabilities.ts +25 -0
- package/src/plugins/editor/content-session.ts +173 -83
- package/src/plugins/editor/content-tools.ts +76 -17
- package/src/plugins/editor/editor-mx.ts +28 -9
- package/src/plugins/editor/editor.plugin.ts +3 -1
- package/src/plugins/editor/failures.ts +28 -7
- package/src/plugins/editor/prompts.ts +2 -1
- package/src/plugins/editor/provider.ts +553 -40
- package/src/plugins/editor/standalone-editor-tool.ts +61 -18
- package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
- package/src/plugins/flows/action-metadata.ts +111 -11
- package/src/plugins/flows/actions.ts +18 -0
- package/src/plugins/flows/decision-context.ts +31 -0
- package/src/plugins/flows/edit.ts +223 -52
- package/src/plugins/flows/errors.ts +22 -1
- package/src/plugins/flows/flow-doc.ts +92 -9
- package/src/plugins/flows/flows.plugin.ts +11 -5
- package/src/plugins/flows/forms.ts +34 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/input-policy.ts +140 -0
- package/src/plugins/flows/prompts.ts +14 -11
- package/src/plugins/flows/read.ts +49 -9
- package/src/plugins/flows/ref-syntax.ts +129 -0
- package/src/plugins/flows/test-homeserver.ts +245 -0
- package/src/plugins/flows/tools/authoring.ts +216 -112
- package/src/plugins/flows/tools/discovery.ts +4 -1
- package/src/plugins/flows/tools/forms.ts +21 -2
- package/src/plugins/flows/tools/settings.ts +34 -13
- package/src/plugins/flows/translator.ts +13 -10
- package/src/plugins/flows/types.ts +167 -42
- package/src/plugins/index.ts +11 -1
- package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
- package/src/plugins/ixo-transaction/index.ts +2 -0
- package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
- package/src/plugins/ixo-transaction/receipts.ts +99 -0
- package/src/plugins/ixo-transaction/tools.ts +329 -0
- package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
- package/src/plugins/mcp-tool-adapter.ts +23 -2
- package/src/plugins/memory/memory-tools.ts +7 -2
- package/src/plugins/memory/memory-ucan.ts +3 -2
- package/src/plugins/memory/memory.plugin.ts +2 -0
- package/src/plugins/pod-creator/blueprint-store.ts +163 -0
- package/src/plugins/pod-creator/blueprint-types.ts +61 -0
- package/src/plugins/pod-creator/bounded-map.ts +87 -0
- package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
- package/src/plugins/pod-creator/chain-gateway.ts +110 -0
- package/src/plugins/pod-creator/config.ts +85 -0
- package/src/plugins/pod-creator/create-session-store.ts +180 -0
- package/src/plugins/pod-creator/create-tools.ts +538 -0
- package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
- package/src/plugins/pod-creator/index.ts +65 -0
- package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
- package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
- package/src/plugins/pod-creator/stage.ts +201 -0
- package/src/plugins/pod-creator/sub-agents.ts +189 -0
- package/src/plugins/portal/action-log.ts +21 -1
- package/src/plugins/portal/declared-tools.ts +90 -0
- package/src/plugins/portal/portal.plugin.ts +42 -16
- package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
- package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
- package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
- package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
- package/src/plugins/tasks/manifest.ts +1 -1
- package/src/plugins/tasks/middleware.ts +20 -7
- package/src/plugins/tasks/tasks-tools.ts +30 -3
- package/src/plugins/tasks/tasks.plugin.ts +64 -11
- package/src/plugins/user-preferences/schema.ts +12 -1
- package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
- package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
- package/src/plugins/vfs/vfs-client.ts +250 -71
- package/src/plugins/vfs/vfs-content.ts +30 -11
- package/src/plugins/vfs/vfs-errors.ts +22 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
- package/src/plugins/vfs/vfs-tools.ts +60 -20
- package/src/realtime/frontend-call-registry.ts +228 -81
- package/src/realtime/realtime-endpoint.ts +215 -44
- package/src/realtime/session-socket-hub.ts +47 -2
- package/src/secrets/secrets-service.ts +9 -3
- package/src/shell/app.ts +562 -82
- package/src/shell/auth.ts +131 -37
- package/src/shell/turn-body-cap.ts +11 -13
- package/src/sqlite/blob-compactor.ts +61 -28
- package/src/sqlite/database.ts +156 -90
- package/src/sqlite/do-vfs.ts +416 -129
- package/src/sqlite/page-tier.ts +133 -40
- package/src/sqlite/serialization.ts +40 -0
- package/src/sqlite/sessions-store.ts +10 -2
- package/src/sqlite/sqlite-saver.ts +139 -19
- package/src/sqlite/user-kv-store.ts +317 -0
- package/src/tasks/schedule.ts +201 -12
- package/src/tasks/scheduler.ts +586 -98
- package/src/tasks/spec.ts +67 -3
- package/src/tasks/store.ts +292 -27
- package/src/tasks/topic-deliverables.ts +70 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { RuntimeContext, UserKvSurface } from '../../plugin-api/types';
|
|
2
|
+
import {
|
|
3
|
+
podBlueprintSchema,
|
|
4
|
+
type BlueprintSection,
|
|
5
|
+
type PodBlueprint,
|
|
6
|
+
} from './blueprint-types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Durable per-thread store for the evolving POD blueprint. The blueprint must
|
|
10
|
+
* outlive a single request (each request builds a fresh `RuntimeContext`), so
|
|
11
|
+
* it is held outside the graph state — never as a graph-state field.
|
|
12
|
+
*/
|
|
13
|
+
export interface BlueprintStore {
|
|
14
|
+
/** Create the blueprint for a thread, or return the existing one. */
|
|
15
|
+
init(threadId: string, brief: string | undefined): Promise<PodBlueprint>;
|
|
16
|
+
/** Read the current blueprint, or `null` if no session has started. */
|
|
17
|
+
get(threadId: string): Promise<PodBlueprint | null>;
|
|
18
|
+
/** Record or replace a section; returns the updated blueprint. */
|
|
19
|
+
putSection(
|
|
20
|
+
threadId: string,
|
|
21
|
+
section: BlueprintSection,
|
|
22
|
+
): Promise<PodBlueprint>;
|
|
23
|
+
/** Discard the thread's blueprint so a fresh design can start. */
|
|
24
|
+
reset(threadId: string): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolves the blueprint store for one request (it lives in the user's database). */
|
|
28
|
+
export type BlueprintStoreFor = (ctx: RuntimeContext) => BlueprintStore;
|
|
29
|
+
|
|
30
|
+
/** `ctx.kv` namespace holding one blueprint per thread. */
|
|
31
|
+
export const BLUEPRINT_NAMESPACE = 'pod-creator/blueprints';
|
|
32
|
+
|
|
33
|
+
/** A design session that idles longer than this is discarded. */
|
|
34
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
35
|
+
/** Design sessions retained per user before LRU eviction. */
|
|
36
|
+
const DEFAULT_MAX_ENTRIES = 500;
|
|
37
|
+
|
|
38
|
+
export interface KvBlueprintStoreOptions {
|
|
39
|
+
maxEntries?: number;
|
|
40
|
+
ttlMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nowIso = (): string => new Date().toISOString();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a stored blueprint. A row that no longer matches the schema is
|
|
47
|
+
* reported rather than silently treated as absent: the user can discard it
|
|
48
|
+
* with `start_pod_design({ restart: true })`.
|
|
49
|
+
*/
|
|
50
|
+
function parseStored(threadId: string, value: unknown): PodBlueprint | null {
|
|
51
|
+
if (value === undefined) return null;
|
|
52
|
+
const parsed = podBlueprintSchema.safeParse(value);
|
|
53
|
+
if (!parsed.success) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`The stored POD blueprint for thread ${threadId} is unreadable (${parsed.error.issues
|
|
56
|
+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
57
|
+
.join(
|
|
58
|
+
'; ',
|
|
59
|
+
)}). Restart the design with start_pod_design({ restart: true }).`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return parsed.data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* {@link BlueprintStore} over the host's `ctx.kv` rows in the user's own
|
|
67
|
+
* database: the blueprint survives the user object being evicted and restored
|
|
68
|
+
* from its owner copy. Bounded per user (LRU + idle TTL, enforced by the KV
|
|
69
|
+
* store on every write), and every mutation is one atomic read-modify-write,
|
|
70
|
+
* so specialists submitting sections concurrently never lose one another's.
|
|
71
|
+
* Values cross the store as JSON, so a caller can never mutate the stored
|
|
72
|
+
* document in place.
|
|
73
|
+
*/
|
|
74
|
+
export class KvBlueprintStore implements BlueprintStore {
|
|
75
|
+
private readonly writeOptions: { idleTtlMs: number; maxEntries: number };
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly kv: UserKvSurface,
|
|
79
|
+
options: KvBlueprintStoreOptions = {},
|
|
80
|
+
) {
|
|
81
|
+
this.writeOptions = {
|
|
82
|
+
idleTtlMs: options.ttlMs ?? DEFAULT_TTL_MS,
|
|
83
|
+
maxEntries: options.maxEntries ?? DEFAULT_MAX_ENTRIES,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async init(
|
|
88
|
+
threadId: string,
|
|
89
|
+
brief: string | undefined,
|
|
90
|
+
): Promise<PodBlueprint> {
|
|
91
|
+
const written = await this.kv.update(
|
|
92
|
+
BLUEPRINT_NAMESPACE,
|
|
93
|
+
threadId,
|
|
94
|
+
(current) => {
|
|
95
|
+
const existing = parseStored(threadId, current);
|
|
96
|
+
if (existing) {
|
|
97
|
+
if (brief && existing.brief === undefined) {
|
|
98
|
+
return { ...existing, brief, updatedAt: nowIso() };
|
|
99
|
+
}
|
|
100
|
+
return existing;
|
|
101
|
+
}
|
|
102
|
+
const now = nowIso();
|
|
103
|
+
const created: PodBlueprint = {
|
|
104
|
+
threadId,
|
|
105
|
+
sections: {},
|
|
106
|
+
createdAt: now,
|
|
107
|
+
updatedAt: now,
|
|
108
|
+
};
|
|
109
|
+
if (brief !== undefined) {
|
|
110
|
+
created.brief = brief;
|
|
111
|
+
}
|
|
112
|
+
return created;
|
|
113
|
+
},
|
|
114
|
+
this.writeOptions,
|
|
115
|
+
);
|
|
116
|
+
return this.required(threadId, written);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async get(threadId: string): Promise<PodBlueprint | null> {
|
|
120
|
+
return parseStored(
|
|
121
|
+
threadId,
|
|
122
|
+
await this.kv.get(BLUEPRINT_NAMESPACE, threadId),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async putSection(
|
|
127
|
+
threadId: string,
|
|
128
|
+
section: BlueprintSection,
|
|
129
|
+
): Promise<PodBlueprint> {
|
|
130
|
+
const written = await this.kv.update(
|
|
131
|
+
BLUEPRINT_NAMESPACE,
|
|
132
|
+
threadId,
|
|
133
|
+
(current) => {
|
|
134
|
+
const now = nowIso();
|
|
135
|
+
const bp = parseStored(threadId, current) ?? {
|
|
136
|
+
threadId,
|
|
137
|
+
sections: {},
|
|
138
|
+
createdAt: now,
|
|
139
|
+
updatedAt: now,
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
...bp,
|
|
143
|
+
sections: { ...bp.sections, [section.role]: section },
|
|
144
|
+
updatedAt: now,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
this.writeOptions,
|
|
148
|
+
);
|
|
149
|
+
return this.required(threadId, written);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async reset(threadId: string): Promise<void> {
|
|
153
|
+
await this.kv.delete(BLUEPRINT_NAMESPACE, threadId);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private required(threadId: string, written: unknown): PodBlueprint {
|
|
157
|
+
const bp = parseStored(threadId, written);
|
|
158
|
+
if (!bp) {
|
|
159
|
+
throw new Error(`POD blueprint for thread ${threadId} was not written`);
|
|
160
|
+
}
|
|
161
|
+
return bp;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DESIGN_POD_STAGES, type DesignPodStage } from './design-pod-roles';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One section of the blueprint, produced by a specialist role. The `content`
|
|
6
|
+
* is role-defined and opaque to the conductor — it is recorded, never parsed,
|
|
7
|
+
* by the orchestration layer. Gate-bearing roles (the evaluate oracles and the
|
|
8
|
+
* launch-readiness gate) additionally carry a `verdict`.
|
|
9
|
+
*
|
|
10
|
+
* Declared as a schema because the blueprint is stored as JSON in the user's
|
|
11
|
+
* database and read back as `unknown`.
|
|
12
|
+
*/
|
|
13
|
+
export const blueprintSectionSchema = z.object({
|
|
14
|
+
/** The role id that produced this section (e.g. `service_architect`). */
|
|
15
|
+
role: z.string(),
|
|
16
|
+
/** The lifecycle stage the section belongs to. */
|
|
17
|
+
stage: z.enum(DESIGN_POD_STAGES),
|
|
18
|
+
/** The section's structured content (role-defined). */
|
|
19
|
+
content: z.unknown(),
|
|
20
|
+
/** Gate verdict for gate-bearing roles; absent for plain sections. */
|
|
21
|
+
verdict: z.enum(['pass', 'fail']).optional(),
|
|
22
|
+
/** Blocking issues when `verdict` is `'fail'`. */
|
|
23
|
+
blockers: z.array(z.string()).optional(),
|
|
24
|
+
/** ISO-8601 timestamp when the section was recorded. */
|
|
25
|
+
recordedAt: z.string(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type BlueprintSection = z.infer<typeof blueprintSectionSchema>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The per-thread blueprint document the conductor builds up across a design
|
|
32
|
+
* session. Sections are keyed by role id (one section per specialist). Stage
|
|
33
|
+
* and readiness are DERIVED from these sections — they are never stored, so the
|
|
34
|
+
* document stays a single source of truth.
|
|
35
|
+
*/
|
|
36
|
+
export const podBlueprintSchema = z.object({
|
|
37
|
+
/** The owning thread id (`RuntimeContext.session.id`). */
|
|
38
|
+
threadId: z.string(),
|
|
39
|
+
/** A concise statement of the POD's intent, set at session start. */
|
|
40
|
+
brief: z.string().optional(),
|
|
41
|
+
/** Recorded sections, keyed by role id. */
|
|
42
|
+
sections: z.record(z.string(), blueprintSectionSchema),
|
|
43
|
+
/** ISO-8601 timestamps. */
|
|
44
|
+
createdAt: z.string(),
|
|
45
|
+
updatedAt: z.string(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export type PodBlueprint = z.infer<typeof podBlueprintSchema>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The assembled artifact produced once the launch-readiness gate passes — the
|
|
52
|
+
* `service_pod_blueprint`. The exact registry schema is refined when the
|
|
53
|
+
* design-pod templates are wired; this is the structural assembly of the
|
|
54
|
+
* recorded sections grouped by stage.
|
|
55
|
+
*/
|
|
56
|
+
export interface ServicePodBlueprint {
|
|
57
|
+
threadId: string;
|
|
58
|
+
brief?: string;
|
|
59
|
+
stages: Record<DesignPodStage, BlueprintSection[]>;
|
|
60
|
+
assembledAt: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Map with an entry cap and an idle TTL, for caches held on the plugin
|
|
3
|
+
* instance, which lives as long as the isolate and is shared by every user
|
|
4
|
+
* object the isolate hosts. Reads and writes refresh an entry's recency and
|
|
5
|
+
* idle deadline; when the cap is exceeded the least-recently-used entry is
|
|
6
|
+
* evicted. Expiry is lazy — enforced on access, not by timers (a pending
|
|
7
|
+
* timer would keep a Durable Object resident) — so the structure is safe to
|
|
8
|
+
* hold indefinitely. Durable per-user state does not belong here: it goes
|
|
9
|
+
* through `ctx.kv`, which applies the same bounds in the user's database.
|
|
10
|
+
*/
|
|
11
|
+
export interface BoundedMapOptions {
|
|
12
|
+
/** Maximum live entries; the least-recently-used entry is evicted beyond it. */
|
|
13
|
+
maxEntries: number;
|
|
14
|
+
/** Idle time after the last read/write before an entry expires. */
|
|
15
|
+
ttlMs: number;
|
|
16
|
+
/** Clock override for tests. */
|
|
17
|
+
now?: () => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Entry<V> {
|
|
21
|
+
value: V;
|
|
22
|
+
expiresAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class BoundedMap<V> {
|
|
26
|
+
private readonly entries = new Map<string, Entry<V>>();
|
|
27
|
+
private readonly maxEntries: number;
|
|
28
|
+
private readonly ttlMs: number;
|
|
29
|
+
private readonly now: () => number;
|
|
30
|
+
|
|
31
|
+
constructor(options: BoundedMapOptions) {
|
|
32
|
+
this.maxEntries = options.maxEntries;
|
|
33
|
+
this.ttlMs = options.ttlMs;
|
|
34
|
+
this.now = options.now ?? Date.now;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(key: string): V | undefined {
|
|
38
|
+
const entry = this.entries.get(key);
|
|
39
|
+
if (!entry) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (entry.expiresAt <= this.now()) {
|
|
43
|
+
this.entries.delete(key);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
// Re-insert to mark as most-recently-used and extend the idle deadline.
|
|
47
|
+
this.entries.delete(key);
|
|
48
|
+
entry.expiresAt = this.now() + this.ttlMs;
|
|
49
|
+
this.entries.set(key, entry);
|
|
50
|
+
return entry.value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
set(key: string, value: V): void {
|
|
54
|
+
this.entries.delete(key);
|
|
55
|
+
this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
|
|
56
|
+
this.evict();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
delete(key: string): void {
|
|
60
|
+
this.entries.delete(key);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
clear(): void {
|
|
64
|
+
this.entries.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get size(): number {
|
|
68
|
+
return this.entries.size;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop expired entries, then trim oldest-first down to the cap. */
|
|
72
|
+
private evict(): void {
|
|
73
|
+
const cutoff = this.now();
|
|
74
|
+
for (const [key, entry] of this.entries) {
|
|
75
|
+
if (entry.expiresAt <= cutoff) {
|
|
76
|
+
this.entries.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
while (this.entries.size > this.maxEntries) {
|
|
80
|
+
const oldest = this.entries.keys().next();
|
|
81
|
+
if (oldest.done) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
this.entries.delete(oldest.value);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import type { RuntimeContext } from '../../plugin-api/types';
|
|
2
|
+
import { mintInvocationSafely, resolveServiceDidSafely } from '../ucan-failure';
|
|
3
|
+
import { BoundedMap } from './bounded-map';
|
|
4
|
+
import { DEFAULT_NETWORK } from './config';
|
|
5
|
+
|
|
6
|
+
/** Public capsules registry served by ai-skills. */
|
|
7
|
+
export const DEFAULT_CAPSULES_BASE_URL = 'https://capsules.skills.ixo.earth';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Capsule texts cached across requests. Keyed by capsule name only, so the
|
|
11
|
+
* twelve roles fill at most twelve entries; the cap is a guard, not a budget.
|
|
12
|
+
*/
|
|
13
|
+
const CACHE_MAX_ENTRIES = 64;
|
|
14
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How long a failed fetch is remembered: within it every turn uses the
|
|
18
|
+
* built-in prompt without another request (and without another log line).
|
|
19
|
+
*/
|
|
20
|
+
export const CAPSULE_FAILURE_TTL_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/** Default deadline of one registry instructions request. */
|
|
23
|
+
export const DEFAULT_CAPSULE_FETCH_TIMEOUT_MS = 10_000;
|
|
24
|
+
|
|
25
|
+
/** Largest instructions body accepted from the registry (a larger one is refused). */
|
|
26
|
+
export const MAX_CAPSULE_INSTRUCTIONS_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
/** The registry's skill-name rule (`GET /skills/{name}/instructions`). */
|
|
29
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
30
|
+
const SKILL_NAME_MAX_LENGTH = 64;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mints an `ixo:skills` invocation for an outbound capsules-registry call, or
|
|
34
|
+
* returns `undefined` so the caller falls back to public-only. Never throws.
|
|
35
|
+
*/
|
|
36
|
+
export type CapsuleUcanBuilder = (
|
|
37
|
+
serviceUrl: string,
|
|
38
|
+
rt: RuntimeContext,
|
|
39
|
+
) => Promise<string | undefined>;
|
|
40
|
+
|
|
41
|
+
/** Carrier handed to a {@link CapsuleContentFetcher} for one retrieval. */
|
|
42
|
+
export interface CapsuleFetchContext {
|
|
43
|
+
baseUrl: string;
|
|
44
|
+
network: string;
|
|
45
|
+
headers: Record<string, string>;
|
|
46
|
+
/** The turn's abort signal: a cancelled turn abandons the fetch. */
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Retrieves a capsule's `SKILL.md` text from the registry. Injected so tests
|
|
52
|
+
* stub it and so a deployment chooses the retrieval path without touching the
|
|
53
|
+
* client's auth / caching concerns.
|
|
54
|
+
*/
|
|
55
|
+
export type CapsuleContentFetcher = (
|
|
56
|
+
capsuleName: string,
|
|
57
|
+
ctx: CapsuleFetchContext,
|
|
58
|
+
) => Promise<string>;
|
|
59
|
+
|
|
60
|
+
export interface CapsuleContentClientOptions {
|
|
61
|
+
/** Registry base URL. Defaults to {@link DEFAULT_CAPSULES_BASE_URL}. */
|
|
62
|
+
baseUrl?: string;
|
|
63
|
+
/** Routing hint forwarded as `X-IXO-Network`. Defaults to {@link DEFAULT_NETWORK}. */
|
|
64
|
+
network?: string;
|
|
65
|
+
/** UCAN minter. Defaults to a shared-helper builder for `ixo:skills`. */
|
|
66
|
+
ucanBuilder?: CapsuleUcanBuilder;
|
|
67
|
+
/**
|
|
68
|
+
* Registry retrieval. Omitted: no registry is contacted (no fetch, no UCAN
|
|
69
|
+
* mint) and every specialist runs on its built-in prompt.
|
|
70
|
+
*/
|
|
71
|
+
fetcher?: CapsuleContentFetcher;
|
|
72
|
+
/** Clock override for tests (failure-cache expiry). */
|
|
73
|
+
now?: () => number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const defaultUcanBuilder: CapsuleUcanBuilder = async (serviceUrl, rt) => {
|
|
77
|
+
const did = await resolveServiceDidSafely(rt, serviceUrl, 'pod-creator');
|
|
78
|
+
if (!did) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
// Claim the ability the user's delegation grants for the registry: on
|
|
82
|
+
// Workers an unqualified mint claims `'*'`, which only a `'*'` grant covers
|
|
83
|
+
// (same claim as the skills plugin).
|
|
84
|
+
const invocation = await mintInvocationSafely(
|
|
85
|
+
rt,
|
|
86
|
+
{ did, capability: 'ixo:skills' },
|
|
87
|
+
'pod-creator',
|
|
88
|
+
{ can: 'skills/*' },
|
|
89
|
+
);
|
|
90
|
+
return invocation ?? undefined;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A {@link CapsuleContentFetcher} over the registry's
|
|
95
|
+
* `GET /skills/{name}/instructions`, which returns the `SKILL.md` body
|
|
96
|
+
* (frontmatter stripped) of the latest PUBLIC version of a skill. The
|
|
97
|
+
* endpoint serves public mainnet skills only: the auth and network headers
|
|
98
|
+
* are sent but do not widen what it returns. Each request is bounded by
|
|
99
|
+
* `timeoutMs`, by the turn's abort signal and by
|
|
100
|
+
* {@link MAX_CAPSULE_INSTRUCTIONS_BYTES} (a larger body is refused, not
|
|
101
|
+
* truncated). A non-2xx answer (404 for an unpublished capsule) is an error,
|
|
102
|
+
* which the client turns into the built-in fallback prompt.
|
|
103
|
+
*/
|
|
104
|
+
export function createRegistryInstructionsFetcher(
|
|
105
|
+
options: { timeoutMs?: number } = {},
|
|
106
|
+
): CapsuleContentFetcher {
|
|
107
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CAPSULE_FETCH_TIMEOUT_MS;
|
|
108
|
+
return async (capsuleName, ctx) => {
|
|
109
|
+
if (
|
|
110
|
+
capsuleName.length > SKILL_NAME_MAX_LENGTH ||
|
|
111
|
+
!SKILL_NAME_PATTERN.test(capsuleName)
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(`"${capsuleName}" is not a valid registry skill name`);
|
|
114
|
+
}
|
|
115
|
+
const url = new URL(
|
|
116
|
+
`/skills/${encodeURIComponent(capsuleName)}/instructions`,
|
|
117
|
+
ctx.baseUrl,
|
|
118
|
+
);
|
|
119
|
+
// A timer that is always cleared, not `AbortSignal.timeout`: a pending
|
|
120
|
+
// timer keeps the user's Durable Object resident until it fires.
|
|
121
|
+
const deadline = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs);
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(url.toString(), {
|
|
125
|
+
headers: { ...ctx.headers, Accept: 'text/markdown' },
|
|
126
|
+
signal: ctx.signal
|
|
127
|
+
? AbortSignal.any([ctx.signal, deadline.signal])
|
|
128
|
+
: deadline.signal,
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
// Release the connection without reading an error body we discard.
|
|
132
|
+
await response.body?.cancel();
|
|
133
|
+
throw new Error(
|
|
134
|
+
`registry answered ${response.status} for the instructions of "${capsuleName}"`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const text = await readCapped(response, capsuleName);
|
|
138
|
+
if (text.trim().length === 0) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`registry returned empty instructions for "${capsuleName}"`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return text;
|
|
144
|
+
} finally {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The response body as text, refusing it once it passes
|
|
152
|
+
* {@link MAX_CAPSULE_INSTRUCTIONS_BYTES} — by the declared length when there
|
|
153
|
+
* is one, and by the bytes actually read either way.
|
|
154
|
+
*/
|
|
155
|
+
async function readCapped(
|
|
156
|
+
response: Response,
|
|
157
|
+
capsuleName: string,
|
|
158
|
+
): Promise<string> {
|
|
159
|
+
const tooLarge = (): Error =>
|
|
160
|
+
new Error(
|
|
161
|
+
`registry instructions for "${capsuleName}" exceed ${MAX_CAPSULE_INSTRUCTIONS_BYTES} bytes`,
|
|
162
|
+
);
|
|
163
|
+
const declared = Number(response.headers.get('content-length'));
|
|
164
|
+
if (Number.isFinite(declared) && declared > MAX_CAPSULE_INSTRUCTIONS_BYTES) {
|
|
165
|
+
await response.body?.cancel();
|
|
166
|
+
throw tooLarge();
|
|
167
|
+
}
|
|
168
|
+
if (!response.body) return '';
|
|
169
|
+
const reader = response.body.getReader();
|
|
170
|
+
const chunks: Uint8Array[] = [];
|
|
171
|
+
let total = 0;
|
|
172
|
+
for (;;) {
|
|
173
|
+
const { done, value } = await reader.read();
|
|
174
|
+
if (done) break;
|
|
175
|
+
total += value.byteLength;
|
|
176
|
+
if (total > MAX_CAPSULE_INSTRUCTIONS_BYTES) {
|
|
177
|
+
await reader.cancel();
|
|
178
|
+
throw tooLarge();
|
|
179
|
+
}
|
|
180
|
+
chunks.push(value);
|
|
181
|
+
}
|
|
182
|
+
const bytes = new Uint8Array(total);
|
|
183
|
+
let offset = 0;
|
|
184
|
+
for (const chunk of chunks) {
|
|
185
|
+
bytes.set(chunk, offset);
|
|
186
|
+
offset += chunk.byteLength;
|
|
187
|
+
}
|
|
188
|
+
return new TextDecoder().decode(bytes);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function buildRegistryHeaders(
|
|
192
|
+
network: string,
|
|
193
|
+
ucan: string | undefined,
|
|
194
|
+
): Record<string, string> {
|
|
195
|
+
const headers: Record<string, string> = { 'X-IXO-Network': network };
|
|
196
|
+
if (ucan) {
|
|
197
|
+
headers.Authorization = `Bearer ${ucan}`;
|
|
198
|
+
headers['X-Auth-Type'] = 'ucan';
|
|
199
|
+
}
|
|
200
|
+
return headers;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolves design-pod role capsules to their `SKILL.md` text for use as
|
|
205
|
+
* sub-agent system prompts, or to `undefined` when the built-in prompt should
|
|
206
|
+
* be used. Owns the cross-cutting concerns — `ixo:skills` auth, the network
|
|
207
|
+
* header, caching — and delegates the retrieval to an injectable
|
|
208
|
+
* {@link CapsuleContentFetcher}.
|
|
209
|
+
*
|
|
210
|
+
* Without a fetcher (the bundled default) it answers `undefined` at once: no
|
|
211
|
+
* request, no UCAN mint, no log line.
|
|
212
|
+
*
|
|
213
|
+
* The cache lives on the plugin instance — isolate memory shared by every
|
|
214
|
+
* user object the isolate hosts — and is keyed by capsule name alone. That
|
|
215
|
+
* is sound because the registry endpoint serves public content only; a
|
|
216
|
+
* custom fetcher must likewise return text that is the same for every user.
|
|
217
|
+
* A failure is remembered for {@link CAPSULE_FAILURE_TTL_MS} and logged once
|
|
218
|
+
* per capsule per window, so an unpublished capsule costs one request and one
|
|
219
|
+
* warn line per window, not one per turn.
|
|
220
|
+
*/
|
|
221
|
+
export class CapsuleContentClient {
|
|
222
|
+
private readonly baseUrl: string;
|
|
223
|
+
private readonly network: string;
|
|
224
|
+
private readonly ucanBuilder: CapsuleUcanBuilder;
|
|
225
|
+
private readonly fetcher?: CapsuleContentFetcher;
|
|
226
|
+
private readonly cache: BoundedMap<string>;
|
|
227
|
+
private readonly failures: BoundedMap<true>;
|
|
228
|
+
|
|
229
|
+
constructor(options: CapsuleContentClientOptions = {}) {
|
|
230
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_CAPSULES_BASE_URL;
|
|
231
|
+
this.network = options.network ?? DEFAULT_NETWORK;
|
|
232
|
+
this.ucanBuilder = options.ucanBuilder ?? defaultUcanBuilder;
|
|
233
|
+
this.fetcher = options.fetcher;
|
|
234
|
+
const clock = options.now ? { now: options.now } : {};
|
|
235
|
+
this.cache = new BoundedMap({
|
|
236
|
+
maxEntries: CACHE_MAX_ENTRIES,
|
|
237
|
+
ttlMs: CACHE_TTL_MS,
|
|
238
|
+
...clock,
|
|
239
|
+
});
|
|
240
|
+
this.failures = new BoundedMap({
|
|
241
|
+
maxEntries: CACHE_MAX_ENTRIES,
|
|
242
|
+
ttlMs: CAPSULE_FAILURE_TTL_MS,
|
|
243
|
+
...clock,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The capsule's `SKILL.md` text, or `undefined` when there is no fetcher,
|
|
249
|
+
* when the fetch fails, or while a recent failure is remembered. Never
|
|
250
|
+
* throws. Mints an `ixo:skills` invocation for the request when possible
|
|
251
|
+
* and degrades to unauthenticated on auth failure.
|
|
252
|
+
*/
|
|
253
|
+
async getSkillMarkdown(
|
|
254
|
+
capsuleName: string,
|
|
255
|
+
rt: RuntimeContext,
|
|
256
|
+
): Promise<string | undefined> {
|
|
257
|
+
if (!this.fetcher) return undefined;
|
|
258
|
+
const cached = this.cache.get(capsuleName);
|
|
259
|
+
if (cached !== undefined) return cached;
|
|
260
|
+
if (this.failures.get(capsuleName)) return undefined;
|
|
261
|
+
try {
|
|
262
|
+
const ucan = await this.ucanBuilder(this.baseUrl, rt);
|
|
263
|
+
const markdown = await this.fetcher(capsuleName, {
|
|
264
|
+
baseUrl: this.baseUrl,
|
|
265
|
+
network: this.network,
|
|
266
|
+
headers: buildRegistryHeaders(this.network, ucan),
|
|
267
|
+
signal: rt.abortSignal,
|
|
268
|
+
});
|
|
269
|
+
this.cache.set(capsuleName, markdown);
|
|
270
|
+
return markdown;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// A cancelled turn says nothing about the registry: don't remember it.
|
|
273
|
+
if (rt.abortSignal.aborted) return undefined;
|
|
274
|
+
this.failures.set(capsuleName, true);
|
|
275
|
+
rt.logger.warn(
|
|
276
|
+
`[pod-creator] registry instructions for "${capsuleName}" unavailable (${
|
|
277
|
+
error instanceof Error ? error.message : String(error)
|
|
278
|
+
}); using the built-in prompt for ${Math.round(
|
|
279
|
+
CAPSULE_FAILURE_TTL_MS / 60_000,
|
|
280
|
+
)} min before retrying.`,
|
|
281
|
+
);
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|