@nebutra/agent-runtime 0.2.0 → 0.2.2

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 (137) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +2 -0
  3. package/dist/adapters/dispatcher-sse.js +1 -0
  4. package/dist/adapters/index.d.ts +24 -5
  5. package/dist/adapters/index.js +28 -0
  6. package/dist/adapters/index.js.map +1 -1
  7. package/dist/adapters/mcp-catalog.js +1 -0
  8. package/dist/adapters/prisma-rollout.js +1 -0
  9. package/dist/chunk-424PT5DM.js +23 -0
  10. package/dist/chunk-424PT5DM.js.map +1 -0
  11. package/dist/{chunk-NN7DATXA.js → chunk-4Y25ZTKI.js} +3 -3
  12. package/dist/chunk-4Y25ZTKI.js.map +1 -0
  13. package/dist/{chunk-BJBBR3QA.js → chunk-D4YAPLOW.js} +4 -4
  14. package/dist/chunk-D4YAPLOW.js.map +1 -0
  15. package/dist/{chunk-ZMYX5VBU.js → chunk-GQZKYWFT.js} +23 -7
  16. package/dist/chunk-GQZKYWFT.js.map +1 -0
  17. package/dist/chunk-KCNN4QUQ.js +255 -0
  18. package/dist/chunk-KCNN4QUQ.js.map +1 -0
  19. package/dist/{chunk-PGGWSUTM.js → chunk-NI4EDT4T.js} +2 -2
  20. package/dist/chunk-NI4EDT4T.js.map +1 -0
  21. package/dist/chunk-Q62VKHIT.js +178 -0
  22. package/dist/chunk-Q62VKHIT.js.map +1 -0
  23. package/dist/{chunk-MUF7ZZTO.js → chunk-R5HOSQUW.js} +2 -1
  24. package/dist/{chunk-MUF7ZZTO.js.map → chunk-R5HOSQUW.js.map} +1 -1
  25. package/dist/{chunk-5N4644PB.js → chunk-SD2ZJ7XG.js} +4 -4
  26. package/dist/cli.d.ts +2 -0
  27. package/dist/cli.js +52 -0
  28. package/dist/cli.js.map +1 -0
  29. package/dist/commands.js +1 -0
  30. package/dist/definitions.js +1 -0
  31. package/dist/dispatcher.js +1 -0
  32. package/dist/durable-turn.js +3 -2
  33. package/dist/hook-pipeline.js +1 -0
  34. package/dist/index.d.ts +8 -85
  35. package/dist/index.js +249 -134
  36. package/dist/index.js.map +1 -1
  37. package/dist/loop.d.ts +2 -2
  38. package/dist/loop.js +3 -2
  39. package/dist/mcp-bridge.d.ts +3 -3
  40. package/dist/mcp-bridge.js +3 -2
  41. package/dist/model.js +1 -0
  42. package/dist/orchestration.d.ts +84 -0
  43. package/dist/orchestration.js +16 -0
  44. package/dist/orchestration.js.map +1 -0
  45. package/dist/policy.js +1 -0
  46. package/dist/protocol.js +1 -0
  47. package/dist/pulsar.d.ts +78 -0
  48. package/dist/pulsar.js +17 -0
  49. package/dist/pulsar.js.map +1 -0
  50. package/dist/rollout-store-persistent.js +1 -0
  51. package/dist/rollout.js +1 -0
  52. package/dist/sandbox.js +2 -1
  53. package/dist/skills.js +2 -1
  54. package/dist/subagents.js +1 -0
  55. package/dist/tools.d.ts +4 -3
  56. package/dist/tools.js +5 -3
  57. package/package.json +84 -27
  58. package/.turbo/turbo-build.log +0 -115
  59. package/.turbo/turbo-test.log +0 -44
  60. package/.turbo/turbo-typecheck.log +0 -4
  61. package/CHANGELOG.md +0 -253
  62. package/dist/chunk-BJBBR3QA.js.map +0 -1
  63. package/dist/chunk-NN7DATXA.js.map +0 -1
  64. package/dist/chunk-PGGWSUTM.js.map +0 -1
  65. package/dist/chunk-ZMYX5VBU.js.map +0 -1
  66. package/src/adapters/dispatcher-sse.test.ts +0 -218
  67. package/src/adapters/dispatcher-sse.ts +0 -222
  68. package/src/adapters/index.ts +0 -18
  69. package/src/adapters/mcp-catalog.test.ts +0 -213
  70. package/src/adapters/mcp-catalog.ts +0 -188
  71. package/src/adapters/prisma-rollout.test.ts +0 -153
  72. package/src/adapters/prisma-rollout.ts +0 -104
  73. package/src/agent-runtime.test.ts +0 -176
  74. package/src/artifact-stream.test.ts +0 -330
  75. package/src/artifact-stream.ts +0 -453
  76. package/src/channel-gateway.test.ts +0 -432
  77. package/src/channel-gateway.ts +0 -357
  78. package/src/code-review.test.ts +0 -501
  79. package/src/code-review.ts +0 -495
  80. package/src/command-suggestions.test.ts +0 -251
  81. package/src/command-suggestions.ts +0 -338
  82. package/src/commands.test.ts +0 -184
  83. package/src/commands.ts +0 -140
  84. package/src/commit-message.test.ts +0 -249
  85. package/src/commit-message.ts +0 -180
  86. package/src/context-compaction.test.ts +0 -522
  87. package/src/context-compaction.ts +0 -434
  88. package/src/definitions.test.ts +0 -78
  89. package/src/definitions.ts +0 -190
  90. package/src/deployment-status.test.ts +0 -215
  91. package/src/deployment-status.ts +0 -227
  92. package/src/design-context.test.ts +0 -195
  93. package/src/design-context.ts +0 -198
  94. package/src/dispatcher.test.ts +0 -234
  95. package/src/dispatcher.ts +0 -189
  96. package/src/durable-turn.test.ts +0 -209
  97. package/src/durable-turn.ts +0 -135
  98. package/src/edit-planner.test.ts +0 -204
  99. package/src/edit-planner.ts +0 -325
  100. package/src/fuzzy-match.test.ts +0 -311
  101. package/src/fuzzy-match.ts +0 -444
  102. package/src/hook-pipeline.test.ts +0 -279
  103. package/src/hook-pipeline.ts +0 -373
  104. package/src/inbound-admission.test.ts +0 -394
  105. package/src/inbound-admission.ts +0 -246
  106. package/src/index.ts +0 -47
  107. package/src/loop.test.ts +0 -161
  108. package/src/loop.ts +0 -211
  109. package/src/mcp-bridge.test.ts +0 -165
  110. package/src/mcp-bridge.ts +0 -76
  111. package/src/memory-provider.test.ts +0 -232
  112. package/src/memory-provider.ts +0 -257
  113. package/src/model.ts +0 -168
  114. package/src/permission-ruleset.test.ts +0 -301
  115. package/src/permission-ruleset.ts +0 -200
  116. package/src/policy.ts +0 -151
  117. package/src/project-repo.test.ts +0 -232
  118. package/src/project-repo.ts +0 -311
  119. package/src/protocol.ts +0 -159
  120. package/src/rollout-store-persistent.test.ts +0 -217
  121. package/src/rollout-store-persistent.ts +0 -166
  122. package/src/rollout.ts +0 -150
  123. package/src/sandbox.ts +0 -113
  124. package/src/session-share.test.ts +0 -360
  125. package/src/session-share.ts +0 -310
  126. package/src/skill-distillation.test.ts +0 -177
  127. package/src/skill-distillation.ts +0 -369
  128. package/src/skills.test.ts +0 -277
  129. package/src/skills.ts +0 -255
  130. package/src/subagents.test.ts +0 -290
  131. package/src/subagents.ts +0 -332
  132. package/src/tools.ts +0 -126
  133. package/src/workbench.test.ts +0 -0
  134. package/src/workbench.ts +0 -0
  135. package/tsconfig.json +0 -12
  136. package/tsup.config.ts +0 -33
  137. /package/dist/{chunk-5N4644PB.js.map → chunk-SD2ZJ7XG.js.map} +0 -0
