@ixo/oracle-runtime-workers 0.13.0 → 0.15.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.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +1883 -264
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +529 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. 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
+ }