@nebutra/agent-runtime 0.2.1 → 2.0.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 (114) hide show
  1. package/LICENSE +21 -676
  2. package/README.md +33 -10
  3. package/dist/adapters/index.d.ts +24 -5
  4. package/dist/adapters/index.js +27 -0
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
  7. package/dist/chunk-7ELA4SWE.js +73 -0
  8. package/dist/chunk-7ELA4SWE.js.map +1 -0
  9. package/dist/chunk-Q62VKHIT.js +178 -0
  10. package/dist/chunk-Q62VKHIT.js.map +1 -0
  11. package/dist/chunk-VXILZAXK.js +541 -0
  12. package/dist/chunk-VXILZAXK.js.map +1 -0
  13. package/dist/command-exec.d.ts +45 -0
  14. package/dist/command-exec.js +15 -0
  15. package/dist/command-exec.js.map +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +81 -28
  18. package/dist/index.js.map +1 -1
  19. package/dist/orchestration.d.ts +42 -1
  20. package/dist/orchestration.js +7 -3
  21. package/dist/pulsar.js +2 -2
  22. package/dist/sandbox-DfcRttXt.d.ts +219 -0
  23. package/dist/sandbox.d.ts +2 -64
  24. package/dist/sandbox.js +25 -3
  25. package/package.json +81 -30
  26. package/.turbo/turbo-build.log +0 -130
  27. package/.turbo/turbo-test.log +0 -47
  28. package/.turbo/turbo-typecheck.log +0 -4
  29. package/CHANGELOG.md +0 -250
  30. package/dist/chunk-MUF7ZZTO.js +0 -57
  31. package/dist/chunk-MUF7ZZTO.js.map +0 -1
  32. package/dist/chunk-MX2WL43P.js +0 -90
  33. package/dist/chunk-MX2WL43P.js.map +0 -1
  34. package/examples/pulsar-quickstart.ts +0 -35
  35. package/examples/resume-branch.ts +0 -45
  36. package/examples/subagent-fanout.ts +0 -20
  37. package/src/adapters/dispatcher-sse.test.ts +0 -218
  38. package/src/adapters/dispatcher-sse.ts +0 -222
  39. package/src/adapters/index.ts +0 -18
  40. package/src/adapters/mcp-catalog.test.ts +0 -213
  41. package/src/adapters/mcp-catalog.ts +0 -188
  42. package/src/adapters/prisma-rollout.test.ts +0 -153
  43. package/src/adapters/prisma-rollout.ts +0 -104
  44. package/src/agent-runtime.test.ts +0 -176
  45. package/src/artifact-stream.test.ts +0 -330
  46. package/src/artifact-stream.ts +0 -453
  47. package/src/channel-gateway.test.ts +0 -432
  48. package/src/channel-gateway.ts +0 -357
  49. package/src/cli.ts +0 -49
  50. package/src/code-review.test.ts +0 -501
  51. package/src/code-review.ts +0 -495
  52. package/src/command-suggestions.test.ts +0 -251
  53. package/src/command-suggestions.ts +0 -338
  54. package/src/commands.test.ts +0 -184
  55. package/src/commands.ts +0 -140
  56. package/src/commit-message.test.ts +0 -249
  57. package/src/commit-message.ts +0 -180
  58. package/src/context-compaction.test.ts +0 -522
  59. package/src/context-compaction.ts +0 -438
  60. package/src/definitions.test.ts +0 -78
  61. package/src/definitions.ts +0 -190
  62. package/src/deployment-status.test.ts +0 -215
  63. package/src/deployment-status.ts +0 -227
  64. package/src/design-context.test.ts +0 -195
  65. package/src/design-context.ts +0 -198
  66. package/src/dispatcher.test.ts +0 -234
  67. package/src/dispatcher.ts +0 -189
  68. package/src/durable-turn.test.ts +0 -209
  69. package/src/durable-turn.ts +0 -135
  70. package/src/edit-planner.test.ts +0 -204
  71. package/src/edit-planner.ts +0 -325
  72. package/src/fuzzy-match.test.ts +0 -311
  73. package/src/fuzzy-match.ts +0 -444
  74. package/src/hook-pipeline.test.ts +0 -279
  75. package/src/hook-pipeline.ts +0 -373
  76. package/src/inbound-admission.test.ts +0 -394
  77. package/src/inbound-admission.ts +0 -246
  78. package/src/index.ts +0 -49
  79. package/src/loop.test.ts +0 -161
  80. package/src/loop.ts +0 -211
  81. package/src/mcp-bridge.test.ts +0 -165
  82. package/src/mcp-bridge.ts +0 -81
  83. package/src/memory-provider.test.ts +0 -232
  84. package/src/memory-provider.ts +0 -257
  85. package/src/model.ts +0 -168
  86. package/src/orchestration.test.ts +0 -53
  87. package/src/orchestration.ts +0 -146
  88. package/src/permission-ruleset.test.ts +0 -301
  89. package/src/permission-ruleset.ts +0 -1
  90. package/src/policy.ts +0 -151
  91. package/src/project-repo.test.ts +0 -232
  92. package/src/project-repo.ts +0 -311
  93. package/src/protocol.ts +0 -159
  94. package/src/pulsar.test.ts +0 -156
  95. package/src/pulsar.ts +0 -322
  96. package/src/rollout-store-persistent.test.ts +0 -217
  97. package/src/rollout-store-persistent.ts +0 -166
  98. package/src/rollout.ts +0 -150
  99. package/src/sandbox.ts +0 -113
  100. package/src/session-share.test.ts +0 -360
  101. package/src/session-share.ts +0 -310
  102. package/src/skill-distillation.test.ts +0 -177
  103. package/src/skill-distillation.ts +0 -369
  104. package/src/skills.test.ts +0 -277
  105. package/src/skills.ts +0 -277
  106. package/src/subagents.test.ts +0 -290
  107. package/src/subagents.ts +0 -332
  108. package/src/tools.test.ts +0 -12
  109. package/src/tools.ts +0 -129
  110. package/src/workbench.test.ts +0 -0
  111. package/src/workbench.ts +0 -0
  112. package/tsconfig.json +0 -12
  113. package/tsup.config.ts +0 -36
  114. /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.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
- }