@@ -1,177 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import {
3
- type DistilledSkill,
4
- distillSkill,
5
- improveSkill,
6
- isDistillable,
7
- type Synthesizer,
8
- shouldNudgePersist,
9
- type Trajectory,
10
- } from "./skill-distillation.js";
11
-
12
- const goodTraj = (over: Partial<Trajectory> = {}): Trajectory => ({
13
- tenantId: "org_1",
14
- sessionId: "sess_1",
15
- goal: "Refactor the billing module to use the provider-agnostic adapter",
16
- outcome: "success",
17
- steps: [
18
- { kind: "message", input: "start" },
19
- { kind: "tool", name: "ReadFile", input: { path: "a.ts" }, output: "ok" },
20
- { kind: "observation", output: "found 3 callers" },
21
- { kind: "tool", name: "EditFile", input: { path: "a.ts" }, output: "patched" },
22
- { kind: "tool", name: "RunTests", output: "pass" },
23
- ],
24
- ...over,
25
- });
26
-
27
- const fallback: Synthesizer = async () => ({
28
- name: "Billing Adapter Refactor",
29
- description: "Refactor billing toward the provider-agnostic adapter",
30
- whenToUse: "When migrating billing code to the shared adapter",
31
- body: "1. Read callers\n2. Edit\n3. Run tests",
32
- // intentionally over-broad — must be clamped to used tools
33
- allowedTools: ["ReadFile", "EditFile", "RunTests", "DeployProd"],
34
- });
35
-
36
- describe("isDistillable", () => {
37
- it("rejects a failed trajectory", () => {
38
- const r = isDistillable(goodTraj({ outcome: "failure" }));
39
- expect(r.ok).toBe(false);
40
- expect(r.reason).toBeTruthy();
41
- });
42
-
43
- it("rejects a partial trajectory", () => {
44
- expect(isDistillable(goodTraj({ outcome: "partial" })).ok).toBe(false);
45
- });
46
-
47
- it("rejects too-few tool steps", () => {
48
- const r = isDistillable(goodTraj({ steps: [{ kind: "tool", name: "ReadFile" }] }));
49
- expect(r.ok).toBe(false);
50
- });
51
-
52
- it("rejects a trivial goal", () => {
53
- expect(isDistillable(goodTraj({ goal: "hi" })).ok).toBe(false);
54
- });
55
-
56
- it("accepts a good trajectory", () => {
57
- expect(isDistillable(goodTraj()).ok).toBe(true);
58
- });
59
-
60
- it("honors a configurable minimum tool count", () => {
61
- expect(isDistillable(goodTraj(), { minToolSteps: 99 }).ok).toBe(false);
62
- });
63
- });
64
-
65
- describe("distillSkill", () => {
66
- it("clamps allowedTools to tools actually used (least privilege)", async () => {
67
- const s = await distillSkill(goodTraj(), fallback);
68
- expect([...s.frontmatter.allowedTools].sort()).toEqual(
69
- ["EditFile", "ReadFile", "RunTests"].sort(),
70
- );
71
- expect(s.frontmatter.allowedTools).not.toContain("DeployProd");
72
- });
73
-
74
- it("produces a kebab slug and carries tenant + provenance", async () => {
75
- const s = await distillSkill(goodTraj(), fallback);
76
- expect(s.slug).toBe("billing-adapter-refactor");
77
- expect(s.tenantId).toBe("org_1");
78
- expect(s.sourceTier).toBe("dynamic");
79
- expect(s.provenance.sessionId).toBe("sess_1");
80
- expect(s.provenance.stepCount).toBe(5);
81
- expect(typeof s.provenance.distilledAt).toBe("string");
82
- });
83
-
84
- it("builds a deterministic prompt (snapshot) and passes it to synthesize", async () => {
85
- const spy = vi.fn(fallback);
86
- await distillSkill(goodTraj(), spy);
87
- expect(spy).toHaveBeenCalledTimes(1);
88
- expect(spy.mock.calls[0]![0]).toMatchInlineSnapshot(`
89
- "Distill a reusable skill from this successful trajectory.
90
-
91
- Goal: Refactor the billing module to use the provider-agnostic adapter
92
-
93
- Tools used (in order): ReadFile, EditFile, RunTests
94
-
95
- Salient observations:
96
- - found 3 callers
97
-
98
- Produce a concise, generalizable skill (name, description, whenToUse, body, allowedTools)."
99
- `);
100
- });
101
-
102
- it("fails closed on empty tenant", async () => {
103
- await expect(distillSkill(goodTraj({ tenantId: "" }), fallback)).rejects.toThrow();
104
- });
105
-
106
- it("does not mutate the input trajectory", async () => {
107
- const t = goodTraj();
108
- const snap = JSON.stringify(t);
109
- await distillSkill(t, fallback);
110
- expect(JSON.stringify(t)).toBe(snap);
111
- });
112
- });
113
-
114
- describe("improveSkill", () => {
115
- const base = async (): Promise<DistilledSkill> => distillSkill(goodTraj(), fallback);
116
-
117
- it("bumps version and unions tools clamped to used", async () => {
118
- const existing = await base();
119
- const newTraj = goodTraj({
120
- steps: [
121
- { kind: "tool", name: "ReadFile" },
122
- { kind: "tool", name: "Lint" },
123
- { kind: "tool", name: "RunTests" },
124
- ],
125
- });
126
- const improved = await improveSkill(existing, newTraj, async () => ({
127
- name: existing.frontmatter.name,
128
- description: "better",
129
- whenToUse: "later",
130
- body: "improved body",
131
- allowedTools: ["ReadFile", "Lint", "RunTests", "DeployProd"],
132
- }));
133
- expect(improved.provenance.version).toBe((existing.provenance.version ?? 1) + 1);
134
- // union of existing used (Read/Edit/RunTests) + new used (Read/Lint/RunTests)
135
- expect([...improved.frontmatter.allowedTools].sort()).toEqual(
136
- ["EditFile", "Lint", "ReadFile", "RunTests"].sort(),
137
- );
138
- expect(improved.body).toBe("improved body");
139
- });
140
-
141
- it("throws on tenant mismatch", async () => {
142
- const existing = await base();
143
- await expect(
144
- improveSkill(existing, goodTraj({ tenantId: "org_2" }), fallback),
145
- ).rejects.toThrow();
146
- });
147
-
148
- it("does not mutate the existing skill", async () => {
149
- const existing = await base();
150
- const snap = JSON.stringify(existing);
151
- await improveSkill(existing, goodTraj(), fallback);
152
- expect(JSON.stringify(existing)).toBe(snap);
153
- });
154
- });
155
-
156
- describe("shouldNudgePersist", () => {
157
- it("nudges on enough unsaved successes", () => {
158
- expect(shouldNudgePersist({ turnsSinceLastDistill: 1, unsavedSuccesses: 3 })).toBe(true);
159
- });
160
-
161
- it("nudges on enough turns", () => {
162
- expect(shouldNudgePersist({ turnsSinceLastDistill: 50, unsavedSuccesses: 0 })).toBe(true);
163
- });
164
-
165
- it("does not nudge below thresholds", () => {
166
- expect(shouldNudgePersist({ turnsSinceLastDistill: 2, unsavedSuccesses: 1 })).toBe(false);
167
- });
168
-
169
- it("honors custom thresholds", () => {
170
- expect(
171
- shouldNudgePersist(
172
- { turnsSinceLastDistill: 0, unsavedSuccesses: 1 },
173
- { minUnsavedSuccesses: 1 },
174
- ),
175
- ).toBe(true);
176
- });
177
- });
@@ -1,369 +0,0 @@
1
- /**
2
- * Skill distillation (INVERSE of ./skills).
3
- *
4
- * Where ./skills only *loads and discloses* authored skills, this module
5
- * *creates and refines* them from lived experience — the "learn a skill
6
- * from a trajectory" loop of a self-improving agent, re-expressed from
7
- * first principles.
8
- *
9
- * The loop:
10
- * 1. observe a trajectory (the ordered steps an agent took toward a goal)
11
- * 2. gate it — only clean successes with real tool work distill (no noise)
12
- * 3. build a deterministic distillation prompt from the trajectory
13
- * 4. hand the prompt to an INJECTED synthesizer (the LLM call lives
14
- * outside this module — here it is a pure indirection)
15
- * 5. assemble a tenant-scoped, least-privilege `DistilledSkill` whose
16
- * tool allowlist is clamped to tools the experience actually exercised
17
- * 6. optionally refine an existing skill from a fresh success
18
- *
19
- * SECURITY: every entry point requires a non-empty `tenantId` and fails
20
- * closed. Cross-tenant improvement is rejected. A distilled skill can never
21
- * be granted a tool the trajectory did not use (least privilege). Pure
22
- * data/logic; no host access; the model call is an injected indirection.
23
- */
24
-
25
- import { z } from "zod";
26
-
27
- // ── Trajectory model ─────────────────────────────────────────────────────────
28
-
29
- const trajectoryStepSchema = z.object({
30
- kind: z.enum(["tool", "message", "observation"]),
31
- name: z.string().optional(),
32
- input: z.unknown().optional(),
33
- output: z.unknown().optional(),
34
- });
35
-
36
- const trajectorySchema = z.object({
37
- tenantId: z.string(),
38
- sessionId: z.string(),
39
- goal: z.string(),
40
- steps: z.array(trajectoryStepSchema),
41
- outcome: z.enum(["success", "failure", "partial"]),
42
- });
43
-
44
- export interface TrajectoryStep {
45
- readonly kind: "tool" | "message" | "observation";
46
- readonly name?: string | undefined;
47
- readonly input?: unknown;
48
- readonly output?: unknown;
49
- }
50
-
51
- export interface Trajectory {
52
- readonly tenantId: string;
53
- readonly sessionId: string;
54
- readonly goal: string;
55
- readonly steps: readonly TrajectoryStep[];
56
- readonly outcome: "success" | "failure" | "partial";
57
- }
58
-
59
- // ── Distilled skill (LOCAL minimal structural shape) ─────────────────────────
60
-
61
- /**
62
- * Minimal structural shape — deliberately NOT imported from ./skills /
63
- * ./definitions. It is structurally usable as a skill record consumed by
64
- * the loader: a slug, frontmatter-shaped metadata, and a body text.
65
- */
66
- export interface DistilledSkillFrontmatter {
67
- readonly name: string;
68
- readonly description: string;
69
- readonly whenToUse?: string | undefined;
70
- readonly allowedTools: readonly string[];
71
- }
72
-
73
- export interface DistilledSkillProvenance {
74
- readonly sessionId: string;
75
- readonly distilledAt: string;
76
- readonly stepCount: number;
77
- readonly version?: number | undefined;
78
- }
79
-
80
- export interface DistilledSkill {
81
- readonly slug: string;
82
- readonly tenantId: string;
83
- readonly sourceTier: "dynamic";
84
- readonly frontmatter: DistilledSkillFrontmatter;
85
- readonly body: string;
86
- readonly provenance: DistilledSkillProvenance;
87
- }
88
-
89
- /** Injected LLM indirection — the model call is NOT implemented here. */
90
- export type Synthesizer = (prompt: string) => Promise<{
91
- name: string;
92
- description: string;
93
- whenToUse: string;
94
- body: string;
95
- allowedTools: string[];
96
- }>;
97
-
98
- // ── Pure helpers ─────────────────────────────────────────────────────────────
99
-
100
- const TRIVIAL_GOAL_MIN_CHARS = 12;
101
- const MAX_PROMPT_OBSERVATIONS = 5;
102
- const MAX_OBSERVATION_CHARS = 160;
103
-
104
- /** kebab-case a free-form name into a stable slug. */
105
- export function kebab(name: string): string {
106
- return name
107
- .normalize("NFKD")
108
- .replace(/[^\p{L}\p{N}]+/gu, "-")
109
- .replace(/(^-+)|(-+$)/g, "")
110
- .toLowerCase();
111
- }
112
-
113
- /** Ordered, de-duplicated tool names the trajectory actually exercised. */
114
- function toolsUsed(traj: Trajectory): readonly string[] {
115
- const seen = new Set<string>();
116
- const out: string[] = [];
117
- for (const s of traj.steps) {
118
- if (s.kind === "tool" && s.name && !seen.has(s.name)) {
119
- seen.add(s.name);
120
- out.push(s.name);
121
- }
122
- }
123
- return out;
124
- }
125
-
126
- /** Intersection preserving the order of `wanted`. */
127
- function clampTools(wanted: readonly string[], allowed: readonly string[]): readonly string[] {
128
- const set = new Set(allowed);
129
- const seen = new Set<string>();
130
- const out: string[] = [];
131
- for (const t of wanted) {
132
- if (set.has(t) && !seen.has(t)) {
133
- seen.add(t);
134
- out.push(t);
135
- }
136
- }
137
- return out;
138
- }
139
-
140
- /** Union preserving first-seen order. */
141
- function unionTools(a: readonly string[], b: readonly string[]): readonly string[] {
142
- const seen = new Set<string>();
143
- const out: string[] = [];
144
- for (const t of [...a, ...b]) {
145
- if (!seen.has(t)) {
146
- seen.add(t);
147
- out.push(t);
148
- }
149
- }
150
- return out;
151
- }
152
-
153
- const truncate = (s: string, max: number): string => (s.length <= max ? s : s.slice(0, max));
154
-
155
- function salientObservations(traj: Trajectory): readonly string[] {
156
- const out: string[] = [];
157
- for (const s of traj.steps) {
158
- if (s.kind !== "observation") continue;
159
- const txt = typeof s.output === "string" ? s.output : JSON.stringify(s.output ?? "");
160
- if (txt) out.push(truncate(txt, MAX_OBSERVATION_CHARS));
161
- if (out.length >= MAX_PROMPT_OBSERVATIONS) break;
162
- }
163
- return out;
164
- }
165
-
166
- /** Deterministic distillation prompt — pure function of the trajectory. */
167
- export function buildDistillationPrompt(traj: Trajectory): string {
168
- const tools = toolsUsed(traj);
169
- const obs = salientObservations(traj);
170
- const lines = [
171
- "Distill a reusable skill from this successful trajectory.",
172
- "",
173
- `Goal: ${traj.goal}`,
174
- "",
175
- `Tools used (in order): ${tools.join(", ")}`,
176
- "",
177
- "Salient observations:",
178
- ...(obs.length > 0 ? obs.map((o) => `- ${o}`) : ["- (none)"]),
179
- "",
180
- "Produce a concise, generalizable skill (name, description, whenToUse, body, allowedTools).",
181
- ];
182
- return lines.join("\n");
183
- }
184
-
185
- /** Deterministic improvement prompt — does not regress the existing skill. */
186
- export function buildImprovementPrompt(existing: DistilledSkill, traj: Trajectory): string {
187
- return [
188
- "Improve this existing skill using a new successful trajectory.",
189
- "Refine and generalize; do NOT regress what already works.",
190
- "",
191
- `Existing skill: ${existing.frontmatter.name}`,
192
- `Existing body:\n${existing.frontmatter.description}`,
193
- "",
194
- buildDistillationPrompt(traj),
195
- ].join("\n");
196
- }
197
-
198
- // ── Eligibility gate ─────────────────────────────────────────────────────────
199
-
200
- const eligibilityOptionsSchema = z.object({
201
- minToolSteps: z.number().int().positive().default(2),
202
- });
203
- export type EligibilityOptions = z.input<typeof eligibilityOptionsSchema>;
204
-
205
- /**
206
- * Only clean successes with real tool work and a non-trivial goal qualify.
207
- * Pure. Don't manufacture noise skills from failures or trivial sessions.
208
- */
209
- export function isDistillable(
210
- traj: Trajectory,
211
- options: EligibilityOptions = {},
212
- ): { ok: boolean; reason?: string } {
213
- const t = trajectorySchema.parse(traj);
214
- const opts = eligibilityOptionsSchema.parse(options);
215
-
216
- if (t.outcome !== "success") {
217
- return { ok: false, reason: `outcome is '${t.outcome}', not 'success'` };
218
- }
219
- if (t.goal.trim().length < TRIVIAL_GOAL_MIN_CHARS) {
220
- return { ok: false, reason: "goal is trivial / too short" };
221
- }
222
- const toolStepCount = t.steps.filter((s) => s.kind === "tool").length;
223
- if (toolStepCount < opts.minToolSteps) {
224
- return {
225
- ok: false,
226
- reason: `only ${toolStepCount} tool step(s); need ≥ ${opts.minToolSteps}`,
227
- };
228
- }
229
- return { ok: true };
230
- }
231
-
232
- // ── Distillation ─────────────────────────────────────────────────────────────
233
-
234
- export interface DistillOptions {
235
- /** Override the clock for deterministic provenance in tests. */
236
- readonly now?: (() => Date) | undefined;
237
- readonly eligibility?: EligibilityOptions | undefined;
238
- }
239
-
240
- function assertTenant(tenantId: string, where: string): void {
241
- if (!tenantId || tenantId.trim().length === 0) {
242
- throw new Error(`${where}: tenantId is required (fail-closed)`);
243
- }
244
- }
245
-
246
- /**
247
- * Distill a `DistilledSkill` from a successful trajectory. The model call is
248
- * the injected `synthesize`. The resulting tool allowlist is clamped to the
249
- * intersection of what the synthesizer asked for and what the trajectory
250
- * actually exercised — least privilege, always.
251
- */
252
- export async function distillSkill(
253
- traj: Trajectory,
254
- synthesize: Synthesizer,
255
- opts: DistillOptions = {},
256
- ): Promise<DistilledSkill> {
257
- const t = trajectorySchema.parse(traj);
258
- assertTenant(t.tenantId, "distillSkill");
259
-
260
- const gate = isDistillable(t, opts.eligibility ?? {});
261
- if (!gate.ok) {
262
- throw new Error(`distillSkill: trajectory not distillable — ${gate.reason}`);
263
- }
264
-
265
- const prompt = buildDistillationPrompt(t);
266
- const synthesized = await synthesize(prompt);
267
-
268
- const used = toolsUsed(t);
269
- const allowedTools = clampTools(synthesized.allowedTools, used);
270
- const distilledAt = (opts.now ? opts.now() : new Date()).toISOString();
271
-
272
- return {
273
- slug: kebab(synthesized.name),
274
- tenantId: t.tenantId,
275
- sourceTier: "dynamic",
276
- frontmatter: {
277
- name: synthesized.name,
278
- description: synthesized.description,
279
- whenToUse: synthesized.whenToUse,
280
- allowedTools,
281
- },
282
- body: synthesized.body,
283
- provenance: {
284
- sessionId: t.sessionId,
285
- distilledAt,
286
- stepCount: t.steps.length,
287
- version: 1,
288
- },
289
- };
290
- }
291
-
292
- // ── Improvement ──────────────────────────────────────────────────────────────
293
-
294
- /**
295
- * Refine an existing distilled skill from a fresh successful trajectory for
296
- * the same goal. Tenant must match (fail-closed). The tool allowlist is the
297
- * union of the existing allowlist and the new trajectory's tools, then
298
- * re-clamped to tools the new trajectory actually used (still least
299
- * privilege). The body is replaced via the injected synthesizer with an
300
- * "improve, don't regress" prompt. Version bumps.
301
- */
302
- export async function improveSkill(
303
- existing: DistilledSkill,
304
- traj: Trajectory,
305
- synthesize: Synthesizer,
306
- ): Promise<DistilledSkill> {
307
- const t = trajectorySchema.parse(traj);
308
- assertTenant(existing.tenantId, "improveSkill");
309
- assertTenant(t.tenantId, "improveSkill");
310
- if (existing.tenantId !== t.tenantId) {
311
- throw new Error("improveSkill: cross-tenant improvement rejected");
312
- }
313
- const gate = isDistillable(t);
314
- if (!gate.ok) {
315
- throw new Error(`improveSkill: trajectory not distillable — ${gate.reason}`);
316
- }
317
-
318
- const prompt = buildImprovementPrompt(existing, t);
319
- const synthesized = await synthesize(prompt);
320
-
321
- const used = toolsUsed(t);
322
- const merged = unionTools(existing.frontmatter.allowedTools, used);
323
- const allowedTools = clampTools(merged, unionTools(existing.frontmatter.allowedTools, used));
324
- const distilledAt = new Date().toISOString();
325
- const nextVersion = (existing.provenance.version ?? 1) + 1;
326
-
327
- return {
328
- slug: existing.slug,
329
- tenantId: existing.tenantId,
330
- sourceTier: "dynamic",
331
- frontmatter: {
332
- name: existing.frontmatter.name,
333
- description: synthesized.description,
334
- whenToUse: synthesized.whenToUse,
335
- allowedTools,
336
- },
337
- body: synthesized.body,
338
- provenance: {
339
- sessionId: t.sessionId,
340
- distilledAt,
341
- stepCount: t.steps.length,
342
- version: nextVersion,
343
- },
344
- };
345
- }
346
-
347
- // ── Self-persist nudge ───────────────────────────────────────────────────────
348
-
349
- const nudgeOptionsSchema = z.object({
350
- minUnsavedSuccesses: z.number().int().positive().default(3),
351
- minTurns: z.number().int().positive().default(40),
352
- });
353
- export type NudgeOptions = z.input<typeof nudgeOptionsSchema>;
354
-
355
- /**
356
- * Deterministic heuristic: nudge the agent to persist learning once enough
357
- * unsaved successes have accumulated, or enough turns have passed since the
358
- * last distillation. Pure.
359
- */
360
- export function shouldNudgePersist(
361
- sessionStats: { turnsSinceLastDistill: number; unsavedSuccesses: number },
362
- options: NudgeOptions = {},
363
- ): boolean {
364
- const opts = nudgeOptionsSchema.parse(options);
365
- return (
366
- sessionStats.unsavedSuccesses >= opts.minUnsavedSuccesses ||
367
- sessionStats.turnsSinceLastDistill >= opts.minTurns
368
- );
369
- }