@nebutra/agent-runtime 0.2.0 → 0.2.1

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 (78) hide show
  1. package/.turbo/turbo-build.log +65 -50
  2. package/.turbo/turbo-test.log +39 -36
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/CHANGELOG.md +12 -15
  5. package/README.md +2 -0
  6. package/dist/adapters/dispatcher-sse.js +1 -0
  7. package/dist/adapters/index.js +1 -0
  8. package/dist/adapters/mcp-catalog.js +1 -0
  9. package/dist/adapters/prisma-rollout.js +1 -0
  10. package/dist/chunk-424PT5DM.js +23 -0
  11. package/dist/chunk-424PT5DM.js.map +1 -0
  12. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  13. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  14. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  15. package/dist/chunk-D4YAPLOW.js.map +1 -0
  16. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  17. package/dist/chunk-GQZKYWFT.js.map +1 -0
  18. package/dist/chunk-KCNN4QUQ.js +255 -0
  19. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  20. package/dist/chunk-MX2WL43P.js +90 -0
  21. package/dist/chunk-MX2WL43P.js.map +1 -0
  22. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  23. package/dist/chunk-NI4EDT4T.js.map +1 -0
  24. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  25. package/dist/cli.d.ts +2 -0
  26. package/dist/cli.js +52 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/commands.js +1 -0
  29. package/dist/definitions.js +1 -0
  30. package/dist/dispatcher.js +1 -0
  31. package/dist/durable-turn.js +3 -2
  32. package/dist/hook-pipeline.js +1 -0
  33. package/dist/index.d.ts +8 -85
  34. package/dist/index.js +237 -119
  35. package/dist/index.js.map +1 -1
  36. package/dist/loop.d.ts +2 -2
  37. package/dist/loop.js +3 -2
  38. package/dist/mcp-bridge.d.ts +3 -3
  39. package/dist/mcp-bridge.js +3 -2
  40. package/dist/model.js +1 -0
  41. package/dist/orchestration.d.ts +43 -0
  42. package/dist/orchestration.js +12 -0
  43. package/dist/orchestration.js.map +1 -0
  44. package/dist/policy.js +1 -0
  45. package/dist/protocol.js +1 -0
  46. package/dist/pulsar.d.ts +78 -0
  47. package/dist/pulsar.js +17 -0
  48. package/dist/pulsar.js.map +1 -0
  49. package/dist/rollout-store-persistent.js +1 -0
  50. package/dist/rollout.js +1 -0
  51. package/dist/sandbox.js +1 -0
  52. package/dist/skills.js +2 -1
  53. package/dist/subagents.js +1 -0
  54. package/dist/tools.d.ts +4 -3
  55. package/dist/tools.js +5 -3
  56. package/examples/pulsar-quickstart.ts +35 -0
  57. package/examples/resume-branch.ts +45 -0
  58. package/examples/subagent-fanout.ts +20 -0
  59. package/package.json +12 -5
  60. package/src/cli.ts +49 -0
  61. package/src/context-compaction.ts +6 -2
  62. package/src/index.ts +2 -0
  63. package/src/loop.ts +2 -2
  64. package/src/mcp-bridge.ts +8 -3
  65. package/src/orchestration.test.ts +53 -0
  66. package/src/orchestration.ts +146 -0
  67. package/src/permission-ruleset.ts +1 -200
  68. package/src/pulsar.test.ts +156 -0
  69. package/src/pulsar.ts +322 -0
  70. package/src/skills.ts +30 -8
  71. package/src/tools.test.ts +12 -0
  72. package/src/tools.ts +5 -2
  73. package/tsup.config.ts +4 -1
  74. package/dist/chunk-BJBBR3QA.js.map +0 -1
  75. package/dist/chunk-NN7DATXA.js.map +0 -1
  76. package/dist/chunk-PGGWSUTM.js.map +0 -1
  77. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  78. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
package/src/pulsar.ts ADDED
@@ -0,0 +1,322 @@
1
+ import { type ApprovalGate, type ModelInvoker, runTurn } from "./loop";
2
+ import type { ThreadEvent, ThreadItem, TurnConfig } from "./model";
3
+ import { isTurnTerminal } from "./model";
4
+ import type { ApprovalPolicy } from "./policy";
5
+ import { DEFAULT_APPROVAL_POLICY } from "./policy";
6
+ import type { RolloutLine, RolloutStore } from "./rollout";
7
+ import { replay } from "./rollout";
8
+ import { RuntimeToolRegistry } from "./tools";
9
+
10
+ type EventLogKind = "llm_call" | "tool_call" | "sub_agent_call" | "content_write";
11
+
12
+ export interface PulsarEventInput {
13
+ readonly traceId: string;
14
+ readonly kind: EventLogKind;
15
+ readonly affected: readonly string[];
16
+ readonly parent: string | null;
17
+ readonly snapshot?: Record<string, string>;
18
+ }
19
+
20
+ export interface PulsarBranchRecord {
21
+ readonly name: string;
22
+ readonly from: string;
23
+ readonly at: string;
24
+ }
25
+
26
+ export interface PulsarEventLogPort {
27
+ commit(event: PulsarEventInput): Promise<string>;
28
+ branchFrom(id: string, name: string): Promise<PulsarBranchRecord>;
29
+ }
30
+
31
+ export interface StartThreadOptions {
32
+ readonly threadId?: string;
33
+ readonly maxSteps?: number;
34
+ }
35
+
36
+ export interface PulsarDeps {
37
+ readonly tenantId: string;
38
+ readonly config: TurnConfig;
39
+ readonly model: ModelInvoker;
40
+ readonly tools: RuntimeToolRegistry;
41
+ readonly store: RolloutStore;
42
+ readonly approvalGate: ApprovalGate;
43
+ readonly approvalPolicy: ApprovalPolicy;
44
+ readonly eventLog?: PulsarEventLogPort;
45
+ }
46
+
47
+ function newId(prefix: string): string {
48
+ return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`;
49
+ }
50
+
51
+ function requireNonEmpty(value: string, label: string): string {
52
+ const trimmed = value.trim();
53
+ if (!trimmed) {
54
+ throw new Error(`${label} is required. Suggestion: pass a non-empty ${label}.`);
55
+ }
56
+ return trimmed;
57
+ }
58
+
59
+ function recordedEvents(lines: readonly RolloutLine[]): ThreadEvent[] {
60
+ return lines.flatMap((line) => (line.type === "event" ? [line.event] : []));
61
+ }
62
+
63
+ function mapItemToEvent(item: ThreadItem): PulsarEventInput["kind"] {
64
+ switch (item.type) {
65
+ case "agent_message":
66
+ case "reasoning":
67
+ return "llm_call";
68
+ case "file_change":
69
+ return "content_write";
70
+ case "command_execution":
71
+ case "mcp_tool_call":
72
+ return "tool_call";
73
+ case "error":
74
+ case "todo_list":
75
+ case "web_search":
76
+ return "tool_call";
77
+ }
78
+ }
79
+
80
+ function affectedPaths(item: ThreadItem): readonly string[] {
81
+ return item.type === "file_change" ? item.changes.map((change) => change.path) : [];
82
+ }
83
+
84
+ export class PulsarThread {
85
+ readonly id: string;
86
+ readonly playId: string;
87
+ readonly tenantId: string;
88
+ readonly #deps: PulsarDeps;
89
+ readonly #input: string;
90
+ readonly #maxSteps: number | undefined;
91
+ readonly #mode: "start" | "resume";
92
+ #events: ThreadEvent[] | undefined;
93
+
94
+ constructor(input: {
95
+ readonly id: string;
96
+ readonly playId: string;
97
+ readonly tenantId: string;
98
+ readonly deps: PulsarDeps;
99
+ readonly mode: "start" | "resume";
100
+ readonly userInput: string;
101
+ readonly maxSteps?: number;
102
+ }) {
103
+ this.id = input.id;
104
+ this.playId = input.playId;
105
+ this.tenantId = input.tenantId;
106
+ this.#deps = input.deps;
107
+ this.#input = input.userInput;
108
+ this.#mode = input.mode;
109
+ this.#maxSteps = input.maxSteps;
110
+ }
111
+
112
+ async *subscribe(): AsyncGenerator<ThreadEvent> {
113
+ if (this.#events) {
114
+ for (const event of this.#events) yield event;
115
+ return;
116
+ }
117
+
118
+ const events: ThreadEvent[] = [];
119
+ const emit = async (event: ThreadEvent): Promise<ThreadEvent> => {
120
+ events.push(event);
121
+ if (event.type === "item.completed") {
122
+ await this.#mirrorItem(event.item);
123
+ }
124
+ return event;
125
+ };
126
+
127
+ if (this.#mode === "resume") {
128
+ const lines = await this.#deps.store.read(this.tenantId, this.id);
129
+ const projection = replay(lines);
130
+ const recorded = recordedEvents(lines);
131
+ const last = recorded.at(-1);
132
+ if (projection && last && isTurnTerminal(last)) {
133
+ this.#events = recorded;
134
+ for (const event of recorded) yield event;
135
+ return;
136
+ }
137
+ }
138
+
139
+ yield await emit({ type: "thread.started", threadId: this.id });
140
+ await this.#deps.store.append({
141
+ tenantId: this.tenantId,
142
+ threadId: this.id,
143
+ type: "event",
144
+ event: { type: "thread.started", threadId: this.id },
145
+ at: new Date().toISOString(),
146
+ });
147
+
148
+ const stream = runTurn(this.#input, {
149
+ tenantId: this.tenantId,
150
+ threadId: this.id,
151
+ config: this.#deps.config,
152
+ approvalPolicy: this.#deps.approvalPolicy,
153
+ model: this.#deps.model,
154
+ tools: this.#deps.tools,
155
+ store: this.#deps.store,
156
+ approvalGate: this.#deps.approvalGate,
157
+ ruleEvaluator: () => "allow",
158
+ ...(this.#maxSteps !== undefined ? { maxSteps: this.#maxSteps } : {}),
159
+ });
160
+
161
+ for await (const event of stream) {
162
+ yield await emit(event);
163
+ }
164
+ this.#events = events;
165
+ }
166
+
167
+ async #mirrorItem(item: ThreadItem): Promise<void> {
168
+ const eventLog = this.#deps.eventLog;
169
+ if (!eventLog) return;
170
+ const parent = Pulsar.itemCommitIds.get(`${this.id}::__last`) ?? null;
171
+ const commitId = await eventLog.commit({
172
+ traceId: this.id,
173
+ kind: mapItemToEvent(item),
174
+ affected: affectedPaths(item),
175
+ parent,
176
+ });
177
+ Pulsar.itemCommitIds.set(`${this.id}:${item.id}`, commitId);
178
+ Pulsar.itemCommitIds.set(`${this.id}::__last`, commitId);
179
+ }
180
+ }
181
+
182
+ export class PulsarBuilder {
183
+ #tenantId?: string;
184
+ #config?: TurnConfig;
185
+ #model?: ModelInvoker;
186
+ #tools: RuntimeToolRegistry = new RuntimeToolRegistry();
187
+ #store?: RolloutStore;
188
+ #approvalGate?: ApprovalGate;
189
+ #approvalPolicy: ApprovalPolicy = DEFAULT_APPROVAL_POLICY;
190
+ #eventLog?: PulsarEventLogPort;
191
+
192
+ withTenant(tenantId: string): this {
193
+ this.#tenantId = requireNonEmpty(tenantId, "tenantId");
194
+ return this;
195
+ }
196
+
197
+ withConfig(config: TurnConfig): this {
198
+ this.#config = config;
199
+ return this;
200
+ }
201
+
202
+ withModel(model: ModelInvoker): this {
203
+ this.#model = model;
204
+ return this;
205
+ }
206
+
207
+ withTools(tools: RuntimeToolRegistry): this {
208
+ this.#tools = tools;
209
+ return this;
210
+ }
211
+
212
+ withRolloutStore(store: RolloutStore): this {
213
+ this.#store = store;
214
+ return this;
215
+ }
216
+
217
+ withApprovalGate(approvalGate: ApprovalGate): this {
218
+ this.#approvalGate = approvalGate;
219
+ return this;
220
+ }
221
+
222
+ withApprovalPolicy(policy: ApprovalPolicy): this {
223
+ this.#approvalPolicy = policy;
224
+ return this;
225
+ }
226
+
227
+ withEventLog(eventLog: PulsarEventLogPort): this {
228
+ this.#eventLog = eventLog;
229
+ return this;
230
+ }
231
+
232
+ build(): Pulsar {
233
+ if (!this.#tenantId) throw new Error("tenantId is required. Suggestion: call withTenant().");
234
+ if (!this.#config) throw new Error("config is required. Suggestion: call withConfig().");
235
+ if (!this.#model) throw new Error("model is required. Suggestion: call withModel().");
236
+ if (!this.#store) {
237
+ throw new Error("rollout store is required. Suggestion: call withRolloutStore().");
238
+ }
239
+ if (!this.#approvalGate) {
240
+ throw new Error("approval gate is required. Suggestion: call withApprovalGate().");
241
+ }
242
+ const deps: PulsarDeps = {
243
+ tenantId: this.#tenantId,
244
+ config: this.#config,
245
+ model: this.#model,
246
+ tools: this.#tools,
247
+ store: this.#store,
248
+ approvalGate: this.#approvalGate,
249
+ approvalPolicy: this.#approvalPolicy,
250
+ ...(this.#eventLog !== undefined ? { eventLog: this.#eventLog } : {}),
251
+ };
252
+ return new Pulsar(deps);
253
+ }
254
+ }
255
+
256
+ export class Pulsar {
257
+ static readonly itemCommitIds = new Map<string, string>();
258
+ readonly #deps: PulsarDeps;
259
+
260
+ constructor(deps: PulsarDeps) {
261
+ this.#deps = deps;
262
+ }
263
+
264
+ static builder(): PulsarBuilder {
265
+ return new PulsarBuilder();
266
+ }
267
+
268
+ async startPlay(
269
+ playId: string,
270
+ input: string,
271
+ options: StartThreadOptions = {},
272
+ ): Promise<PulsarThread> {
273
+ const threadId = options.threadId ?? newId("thread");
274
+ await this.#deps.store.append({
275
+ tenantId: this.#deps.tenantId,
276
+ threadId,
277
+ type: "session_meta",
278
+ config: this.#deps.config,
279
+ at: new Date().toISOString(),
280
+ });
281
+ return new PulsarThread({
282
+ id: threadId,
283
+ playId: requireNonEmpty(playId, "playId"),
284
+ tenantId: this.#deps.tenantId,
285
+ deps: this.#deps,
286
+ mode: "start",
287
+ userInput: input,
288
+ ...(options.maxSteps !== undefined ? { maxSteps: options.maxSteps } : {}),
289
+ });
290
+ }
291
+
292
+ async resume(threadId: string): Promise<PulsarThread> {
293
+ return new PulsarThread({
294
+ id: requireNonEmpty(threadId, "threadId"),
295
+ playId: "resume",
296
+ tenantId: this.#deps.tenantId,
297
+ deps: this.#deps,
298
+ mode: "resume",
299
+ userInput: "",
300
+ });
301
+ }
302
+
303
+ async branchFromItem(
304
+ threadId: string,
305
+ itemId: string,
306
+ name: string,
307
+ ): Promise<PulsarBranchRecord> {
308
+ const eventLog = this.#deps.eventLog;
309
+ if (!eventLog) {
310
+ throw new Error("event-log is required. Suggestion: call withEventLog() before branching.");
311
+ }
312
+ const commitId = Pulsar.itemCommitIds.get(
313
+ `${requireNonEmpty(threadId, "threadId")}:${requireNonEmpty(itemId, "itemId")}`,
314
+ );
315
+ if (!commitId) {
316
+ throw new Error(
317
+ "item has no event-log commit. Suggestion: subscribe to the thread before branching.",
318
+ );
319
+ }
320
+ return eventLog.branchFrom(commitId, requireNonEmpty(name, "branch name"));
321
+ }
322
+ }
package/src/skills.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  * — no host filesystem access (resource base is an injected indirection).
26
26
  */
27
27
 
28
+ import { estimateTokens as estimatePrimitiveTokens } from "@nebutra/ai-primitives";
28
29
  import { z } from "zod";
29
30
  import {
30
31
  type Definition,
@@ -38,9 +39,33 @@ export type SkillRecord = Definition;
38
39
 
39
40
  /** Tiers treated as first-party (never truncated, never dropped). */
40
41
  const FIRST_PARTY_TIERS: ReadonlySet<string> = new Set(["bundled", "builtin"]);
42
+ const REGEX_META_CHARS = new Set([
43
+ ".",
44
+ "+",
45
+ "?",
46
+ "^",
47
+ "$",
48
+ "{",
49
+ "}",
50
+ "(",
51
+ ")",
52
+ "|",
53
+ "[",
54
+ "]",
55
+ "\\",
56
+ ]);
41
57
 
42
58
  const isFirstParty = (s: SkillRecord): boolean => FIRST_PARTY_TIERS.has(s.sourceTier);
43
59
 
60
+ /**
61
+ * Listing budgets preserve the catalog degradation contract: very tight lower-
62
+ * tier budgets should still degrade to names-only before dropping. Context
63
+ * compaction uses the corrected 1.3 estimate; this listing surface deliberately
64
+ * uses the shared primitive with no correction.
65
+ */
66
+ const estimateListingTokens = (text: string): number =>
67
+ estimatePrimitiveTokens(text, { correction: 1 });
68
+
44
69
  // ── Phase 1: listing ─────────────────────────────────────────────────────────
45
70
 
46
71
  /** One catalog entry — only the cheap, always-in-context surface. */
@@ -66,14 +91,11 @@ const listingOptionsSchema = z.object({
66
91
  });
67
92
  export type SkillListingOptions = z.input<typeof listingOptionsSchema>;
68
93
 
69
- /** Cheap, deterministic token estimate (≈4 chars/token). */
70
- const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
71
-
72
94
  /** Minimal glob: `**` = any chars, `*` = any chars except `/`, else exact. */
73
95
  function globToRegExp(glob: string): RegExp {
74
96
  let out = "^";
75
97
  for (let i = 0; i < glob.length; i++) {
76
- const c = glob[i]!;
98
+ const c = glob.charAt(i);
77
99
  if (c === "*") {
78
100
  if (glob[i + 1] === "*") {
79
101
  out += ".*";
@@ -81,7 +103,7 @@ function globToRegExp(glob: string): RegExp {
81
103
  } else {
82
104
  out += "[^/]*";
83
105
  }
84
- } else if (".+?^${}()|[]\\".includes(c)) {
106
+ } else if (REGEX_META_CHARS.has(c)) {
85
107
  out += `\\${c}`;
86
108
  } else {
87
109
  out += c;
@@ -158,7 +180,7 @@ export function buildSkillListing(
158
180
  firstParty: true,
159
181
  };
160
182
  entries.push(entry);
161
- used += estimateTokens(renderEntry(entry));
183
+ used += estimateListingTokens(renderEntry(entry));
162
184
  continue;
163
185
  }
164
186
 
@@ -169,7 +191,7 @@ export function buildSkillListing(
169
191
  degraded: false,
170
192
  firstParty: false,
171
193
  };
172
- const fullCost = estimateTokens(renderEntry(full));
194
+ const fullCost = estimateListingTokens(renderEntry(full));
173
195
  if (used + fullCost <= budget) {
174
196
  entries.push(full);
175
197
  used += fullCost;
@@ -183,7 +205,7 @@ export function buildSkillListing(
183
205
  degraded: true,
184
206
  firstParty: false,
185
207
  };
186
- const nameCost = estimateTokens(renderEntry(nameOnly));
208
+ const nameCost = estimateListingTokens(renderEntry(nameOnly));
187
209
  if (used + nameCost <= budget) {
188
210
  entries.push(nameOnly);
189
211
  used += nameCost;
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RuntimeToolRegistry, ToolRegistry } from "./tools";
3
+
4
+ describe("runtime tool registry naming", () => {
5
+ it("uses RuntimeToolRegistry as the canonical dispatcher name while preserving ToolRegistry compatibility", () => {
6
+ const runtimeRegistry = new RuntimeToolRegistry();
7
+ const compatRegistry = new ToolRegistry();
8
+
9
+ expect(runtimeRegistry).toBeInstanceOf(RuntimeToolRegistry);
10
+ expect(compatRegistry).toBeInstanceOf(RuntimeToolRegistry);
11
+ });
12
+ });
package/src/tools.ts CHANGED
@@ -52,10 +52,10 @@ export interface ToolHooks {
52
52
  }
53
53
 
54
54
  /**
55
- * registry -> router -> orchestrator pipeline. MCP tools register via
55
+ * runtime registry -> router -> orchestrator pipeline. MCP tools register via
56
56
  * {@link adaptMcpTool} and are dispatched identically to native tools.
57
57
  */
58
- export class ToolRegistry {
58
+ export class RuntimeToolRegistry {
59
59
  readonly #tools = new Map<string, { reg: RegisteredTool; handler: ToolHandler }>();
60
60
  readonly #hooks: ToolHooks;
61
61
 
@@ -95,6 +95,9 @@ export class ToolRegistry {
95
95
  }
96
96
  }
97
97
 
98
+ /** @deprecated Use RuntimeToolRegistry for new code. */
99
+ export { RuntimeToolRegistry as ToolRegistry };
100
+
98
101
  /** Minimal shape of an MCP client (satisfied by `@nebutra/mcp`'s `mcpClient`). */
99
102
  export interface McpClientLike {
100
103
  executeTool(
package/tsup.config.ts CHANGED
@@ -8,6 +8,7 @@ export default defineConfig({
8
8
  "adapters/mcp-catalog": "src/adapters/mcp-catalog.ts",
9
9
  "adapters/prisma-rollout": "src/adapters/prisma-rollout.ts",
10
10
  commands: "src/commands.ts",
11
+ cli: "src/cli.ts",
11
12
  definitions: "src/definitions.ts",
12
13
  dispatcher: "src/dispatcher.ts",
13
14
  "durable-turn": "src/durable-turn.ts",
@@ -15,8 +16,10 @@ export default defineConfig({
15
16
  loop: "src/loop.ts",
16
17
  "mcp-bridge": "src/mcp-bridge.ts",
17
18
  model: "src/model.ts",
19
+ orchestration: "src/orchestration.ts",
18
20
  policy: "src/policy.ts",
19
21
  protocol: "src/protocol.ts",
22
+ pulsar: "src/pulsar.ts",
20
23
  rollout: "src/rollout.ts",
21
24
  "rollout-store-persistent": "src/rollout-store-persistent.ts",
22
25
  sandbox: "src/sandbox.ts",
@@ -29,5 +32,5 @@ export default defineConfig({
29
32
  clean: true,
30
33
  sourcemap: true,
31
34
  target: "es2022",
32
- external: ["@nebutra/mcp"],
35
+ external: ["@nebutra/execution-policy", "@nebutra/mcp"],
33
36
  });
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/loop.ts"],"sourcesContent":["/**\n * Agent loop runner (WRAP — the turn engine).\n *\n * Faithful re-expression of the upstream loop: a turn is\n * `loop { model_call → emit items → execute tools → feed results back }`\n * until the model stops requesting tools or a bounded step ceiling is hit.\n * Single-threaded (Cognition teaching: shared context, no conflicting\n * sub-agent decisions). Every item is appended to the tenant-scoped rollout\n * as it reaches a terminal state, so the turn is resumable by replay.\n *\n * The model call is abstracted behind {@link ModelInvoker} so this WRAPs an\n * existing model stack (e.g. `@nebutra/agents`) rather than re-porting\n * provider/routing/fallback. No untrusted code runs here — command items are\n * dispatched through the tool registry / external-sandbox seam.\n */\n\nimport type { AgentMessageItem, ThreadEvent, ThreadItem, TurnConfig, TurnUsage } from \"./model\";\nimport {\n type ApprovalPolicy,\n DENIED,\n isApproval,\n type ReviewDecision,\n type RuleDecision,\n resolveRuleDecision,\n} from \"./policy\";\nimport type { ServerRequest } from \"./protocol\";\nimport { type RolloutLine, type RolloutStore, sanitizeForPersist } from \"./rollout\";\nimport type { ToolDispatchContext, ToolRegistry } from \"./tools\";\n\n/** A single thing the model emitted in one round. */\nexport type ModelEmission =\n | { readonly kind: \"text\"; readonly text: string }\n | {\n readonly kind: \"tool_call\";\n readonly id: string;\n readonly name: string;\n readonly args: unknown;\n };\n\nexport interface ModelRoundResult {\n readonly emissions: readonly ModelEmission[];\n readonly usage?: Partial<TurnUsage>;\n}\n\nexport interface ModelRoundRequest {\n readonly config: TurnConfig;\n /** Running transcript: user input, agent text, and tool results. */\n readonly history: readonly { readonly role: string; readonly content: string }[];\n readonly toolNames: readonly string[];\n}\n\n/** Abstracts the model stack — implement over `@nebutra/agents`, etc. */\nexport interface ModelInvoker {\n invoke(request: ModelRoundRequest): Promise<ModelRoundResult>;\n}\n\n/** Server-initiated approval transport (see {@link ServerRequest}). */\nexport interface ApprovalGate {\n request(serverRequest: ServerRequest): Promise<ReviewDecision>;\n}\n\n/** Classifies a tool call into a static rule decision before approval. */\nexport type RuleEvaluator = (toolName: string, args: unknown) => RuleDecision;\n\nexport interface RunTurnDeps {\n readonly tenantId: string;\n readonly threadId: string;\n readonly config: TurnConfig;\n readonly approvalPolicy: ApprovalPolicy;\n readonly model: ModelInvoker;\n readonly tools: ToolRegistry;\n readonly store: RolloutStore;\n readonly approvalGate: ApprovalGate;\n /** Defaults to: everything requires a prompt (safe). */\n readonly ruleEvaluator?: RuleEvaluator;\n /** Bounded steps (parity with upstream step ceiling). Default 20. */\n readonly maxSteps?: number;\n}\n\nconst DEFAULT_MAX_STEPS = 20;\nconst requirePrompt: RuleEvaluator = () => \"prompt\";\n\nfunction newId(prefix: string): string {\n return `${prefix}_${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`;\n}\n\n/**\n * Drive one turn to completion. Yields the {@link ThreadEvent} stream and\n * appends each terminal item + the turn outcome to the rollout store.\n * Never throws to the caller — failures surface as a `turn.failed` event.\n */\nexport async function* runTurn(userInput: string, deps: RunTurnDeps): AsyncGenerator<ThreadEvent> {\n const at = () => new Date().toISOString();\n const ruleEvaluator = deps.ruleEvaluator ?? requirePrompt;\n const maxSteps = deps.maxSteps ?? DEFAULT_MAX_STEPS;\n const ctx: ToolDispatchContext = { tenantId: deps.tenantId, threadId: deps.threadId };\n\n const append = async (line: RolloutLine): Promise<void> => deps.store.append(line);\n const event = async (e: ThreadEvent): Promise<ThreadEvent> => {\n await append({\n tenantId: deps.tenantId,\n threadId: deps.threadId,\n type: \"event\",\n event:\n e.type === \"item.completed\"\n ? { type: \"item.completed\", item: sanitizeForPersist(e.item) }\n : e,\n at: at(),\n });\n return e;\n };\n\n yield await event({ type: \"turn.started\" });\n\n const history: { role: string; content: string }[] = [{ role: \"user\", content: userInput }];\n let usage: TurnUsage = {\n inputTokens: 0,\n cachedInputTokens: 0,\n outputTokens: 0,\n reasoningOutputTokens: 0,\n };\n\n try {\n for (let step = 0; step < maxSteps; step++) {\n const round = await deps.model.invoke({\n config: deps.config,\n history,\n toolNames: deps.tools.list().map((t) => t.definition.name),\n });\n if (round.usage) {\n const u = round.usage;\n usage = {\n inputTokens: usage.inputTokens + (u.inputTokens ?? 0),\n cachedInputTokens: usage.cachedInputTokens + (u.cachedInputTokens ?? 0),\n outputTokens: usage.outputTokens + (u.outputTokens ?? 0),\n reasoningOutputTokens: usage.reasoningOutputTokens + (u.reasoningOutputTokens ?? 0),\n };\n }\n\n const toolCalls = round.emissions.filter(\n (e): e is Extract<ModelEmission, { kind: \"tool_call\" }> => e.kind === \"tool_call\",\n );\n\n for (const e of round.emissions) {\n if (e.kind !== \"text\") continue;\n const item: AgentMessageItem = { id: newId(\"msg\"), type: \"agent_message\", text: e.text };\n history.push({ role: \"assistant\", content: e.text });\n yield await event({ type: \"item.completed\", item });\n }\n\n if (toolCalls.length === 0) break; // model is done\n\n for (const call of toolCalls) {\n const decision = await gateToolCall(call.name, call.args, deps, ruleEvaluator);\n if (!isApproval(decision)) {\n const failed: ThreadItem = {\n id: call.id,\n type: \"error\",\n message: `tool '${call.name}' not approved (${decision.kind})`,\n };\n history.push({ role: \"tool\", content: `DENIED: ${call.name}` });\n yield await event({ type: \"item.completed\", item: failed });\n continue;\n }\n try {\n const output = await deps.tools.dispatch(call.name, call.args, ctx);\n const item: ThreadItem = {\n id: call.id,\n type: \"mcp_tool_call\",\n server: \"native\",\n tool: call.name,\n arguments: call.args,\n result: { content: output },\n status: \"completed\",\n };\n history.push({ role: \"tool\", content: JSON.stringify(output) });\n yield await event({ type: \"item.completed\", item });\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n history.push({ role: \"tool\", content: `ERROR: ${message}` });\n yield await event({\n type: \"item.completed\",\n item: { id: call.id, type: \"error\", message },\n });\n }\n }\n }\n\n yield await event({ type: \"turn.completed\", usage });\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n yield await event({ type: \"turn.failed\", error: { message } });\n }\n}\n\n/** Resolve a tool call's approval, raising a server-initiated request if asked. */\nasync function gateToolCall(\n name: string,\n args: unknown,\n deps: RunTurnDeps,\n ruleEvaluator: RuleEvaluator,\n): Promise<ReviewDecision> {\n const outcome = resolveRuleDecision(ruleEvaluator(name, args), deps.approvalPolicy);\n if (outcome === \"auto_allow\") return { kind: \"approved\" };\n if (outcome === \"auto_reject\") return DENIED;\n return deps.approvalGate.request({\n type: \"permissions.request_approval\",\n requestId: newId(\"appr\"),\n summary: `tool '${name}'`,\n });\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/tools.ts"],"sourcesContent":["/**\n * Tool / MCP abstraction (WRAP — capability #9).\n *\n * Faithful re-expression of the upstream uniform tool model: one\n * `ToolDefinition` interface, JSON-schema in/out, a deferred-load flag, and\n * MCP-as-adapter behind the same interface (MCP tools and native tools are\n * indistinguishable at dispatch). Activates `@nebutra/mcp` primitives rather\n * than re-porting them.\n */\n\nimport type { z } from \"zod\";\n\n/** A tool definition — pure data; transport-agnostic. */\nexport interface ToolDefinition<I = unknown, O = unknown> {\n readonly name: string;\n readonly description: string;\n readonly inputSchema: z.ZodType<I>;\n readonly outputSchema?: z.ZodType<O>;\n /**\n * When true, the full schema is withheld until a `tool_search` discovery\n * step lazily loads it (parity with the upstream deferred-tool pattern).\n */\n readonly deferLoading?: boolean;\n}\n\n/** Source provenance — native vs. adapted from an MCP server. */\nexport type ToolOrigin =\n | { readonly kind: \"native\" }\n | { readonly kind: \"mcp\"; readonly server: string };\n\nexport interface RegisteredTool {\n readonly definition: ToolDefinition;\n readonly origin: ToolOrigin;\n}\n\n/** A tool invocation routed through the registry. */\nexport interface ToolDispatchContext {\n /** Mandatory tenant scope — every dispatch is tenant-bound. */\n readonly tenantId: string;\n readonly threadId: string;\n}\n\nexport type ToolHandler<I = unknown, O = unknown> = (\n input: I,\n ctx: ToolDispatchContext,\n) => Promise<O>;\n\n/** Pre/post dispatch hooks (parity with upstream orchestrator hooks). */\nexport interface ToolHooks {\n readonly preToolUse?: (name: string, input: unknown, ctx: ToolDispatchContext) => Promise<void>;\n readonly postToolUse?: (name: string, output: unknown, ctx: ToolDispatchContext) => Promise<void>;\n}\n\n/**\n * registry -> router -> orchestrator pipeline. MCP tools register via\n * {@link adaptMcpTool} and are dispatched identically to native tools.\n */\nexport class ToolRegistry {\n readonly #tools = new Map<string, { reg: RegisteredTool; handler: ToolHandler }>();\n readonly #hooks: ToolHooks;\n\n constructor(hooks: ToolHooks = {}) {\n this.#hooks = hooks;\n }\n\n register<I, O>(\n definition: ToolDefinition<I, O>,\n handler: ToolHandler<I, O>,\n origin: ToolOrigin = { kind: \"native\" },\n ): void {\n if (this.#tools.has(definition.name)) {\n throw new Error(`Tool already registered: ${definition.name}`);\n }\n this.#tools.set(definition.name, {\n reg: { definition: definition as ToolDefinition, origin },\n handler: handler as ToolHandler,\n });\n }\n\n list(): readonly RegisteredTool[] {\n return [...this.#tools.values()].map((e) => e.reg);\n }\n\n async dispatch(name: string, rawInput: unknown, ctx: ToolDispatchContext): Promise<unknown> {\n const entry = this.#tools.get(name);\n if (!entry) throw new Error(`Unknown tool: ${name}`);\n const input = entry.reg.definition.inputSchema.parse(rawInput);\n await this.#hooks.preToolUse?.(name, input, ctx);\n const output = await entry.handler(input, ctx);\n const validated = entry.reg.definition.outputSchema\n ? entry.reg.definition.outputSchema.parse(output)\n : output;\n await this.#hooks.postToolUse?.(name, validated, ctx);\n return validated;\n }\n}\n\n/** Minimal shape of an MCP client (satisfied by `@nebutra/mcp`'s `mcpClient`). */\nexport interface McpClientLike {\n executeTool(\n name: string,\n args: unknown,\n ctx: { requestId: string; tenantId: string },\n ): Promise<unknown>;\n}\n\n/**\n * Adapt an external MCP tool into the uniform tool model. The resulting\n * handler delegates to the MCP client; callers cannot tell it apart from a\n * native tool.\n */\nexport function adaptMcpTool(\n server: string,\n definition: ToolDefinition,\n client: McpClientLike,\n): { definition: ToolDefinition; handler: ToolHandler; origin: ToolOrigin } {\n return {\n definition,\n origin: { kind: \"mcp\", server },\n handler: async (input, ctx) =>\n client.executeTool(definition.name, input, {\n requestId: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,\n tenantId: ctx.tenantId,\n }),\n };\n}\n"],"mappings":";AAyDO,IAAM,eAAN,MAAmB;AAAA,EACf,SAAS,oBAAI,IAA2D;AAAA,EACxE;AAAA,EAET,YAAY,QAAmB,CAAC,GAAG;AACjC,SAAK,SAAS;AAAA,EAChB;AAAA,EAEA,SACE,YACA,SACA,SAAqB,EAAE,MAAM,SAAS,GAChC;AACN,QAAI,KAAK,OAAO,IAAI,WAAW,IAAI,GAAG;AACpC,YAAM,IAAI,MAAM,4BAA4B,WAAW,IAAI,EAAE;AAAA,IAC/D;AACA,SAAK,OAAO,IAAI,WAAW,MAAM;AAAA,MAC/B,KAAK,EAAE,YAA0C,OAAO;AAAA,MACxD;AAAA,IACF,CAAC;AAAA,EACH;AAAA,EAEA,OAAkC;AAChC,WAAO,CAAC,GAAG,KAAK,OAAO,OAAO,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG;AAAA,EACnD;AAAA,EAEA,MAAM,SAAS,MAAc,UAAmB,KAA4C;AAC1F,UAAM,QAAQ,KAAK,OAAO,IAAI,IAAI;AAClC,QAAI,CAAC,MAAO,OAAM,IAAI,MAAM,iBAAiB,IAAI,EAAE;AACnD,UAAM,QAAQ,MAAM,IAAI,WAAW,YAAY,MAAM,QAAQ;AAC7D,UAAM,KAAK,OAAO,aAAa,MAAM,OAAO,GAAG;AAC/C,UAAM,SAAS,MAAM,MAAM,QAAQ,OAAO,GAAG;AAC7C,UAAM,YAAY,MAAM,IAAI,WAAW,eACnC,MAAM,IAAI,WAAW,aAAa,MAAM,MAAM,IAC9C;AACJ,UAAM,KAAK,OAAO,cAAc,MAAM,WAAW,GAAG;AACpD,WAAO;AAAA,EACT;AACF;AAgBO,SAAS,aACd,QACA,YACA,QAC0E;AAC1E,SAAO;AAAA,IACL;AAAA,IACA,QAAQ,EAAE,MAAM,OAAO,OAAO;AAAA,IAC9B,SAAS,OAAO,OAAO,QACrB,OAAO,YAAY,WAAW,MAAM,OAAO;AAAA,MACzC,WAAW,WAAW,QAAQ,aAAa,KAAK,GAAG,KAAK,IAAI,CAAC;AAAA,MAC7D,UAAU,IAAI;AAAA,IAChB,CAAC;AAAA,EACL;AACF;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/mcp-bridge.ts"],"sourcesContent":["/**\n * MCP activation bridge (WRAP — capability #9, activation seam).\n *\n * Concrete bridge that registers external MCP-server tools into the runtime's\n * uniform tool model. It does NOT import `@nebutra/mcp` (WIP / do-not-import):\n * instead it defines minimal injectable ports so callers wire `@nebutra/mcp`\n * (`serverRegistry` + `mcpClient`) without this package taking a hard dep.\n *\n * Tenant-scoped by construction: the catalog port is always queried with the\n * caller's `{ tenantId, plan }`, so a tenant only ever sees its own MCP\n * servers/tools. Fail-closed on missing tenant.\n */\n\nimport { z } from \"zod\";\n\nimport { adaptMcpTool, type McpClientLike, type ToolDefinition, type ToolRegistry } from \"./tools\";\n\n/**\n * Port over an MCP server catalog (satisfied by `@nebutra/mcp`'s\n * `serverRegistry` + plan middleware). Returns only the tools visible to the\n * given tenant/plan — visibility/plan-gating is the port's responsibility.\n */\nexport interface McpServerCatalogPort {\n listTools(ctx: {\n tenantId: string;\n plan?: string;\n }): Promise<{ server: string; definition: ToolDefinition }[]>;\n}\n\nconst ctxSchema = z.object({\n tenantId: z.string().trim().min(1, \"tenantId is required (fail-closed)\"),\n plan: z.string().min(1).optional(),\n});\n\nexport interface ActivateMcpToolsResult {\n /** Tool names newly registered into the registry, in catalog order. */\n readonly registered: readonly string[];\n /** Tool names skipped because the name was already registered. */\n readonly skipped: readonly string[];\n}\n\n/**\n * List the tenant/plan-visible MCP tools, adapt each via {@link adaptMcpTool},\n * and register them into the {@link ToolRegistry}. A tool whose name is already\n * registered is skipped (reported, never thrown). Empty/blank tenantId fails\n * closed before any catalog call.\n */\nexport async function activateMcpTools(\n registry: ToolRegistry,\n catalog: McpServerCatalogPort,\n client: McpClientLike,\n ctx: { tenantId: string; plan?: string },\n): Promise<ActivateMcpToolsResult> {\n const scope = ctxSchema.parse(ctx);\n\n const entries = await catalog.listTools(\n scope.plan === undefined\n ? { tenantId: scope.tenantId }\n : { tenantId: scope.tenantId, plan: scope.plan },\n );\n\n const registered: string[] = [];\n const skipped: string[] = [];\n\n for (const { server, definition } of entries) {\n if (registry.list().some((r) => r.definition.name === definition.name)) {\n skipped.push(definition.name);\n continue;\n }\n const adapted = adaptMcpTool(server, definition, client);\n registry.register(adapted.definition, adapted.handler, adapted.origin);\n registered.push(definition.name);\n }\n\n return { registered, skipped };\n}\n"],"mappings":";;;;;AAaA,SAAS,SAAS;AAgBlB,IAAM,YAAY,EAAE,OAAO;AAAA,EACzB,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,GAAG,oCAAoC;AAAA,EACvE,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,SAAS;AACnC,CAAC;AAeD,eAAsB,iBACpB,UACA,SACA,QACA,KACiC;AACjC,QAAM,QAAQ,UAAU,MAAM,GAAG;AAEjC,QAAM,UAAU,MAAM,QAAQ;AAAA,IAC5B,MAAM,SAAS,SACX,EAAE,UAAU,MAAM,SAAS,IAC3B,EAAE,UAAU,MAAM,UAAU,MAAM,MAAM,KAAK;AAAA,EACnD;AAEA,QAAM,aAAuB,CAAC;AAC9B,QAAM,UAAoB,CAAC;AAE3B,aAAW,EAAE,QAAQ,WAAW,KAAK,SAAS;AAC5C,QAAI,SAAS,KAAK,EAAE,KAAK,CAAC,MAAM,EAAE,WAAW,SAAS,WAAW,IAAI,GAAG;AACtE,cAAQ,KAAK,WAAW,IAAI;AAC5B;AAAA,IACF;AACA,UAAM,UAAU,aAAa,QAAQ,YAAY,MAAM;AACvD,aAAS,SAAS,QAAQ,YAAY,QAAQ,SAAS,QAAQ,MAAM;AACrE,eAAW,KAAK,WAAW,IAAI;AAAA,EACjC;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/skills.ts"],"sourcesContent":["/**\n * Skills system (WRAP — progressive-disclosure capability).\n *\n * Faithful re-expression of the source harness's two-phase skill model:\n *\n * Phase 1 — Listing (cheap, always in context): a budget-bounded catalog\n * exposing only {name, description, whenToUse} per skill. First-party\n * skills (bundled/builtin tier) are never truncated and never dropped;\n * lower-tier skills degrade to names-only, then drop, as the context\n * budget is exhausted. Path-activated skills stay hidden until a touched\n * file matches one of their globs.\n *\n * Phase 2 — Invocation (lazy, on demand): expand the chosen skill's body\n * into turn messages, with arg/var substitution and a merged tool\n * allowlist contributed to the permission context.\n *\n * A skill IS a `Definition` — the same tenant-scoped layered resolver backs\n * the command registry and the subagent registry (see ./definitions).\n *\n * SECURITY: this re-expression intentionally drops the source's body-side\n * command-substitution (the upstream \"!`…`\" inline-shell facility). Skill\n * bodies are inert text; they NEVER trigger host shell execution here. All\n * entry points require a non-empty `tenantId` and fail closed; cross-tenant\n * inputs are rejected by the underlying `DefinitionResolver`. Pure data/logic\n * — no host filesystem access (resource base is an injected indirection).\n */\n\nimport { z } from \"zod\";\nimport {\n type Definition,\n type DefinitionResolver,\n type ResolveContext,\n substituteArguments,\n} from \"./definitions\";\n\n/** A skill is just a resolvable `Definition`. */\nexport type SkillRecord = Definition;\n\n/** Tiers treated as first-party (never truncated, never dropped). */\nconst FIRST_PARTY_TIERS: ReadonlySet<string> = new Set([\"bundled\", \"builtin\"]);\n\nconst isFirstParty = (s: SkillRecord): boolean => FIRST_PARTY_TIERS.has(s.sourceTier);\n\n// ── Phase 1: listing ─────────────────────────────────────────────────────────\n\n/** One catalog entry — only the cheap, always-in-context surface. */\nexport interface SkillListingEntry {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string | undefined;\n /** True when this entry was degraded to names-only by the budget. */\n readonly degraded: boolean;\n readonly firstParty: boolean;\n}\n\nexport interface SkillListing {\n readonly entries: readonly SkillListingEntry[];\n readonly text: string;\n}\n\nconst listingOptionsSchema = z.object({\n maxListingDescChars: z.number().int().positive().default(250),\n contextWindowTokens: z.number().int().positive().default(200_000),\n budgetPercent: z.number().positive().max(1).default(0.01),\n touchedPaths: z.array(z.string()).default([]),\n});\nexport type SkillListingOptions = z.input<typeof listingOptionsSchema>;\n\n/** Cheap, deterministic token estimate (≈4 chars/token). */\nconst estimateTokens = (text: string): number => Math.ceil(text.length / 4);\n\n/** Minimal glob: `**` = any chars, `*` = any chars except `/`, else exact. */\nfunction globToRegExp(glob: string): RegExp {\n let out = \"^\";\n for (let i = 0; i < glob.length; i++) {\n const c = glob[i]!;\n if (c === \"*\") {\n if (glob[i + 1] === \"*\") {\n out += \".*\";\n i++;\n } else {\n out += \"[^/]*\";\n }\n } else if (\".+?^${}()|[]\\\\\".includes(c)) {\n out += `\\\\${c}`;\n } else {\n out += c;\n }\n }\n return new RegExp(`${out}$`);\n}\n\nconst pathMatches = (globs: readonly string[], touched: readonly string[]): boolean =>\n globs.some((g) => {\n const re = globToRegExp(g);\n return touched.some((p) => re.test(p));\n });\n\n/** Stable order: first-party first, then by slug — deterministic output. */\nfunction orderSkills(skills: readonly SkillRecord[]): SkillRecord[] {\n return [...skills].sort((a, b) => {\n const fa = isFirstParty(a) ? 0 : 1;\n const fb = isFirstParty(b) ? 0 : 1;\n if (fa !== fb) return fa - fb;\n return a.slug.localeCompare(b.slug);\n });\n}\n\nconst truncate = (s: string, max: number): string => (s.length <= max ? s : s.slice(0, max));\n\nfunction renderEntry(e: SkillListingEntry): string {\n const head = e.whenToUse ? `- ${e.name} — ${e.whenToUse}` : `- ${e.name}`;\n return e.description ? `${head}\\n ${e.description}` : head;\n}\n\n/**\n * Phase 1 — build the always-in-context skill catalog. Pure & deterministic.\n *\n * Budget = `contextWindowTokens * budgetPercent`. First-party skills are\n * emitted in full and do NOT consume budget against being dropped. Each\n * remaining skill is tried at full description; if it would overflow it is\n * degraded to names-only; if even that overflows it is dropped. Skills in\n * `ctx.loadedSlugs` and unactivated path-gated skills are suppressed.\n */\nexport function buildSkillListing(\n resolver: DefinitionResolver<SkillRecord>,\n ctx: ResolveContext,\n options: SkillListingOptions = {},\n): SkillListing {\n if (!ctx.tenantId) throw new Error(\"buildSkillListing: tenantId is required\");\n const opts = listingOptionsSchema.parse(options);\n\n const loaded = ctx.loadedSlugs ?? new Set<string>();\n const visible = resolver\n .resolve(ctx)\n .filter((s) => !loaded.has(s.slug))\n .filter((s) => {\n const paths = s.frontmatter.paths;\n return paths.length === 0 || pathMatches(paths, opts.touchedPaths);\n });\n\n const budget = Math.max(1, Math.floor(opts.contextWindowTokens * opts.budgetPercent));\n\n const entries: SkillListingEntry[] = [];\n let used = 0;\n\n for (const s of orderSkills(visible)) {\n const fp = isFirstParty(s);\n const fm = s.frontmatter;\n const fullDesc = fp ? fm.description : truncate(fm.description, opts.maxListingDescChars);\n\n if (fp) {\n const entry: SkillListingEntry = {\n name: fm.name,\n description: fm.description,\n whenToUse: fm.whenToUse,\n degraded: false,\n firstParty: true,\n };\n entries.push(entry);\n used += estimateTokens(renderEntry(entry));\n continue;\n }\n\n const full: SkillListingEntry = {\n name: fm.name,\n description: fullDesc,\n whenToUse: fm.whenToUse,\n degraded: false,\n firstParty: false,\n };\n const fullCost = estimateTokens(renderEntry(full));\n if (used + fullCost <= budget) {\n entries.push(full);\n used += fullCost;\n continue;\n }\n\n const nameOnly: SkillListingEntry = {\n name: fm.name,\n description: \"\",\n whenToUse: undefined,\n degraded: true,\n firstParty: false,\n };\n const nameCost = estimateTokens(renderEntry(nameOnly));\n if (used + nameCost <= budget) {\n entries.push(nameOnly);\n used += nameCost;\n }\n // else: drop entirely.\n }\n\n const text = entries.map(renderEntry).join(\"\\n\");\n return { entries, text };\n}\n\n// ── Phase 2: invocation ──────────────────────────────────────────────────────\n\nexport interface SkillMessage {\n readonly role: \"system\" | \"user\";\n readonly content: string;\n}\n\nexport interface ExpandedSkill {\n readonly messages: readonly SkillMessage[];\n readonly allowedTools: readonly string[];\n readonly modelOverride?: string | undefined;\n readonly effort?: \"low\" | \"medium\" | \"high\" | undefined;\n readonly executionMode: \"inline\" | \"fork\";\n}\n\nexport interface ExpandContext {\n readonly tenantId: string;\n readonly args: Record<string, string>;\n readonly vars: Record<string, string>;\n /** Lazy body fetch — host supplies; called only here, only once. */\n readonly bodyLoader: (bodyRef: string) => Promise<string>;\n /** Host-injected resource-base indirection (NO host FS access here). */\n readonly skillResourceBase: (slug: string) => string;\n}\n\n/**\n * Phase 2 — expand a skill into turn messages. Body is loaded lazily via the\n * injected `bodyLoader` (never during listing). A `Base directory…` line is\n * prepended via the injected `skillResourceBase` indirection. Args + vars are\n * substituted with `substituteArguments`. The skill's `allowedTools` are\n * returned for the caller to merge into the permission context — this never\n * mutates global state and never executes anything from the body.\n */\nexport async function expandSkill(skill: SkillRecord, ctx: ExpandContext): Promise<ExpandedSkill> {\n if (!ctx.tenantId) throw new Error(\"expandSkill: tenantId is required\");\n if (skill.tenantId !== ctx.tenantId) {\n throw new Error(\"expandSkill: cross-tenant skill access rejected\");\n }\n\n const rawBody = await ctx.bodyLoader(skill.bodyRef);\n const body = substituteArguments(rawBody, ctx.args, ctx.vars);\n const baseLine = `Base directory for this skill: ${ctx.skillResourceBase(skill.slug)}`;\n\n const messages: readonly SkillMessage[] = [\n { role: \"system\", content: baseLine },\n { role: \"user\", content: body },\n ];\n\n const allowedTools = [...skill.frontmatter.allowedTools];\n\n return {\n messages,\n allowedTools,\n modelOverride: skill.frontmatter.model,\n effort: skill.frontmatter.effort,\n executionMode: skill.frontmatter.executionMode,\n };\n}\n"],"mappings":"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","names":[]}