@vellumai/assistant 0.8.9-staging.3 → 0.8.9-staging.4

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 (90) hide show
  1. package/openapi.yaml +16 -115
  2. package/package.json +1 -1
  3. package/src/__tests__/anthropic-provider.test.ts +23 -8
  4. package/src/__tests__/context-window-manager-compact-retry.test.ts +120 -14
  5. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  6. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  7. package/src/__tests__/llm-resolver.test.ts +73 -0
  8. package/src/__tests__/plugin-external-api.test.ts +68 -0
  9. package/src/__tests__/published-app-updater.test.ts +138 -0
  10. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  11. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  12. package/src/cli/commands/memory-v3.ts +57 -199
  13. package/src/config/call-site-defaults.ts +0 -1
  14. package/src/config/llm-resolver.ts +39 -7
  15. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  16. package/src/config/schemas/call-site-catalog.ts +0 -7
  17. package/src/config/schemas/llm.ts +17 -1
  18. package/src/config/schemas/memory-v3.ts +58 -8
  19. package/src/config/seed-inference-profiles.ts +18 -0
  20. package/src/daemon/conversation-agent-loop.ts +9 -17
  21. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  22. package/src/daemon/tool-side-effects.ts +15 -0
  23. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  24. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  25. package/src/memory/v2/consolidation-job.ts +2 -72
  26. package/src/memory/v2/types.ts +5 -0
  27. package/src/plugins/defaults/compaction/window-manager.ts +56 -0
  28. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  29. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  30. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  31. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  32. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  33. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  34. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  35. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  36. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  37. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  38. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  39. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  40. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  41. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  42. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  43. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  44. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  45. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  46. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  47. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  48. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  49. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  50. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  51. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  52. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  53. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  54. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  55. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  56. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  57. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  58. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  59. package/src/plugins/external-api.ts +114 -0
  60. package/src/providers/anthropic/client.ts +9 -10
  61. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  62. package/src/providers/inference/logit-bias.ts +55 -0
  63. package/src/providers/openai/chat-completions-provider.ts +19 -1
  64. package/src/providers/retry.ts +22 -0
  65. package/src/providers/types.ts +6 -0
  66. package/src/runtime/routes/app-management-routes.ts +3 -0
  67. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  68. package/src/services/published-app-updater.ts +30 -8
  69. package/src/tools/skills/load.ts +1 -1
  70. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  71. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  72. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  73. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  74. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  75. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  76. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  77. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  78. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  79. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  80. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  81. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  82. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  83. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  84. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  85. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  86. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  87. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  88. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  89. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  90. package/src/util/map-limit.ts +0 -27
@@ -33,7 +33,6 @@ import {
33
33
  import { getConfig } from "../config/loader.js";
34
34
  import type { LLMCallSite } from "../config/schemas/llm.js";
35
35
  import type { ContextWindowConfig } from "../config/types.js";
36
- import { runEmergencyCompaction } from "../context/compactor.js";
37
36
  import {
38
37
  derefToolResultReReads,
39
38
  postTurnTruncateToolResults,
@@ -1221,22 +1220,15 @@ export async function runAgentLoopImpl(
1221
1220
  // compressing history. Falls through to reducer tiers on failure.
1222
1221
  {
1223
1222
  try {
1224
- const emergencyConfig = getConfig().compaction;
1225
- const emergencyResult = await runEmergencyCompaction({
1226
- conversationId: ctx.conversationId,
1227
- messages: ctx.messages,
1228
- provider: ctx.provider,
1229
- systemPrompt: ctx.systemPrompt,
1230
- tools: undefined,
1231
- compaction: emergencyConfig,
1232
- maxInputTokens: resolveCurrentMaxInputTokens(),
1233
- previousEstimatedInputTokens: estimatedTokensAtOverflow,
1234
- force: true,
1235
- signal: abortController.signal,
1236
- overrideProfile: resolveCurrentOverrideProfile() ?? null,
1237
- nonPersistedPrefixCount:
1238
- ctx.contextWindowManager.nonPersistedPrefixCount,
1239
- });
1223
+ const emergencyResult =
1224
+ await ctx.contextWindowManager.emergencyCompact(
1225
+ ctx.messages,
1226
+ {
1227
+ previousEstimatedInputTokens: estimatedTokensAtOverflow,
1228
+ overrideProfile: resolveCurrentOverrideProfile() ?? null,
1229
+ },
1230
+ abortController.signal,
1231
+ );
1240
1232
  if (emergencyResult.compacted) {
1241
1233
  rlog.info(
1242
1234
  {
@@ -65,6 +65,7 @@ import { getConfig } from "../config/loader.js";
65
65
  import type { AssistantConfig } from "../config/schema.js";
66
66
  import { HOOKS } from "../plugin-api/constants.js";
67
67
  import { registerDefaultPlugins } from "../plugins/defaults/index.js";
68
+ import { installPluginRuntime } from "../plugins/external-api.js";
68
69
  import { buildExternalPlugin } from "../plugins/external-plugin-loader.js";
69
70
  import {
70
71
  registerPluginSkills,
@@ -186,17 +187,21 @@ function getDisabledPluginFlag(
186
187
  * Bring the plugin layer up during daemon startup. Runs the full sequence in
187
188
  * the one order the rest of the system depends on:
188
189
  *
189
- * 1. Register the first-party defaults so their middleware composes innermost,
190
+ * 1. Install the `globalThis.__vellumPluginRuntime` bridge so plugins can touch
191
+ * it from their module body (see `plugins/external-api.ts` — compiled-binary
192
+ * module identity).
193
+ * 2. Register the first-party defaults so their middleware composes innermost,
190
194
  * ahead of any user plugins.
191
- * 2. Load user plugins from `<workspaceDir>/plugins/*`. A failing user plugin is
195
+ * 3. Load user plugins from `<workspaceDir>/plugins/*`. A failing user plugin is
192
196
  * logged and skipped; `loadUserPlugins()` closes the registration window
193
197
  * when it returns, so the defaults must already be registered by then.
194
- * 3. Run every registered plugin's `init()` via {@link bootstrapPlugins}.
198
+ * 4. Run every registered plugin's `init()` via {@link bootstrapPlugins}.
195
199
  *
196
200
  * Plugin bootstrap is wrapped so a failing plugin cannot block daemon startup —
197
201
  * the daemon comes up with degraded plugin functionality instead.
198
202
  */
199
203
  export async function initializePlugins(): Promise<void> {
204
+ installPluginRuntime();
200
205
  registerDefaultPlugins();
201
206
  await loadUserPlugins();
202
207
  try {
@@ -155,6 +155,21 @@ registerHook("app_refresh", (_name, input, _result, { ctx }) => {
155
155
  notifyAppChanged(ctx, appId, { fileChange: true });
156
156
  });
157
157
 
158
+ // app_update compiles internally (like app_refresh) but emits no events of its
159
+ // own, so without this hook an updated app leaves open surfaces rendering the
160
+ // stale dist and never re-deploys or invalidates the Library. The executor owns
161
+ // the compile; notifyAppChanged only refreshes surfaces and broadcasts.
162
+ registerHook("app_update", (_name, input, _result, { ctx }) => {
163
+ const appId = input.app_id as string | undefined;
164
+ if (!appId) return;
165
+ try {
166
+ addAppConversationId(appId, ctx.conversationId);
167
+ } catch (err) {
168
+ log.warn({ err, appId }, "Failed to track conversation ID on app_update");
169
+ }
170
+ notifyAppChanged(ctx, appId, { fileChange: true });
171
+ });
172
+
158
173
  registerHook("voice_config_update", (_name, input) => {
159
174
  const setting = input.setting as string | undefined;
160
175
  if (!setting) return;
@@ -105,43 +105,15 @@ mock.module("../../jobs-store.js", () => ({
105
105
  isMemoryEnabled: () => true,
106
106
  }));
107
107
 
108
- // ── v3 health-injection mocks ───────────────────────────────────────
108
+ // ── v3 follow-up flag mock ──────────────────────────────────────────
109
109
  //
110
- // The consolidation handler optionally prepends a v3 health block to the
111
- // prompt when a v3 flag is on AND the rendered report is non-empty. These
112
- // mocks let each test (a) toggle the flag, (b) decide what health block the
113
- // renderer produces, and (c) force the health computation to throw — without
114
- // materializing a real v3 data dir. The tree/core/page-index loaders are
115
- // stubbed to inert values; `computeV3Health` is a pass-through whose result is
116
- // fed to the toggleable `renderV3Health`.
110
+ // A v3 flag being on appends `memory_v3_maintain` to the post-consolidation
111
+ // follow-up fan-out. This mock lets each test toggle the flag.
117
112
  let v3FlagOn = false;
118
113
  mock.module("../../../config/assistant-feature-flags.js", () => ({
119
114
  isAssistantFeatureFlagEnabled: () => v3FlagOn,
120
115
  }));
121
116
 
122
- let renderedHealth = "";
123
- let computeThrows = false;
124
- mock.module("../../../plugins/defaults/memory-v3-shadow/health.js", () => ({
125
- computeV3Health: () => {
126
- if (computeThrows) throw new Error("simulated health compute failure");
127
- return {};
128
- },
129
- renderV3Health: () => renderedHealth,
130
- }));
131
-
132
- mock.module("../../../plugins/defaults/memory-v3-shadow/tree.js", () => ({
133
- loadLeafTree: async () => ({ leaves: new Map(), byPage: new Map() }),
134
- resolveDataDir: () => "/tmp/v3-data-stub",
135
- }));
136
-
137
- mock.module("../../../plugins/defaults/memory-v3-shadow/core.js", () => ({
138
- loadCore: async () => new Set<string>(),
139
- }));
140
-
141
- mock.module("../../v2/page-index.js", () => ({
142
- getPageIndex: async () => ({ entries: [] }),
143
- }));
144
-
145
117
  // ── Workspace pin ───────────────────────────────────────────────────
146
118
  let tmpWorkspace: string;
147
119
  let previousWorkspaceEnv: string | undefined;
@@ -211,10 +183,8 @@ beforeEach(() => {
211
183
  enqueuedJobs.length = 0;
212
184
  nextJobIdCounter = 0;
213
185
 
214
- // v3 health injection defaults: flag off, no rendered block, no throw.
186
+ // v3 follow-up flag default: off.
215
187
  v3FlagOn = false;
216
- renderedHealth = "";
217
- computeThrows = false;
218
188
  });
219
189
 
220
190
  // ---------------------------------------------------------------------------
@@ -409,69 +379,6 @@ describe("memoryV2ConsolidateJob — non-empty buffer", () => {
409
379
  });
410
380
  });
411
381
 
412
- describe("memoryV2ConsolidateJob — v3 health injection", () => {
413
- beforeEach(() => {
414
- writeFileSync(
415
- bufferPath(),
416
- "- [Apr 27, 9:00 AM] Alice prefers VS Code over Vim.\n",
417
- );
418
- });
419
-
420
- test("prepends the health block when a v3 flag is on and the report is actionable", async () => {
421
- v3FlagOn = true;
422
- renderedHealth = "memory-v3 health:\n- 2 unassigned slug(s): a, b";
423
-
424
- const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
425
-
426
- expect(result.kind).toBe("invoked");
427
- const prompt = runnerLastArgs?.prompt as string;
428
- // The health block is PREPENDED, separated by a blank line, with the
429
- // base consolidation prompt still present underneath it.
430
- expect(prompt.startsWith(`${renderedHealth}\n\n`)).toBe(true);
431
- expect(prompt).toContain("memory consolidation");
432
- expect(prompt).not.toContain(CUTOFF_PLACEHOLDER);
433
- });
434
-
435
- test("leaves the prompt unchanged when the report is all-green (empty render)", async () => {
436
- v3FlagOn = true;
437
- renderedHealth = "";
438
-
439
- const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
440
-
441
- expect(result.kind).toBe("invoked");
442
- const prompt = runnerLastArgs?.prompt as string;
443
- expect(prompt).not.toContain("memory-v3 health:");
444
- // Prompt is exactly the resolved consolidation body.
445
- expect(prompt).toContain("memory consolidation");
446
- });
447
-
448
- test("leaves the prompt unchanged when v3 flags are off even if a block would render", async () => {
449
- v3FlagOn = false;
450
- renderedHealth = "memory-v3 health:\n- 1 unassigned slug(s): a";
451
-
452
- const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
453
-
454
- expect(result.kind).toBe("invoked");
455
- const prompt = runnerLastArgs?.prompt as string;
456
- expect(prompt).not.toContain("memory-v3 health:");
457
- expect(prompt).toContain("memory consolidation");
458
- });
459
-
460
- test("falls back to the base prompt when health computation throws", async () => {
461
- v3FlagOn = true;
462
- computeThrows = true;
463
- renderedHealth = "memory-v3 health:\n- should not appear";
464
-
465
- const result = await memoryV2ConsolidateJob(makeJob(), CONFIG);
466
-
467
- // A health-compute failure must NEVER break consolidation.
468
- expect(result.kind).toBe("invoked");
469
- const prompt = runnerLastArgs?.prompt as string;
470
- expect(prompt).not.toContain("memory-v3 health:");
471
- expect(prompt).toContain("memory consolidation");
472
- });
473
- });
474
-
475
382
  describe("memoryV2ConsolidateJob — concurrent invocations", () => {
476
383
  beforeEach(() => {
477
384
  writeFileSync(bufferPath(), "- [Apr 27, 9:00 AM] Alice prefers VS Code.\n");
@@ -186,6 +186,28 @@ describe("writePage + readPage round-trip", () => {
186
186
  expect(read!.body).toBe(page.body);
187
187
  });
188
188
 
189
+ test("round-trips authored links: frontmatter (memory-v3 edge lane)", async () => {
190
+ const page = makePage({
191
+ frontmatter: {
192
+ edges: [],
193
+ ref_files: [],
194
+ ref_urls: [],
195
+ links: ["bob-handoff — the partner this hands off to"],
196
+ },
197
+ });
198
+ await writePage(workspaceDir, page);
199
+
200
+ // A page using the optional `links:` frontmatter must NOT be rejected by the
201
+ // strict schema (it would otherwise be dropped from memory-v3 entirely), and
202
+ // the curated links must survive the render → read round-trip so the edge
203
+ // graph can parse them back from the rendered frontmatter.
204
+ const read = await readPage(workspaceDir, page.slug);
205
+ expect(read).not.toBeNull();
206
+ expect(read!.frontmatter.links).toEqual([
207
+ "bob-handoff — the partner this hands off to",
208
+ ]);
209
+ });
210
+
189
211
  test("renders frontmatter at the top with --- delimiters", async () => {
190
212
  const page = makePage();
191
213
  await writePage(workspaceDir, page);
@@ -63,19 +63,6 @@ import { dirname, join } from "node:path";
63
63
 
64
64
  import { isAssistantFeatureFlagEnabled } from "../../config/assistant-feature-flags.js";
65
65
  import type { AssistantConfig } from "../../config/types.js";
66
- import { loadCore } from "../../plugins/defaults/memory-v3-shadow/core.js";
67
- import {
68
- computeV3Health,
69
- renderV3Health,
70
- } from "../../plugins/defaults/memory-v3-shadow/health.js";
71
- import {
72
- loadLeafTree,
73
- resolveDataDir,
74
- } from "../../plugins/defaults/memory-v3-shadow/tree.js";
75
- import type {
76
- LeafPath,
77
- Slug,
78
- } from "../../plugins/defaults/memory-v3-shadow/types.js";
79
66
  import { runBackgroundJob } from "../../runtime/background-job-runner.js";
80
67
  import { getLogger } from "../../util/logger.js";
81
68
  import { getWorkspaceDir } from "../../util/platform.js";
@@ -86,7 +73,6 @@ import {
86
73
  type MemoryJob,
87
74
  type MemoryJobType,
88
75
  } from "../jobs-store.js";
89
- import { getPageIndex } from "../v2/page-index.js";
90
76
  import { MEMORY_V2_CONSOLIDATION_SOURCE } from "./constants.js";
91
77
  import { resolveConsolidationPrompt } from "./prompts/consolidation.js";
92
78
 
@@ -96,8 +82,7 @@ const log = getLogger("memory-v2-consolidate");
96
82
  const JOB_NAME = "memory.consolidate";
97
83
 
98
84
  /**
99
- * v3 plugin flags. Either being on (a) prepends a v3 health block to the
100
- * consolidation prompt and (b) enqueues `memory_v3_maintain` as a
85
+ * v3 plugin flags. Either being on enqueues `memory_v3_maintain` as a
101
86
  * post-consolidation follow-up. These gate the v3 plugin itself.
102
87
  */
103
88
  const MEMORY_V3_SHADOW = "memory-v3-shadow" as const;
@@ -191,15 +176,10 @@ export async function memoryV2ConsolidateJob(
191
176
  // it to a regular file under 1 MiB before substitution so a stray path
192
177
  // (or a `/dev/zero`-style pseudo-file) cannot exfiltrate megabytes of
193
178
  // bytes through the wake hint.
194
- //
195
- // That resolved prompt is the base; `maybePrependV3Health` prepends a
196
- // freshly computed v3 health block when a v3 flag is on and the tree has
197
- // actionable drift, leaving it untouched otherwise.
198
- const basePrompt = resolveConsolidationPrompt(
179
+ const prompt = resolveConsolidationPrompt(
199
180
  config.memory.v2.consolidation_prompt_path,
200
181
  cutoff,
201
182
  );
202
- const prompt = await maybePrependV3Health(basePrompt, config);
203
183
 
204
184
  const runResult = await runBackgroundJob({
205
185
  jobName: JOB_NAME,
@@ -270,56 +250,6 @@ export async function memoryV2ConsolidateJob(
270
250
  }
271
251
  }
272
252
 
273
- /**
274
- * When a v3 flag is enabled and the v3 tree has actionable structural drift,
275
- * prepend a rendered health block to `basePrompt` so the consolidation run can
276
- * fold tree maintenance into its pass. Returns `basePrompt` UNCHANGED when:
277
- * - neither `memory-v3-shadow` nor `memory-v3-live` is enabled,
278
- * - the health report is all-green (`renderV3Health` returns ""), or
279
- * - computing the report throws for any reason.
280
- *
281
- * The block is computed here (not baked into the prompt template) so an
282
- * operator's custom consolidation prompt stays untouched and the block reflects
283
- * the tree state at run time. Health computation is wrapped so a load/compute
284
- * failure can never break consolidation — the run proceeds on the base prompt.
285
- */
286
- async function maybePrependV3Health(
287
- basePrompt: string,
288
- config: AssistantConfig,
289
- ): Promise<string> {
290
- const v3Enabled =
291
- isAssistantFeatureFlagEnabled(MEMORY_V3_SHADOW, config) ||
292
- isAssistantFeatureFlagEnabled(MEMORY_V3_LIVE, config);
293
- if (!v3Enabled) return basePrompt;
294
-
295
- try {
296
- const pageIndex = await getPageIndex(getWorkspaceDir());
297
- const pageLeaves = new Map<Slug, LeafPath[]>();
298
- const allSlugs: Slug[] = [];
299
- for (const entry of pageIndex.entries) {
300
- pageLeaves.set(entry.slug, entry.leaves);
301
- allSlugs.push(entry.slug);
302
- }
303
-
304
- const dataDir = resolveDataDir();
305
- const [tree, core] = await Promise.all([
306
- loadLeafTree(dataDir, pageLeaves),
307
- loadCore(dataDir),
308
- ]);
309
-
310
- const report = computeV3Health({ tree, allSlugs, core });
311
- const healthBlock = renderV3Health(report);
312
- if (healthBlock.length === 0) return basePrompt;
313
- return `${healthBlock}\n\n${basePrompt}`;
314
- } catch (err) {
315
- log.warn(
316
- { err: err instanceof Error ? err.message : String(err) },
317
- "consolidation: v3 health computation failed; using base prompt",
318
- );
319
- return basePrompt;
320
- }
321
- }
322
-
323
253
  /**
324
254
  * Read `memory/buffer.md`. Missing file → empty string so the skip-on-empty
325
255
  * branch doesn't have to distinguish "no file" from "blank file".
@@ -41,6 +41,11 @@ export const ConceptPageFrontmatterSchema = z
41
41
  ref_urls: z.array(z.string().url()).default([]),
42
42
  summary: z.string().optional(),
43
43
  leaves: z.array(z.string()).optional(),
44
+ // Optional authored `"<target-slug> — <why>"` cross-links. Curated, first-class
45
+ // edges for the memory-v3 edge lane. Declared here so `.strict()` does not
46
+ // reject a page that uses `links:`, and so `renderPageContent` round-trips the
47
+ // field (the edge graph reads it back from the rendered frontmatter).
48
+ links: z.array(z.string()).optional(),
44
49
  })
45
50
  .strict();
46
51
 
@@ -20,7 +20,9 @@ import type { LLMCallSite } from "../../../config/schemas/llm.js";
20
20
  import type { ContextWindowConfig } from "../../../config/types.js";
21
21
  import {
22
22
  type CompactionRunArgs,
23
+ type CompactionRunResult,
23
24
  runAssistantDrivenCompaction,
25
+ runEmergencyCompaction,
24
26
  } from "../../../context/compactor.js";
25
27
  import { estimatePromptTokens } from "../../../context/token-estimator.js";
26
28
  import type {
@@ -118,6 +120,21 @@ export interface ContextWindowCompactOptions {
118
120
  actorTrustClass?: TrustClass;
119
121
  }
120
122
 
123
+ export interface EmergencyCompactOptions {
124
+ /**
125
+ * The provider-reported estimate at the overflow that triggered recovery.
126
+ * Sizing the emergency summary needs the actual rejected token count, not
127
+ * the manager's pre-send estimate (which under-counted, hence the
128
+ * rejection).
129
+ */
130
+ previousEstimatedInputTokens: number;
131
+ /**
132
+ * Per-conversation inference-profile override forwarded to the summary
133
+ * call.
134
+ */
135
+ overrideProfile?: string | null;
136
+ }
137
+
121
138
  export interface ContextWindowManagerOptions {
122
139
  provider: Provider;
123
140
  systemPrompt: string | (() => string);
@@ -428,6 +445,45 @@ export class ContextWindowManager {
428
445
  return { ...result, estimatedInputTokens, exhausted: true };
429
446
  }
430
447
 
448
+ /**
449
+ * Emergency mid-turn compaction — summarize everything before the last
450
+ * tool_use/tool_result pair and let the agent continue with
451
+ * `[summary, last_tool_call, last_tool_result]`. Used as a recovery rung
452
+ * after the provider rejects a turn for context overflow. The manager
453
+ * supplies the provider, system prompt, token budget, conversation id, and
454
+ * non-persisted prefix count it already owns; the caller provides only the
455
+ * overflow-specific inputs.
456
+ */
457
+ async emergencyCompact(
458
+ messages: Message[],
459
+ options: EmergencyCompactOptions,
460
+ signal?: AbortSignal,
461
+ ): Promise<CompactionRunResult> {
462
+ if (this.conversationId == null) {
463
+ throw new Error(
464
+ "ContextWindowManager has no conversationId — cannot run emergency compaction",
465
+ );
466
+ }
467
+ try {
468
+ return await runEmergencyCompaction({
469
+ conversationId: this.conversationId,
470
+ messages,
471
+ provider: this.provider,
472
+ systemPrompt: this.systemPrompt,
473
+ tools: undefined,
474
+ compaction: this.resolveCompactionConfig(),
475
+ maxInputTokens: this.config.maxInputTokens,
476
+ previousEstimatedInputTokens: options.previousEstimatedInputTokens,
477
+ force: true,
478
+ signal,
479
+ overrideProfile: options.overrideProfile ?? null,
480
+ nonPersistedPrefixCount: this._nonPersistedPrefixCount,
481
+ });
482
+ } finally {
483
+ this.clearSystemPromptCache();
484
+ }
485
+ }
486
+
431
487
  /**
432
488
  * Recompute the prompt-token estimate against the post-compaction
433
489
  * message array. The compactor returns a conservative placeholder; the
@@ -1,18 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
- CAPABILITIES_LEAF_PATH,
4
+ capabilityOrDiskBody,
5
5
  type CapabilityResolvers,
6
- injectCapabilitiesLeaf,
7
6
  isCapabilitySlug,
8
7
  renderCapabilityContent,
9
8
  } from "../capabilities.js";
10
- import { buildNeedleIndex } from "../needle.js";
11
- import type { LeafPath, LeafTree, Slug } from "../types.js";
12
-
13
- function emptyTree(): LeafTree {
14
- return { leaves: new Map(), byPage: new Map<Slug, LeafPath[]>() };
15
- }
16
9
 
17
10
  describe("isCapabilitySlug", () => {
18
11
  test("true for skill and CLI-command slugs, false otherwise", () => {
@@ -23,52 +16,6 @@ describe("isCapabilitySlug", () => {
23
16
  });
24
17
  });
25
18
 
26
- describe("injectCapabilitiesLeaf", () => {
27
- test("registers an always-on leaf with the synthetic slugs as members", () => {
28
- const tree = emptyTree();
29
- const core = new Set<LeafPath>();
30
- injectCapabilitiesLeaf(tree, core, ["skills/foo", "cli-commands/bar"]);
31
-
32
- const leaf = tree.leaves.get(CAPABILITIES_LEAF_PATH);
33
- expect(leaf?.frontmatter.in_core).toBe(true);
34
- expect(leaf?.members).toEqual(["skills/foo", "cli-commands/bar"]);
35
- expect(core.has(CAPABILITIES_LEAF_PATH)).toBe(true);
36
- });
37
-
38
- test("unions the leaf into each member's byPage entry without dropping existing leaves", () => {
39
- const tree = emptyTree();
40
- tree.byPage.set("skills/foo", ["other/leaf"]);
41
- injectCapabilitiesLeaf(tree, new Set<LeafPath>(), [
42
- "skills/foo",
43
- "cli-commands/bar",
44
- ]);
45
-
46
- expect(tree.byPage.get("skills/foo")).toEqual([
47
- "other/leaf",
48
- CAPABILITIES_LEAF_PATH,
49
- ]);
50
- expect(tree.byPage.get("cli-commands/bar")).toEqual([
51
- CAPABILITIES_LEAF_PATH,
52
- ]);
53
- });
54
-
55
- test("is idempotent — re-injecting does not duplicate the byPage entry", () => {
56
- const tree = emptyTree();
57
- const core = new Set<LeafPath>();
58
- injectCapabilitiesLeaf(tree, core, ["skills/foo"]);
59
- injectCapabilitiesLeaf(tree, core, ["skills/foo"]);
60
- expect(tree.byPage.get("skills/foo")).toEqual([CAPABILITIES_LEAF_PATH]);
61
- });
62
-
63
- test("stays always-on even with no installed skills/commands", () => {
64
- const tree = emptyTree();
65
- const core = new Set<LeafPath>();
66
- injectCapabilitiesLeaf(tree, core, []);
67
- expect(tree.leaves.get(CAPABILITIES_LEAF_PATH)?.members).toEqual([]);
68
- expect(core.has(CAPABILITIES_LEAF_PATH)).toBe(true);
69
- });
70
- });
71
-
72
19
  describe("renderCapabilityContent", () => {
73
20
  const resolvers: CapabilityResolvers = {
74
21
  skill: (slug) =>
@@ -100,19 +47,25 @@ describe("renderCapabilityContent", () => {
100
47
  });
101
48
  });
102
49
 
103
- describe("needle indexes injected capability members", () => {
104
- test("a skill slug is lexically retrievable by its title and summary", async () => {
105
- const tree = emptyTree();
106
- injectCapabilitiesLeaf(tree, new Set<LeafPath>(), ["skills/meet-join"]);
50
+ describe("capabilityOrDiskBody", () => {
51
+ test("a capability slug routes through renderCapabilityContent and never reads disk", async () => {
52
+ let diskReads = 0;
53
+ const readDiskBody = async (): Promise<string> => {
54
+ diskReads += 1;
55
+ return "disk body — should not be read";
56
+ };
57
+ // A capability slug whose production cache has no entry degrades to "" (the
58
+ // `renderCapabilityContent` contract) rather than falling through to the
59
+ // injected disk reader, which must stay untouched.
60
+ expect(await capabilityOrDiskBody("skills/example", readDiskBody)).toBe("");
61
+ expect(diskReads).toBe(0);
62
+ });
107
63
 
108
- const needle = await buildNeedleIndex(
109
- tree,
110
- async () => "Join and transcribe a video meeting",
64
+ test("a non-capability slug returns the injected disk reader's result", async () => {
65
+ const readDiskBody = async (slug: string): Promise<string> =>
66
+ `disk body for ${slug}`;
67
+ expect(await capabilityOrDiskBody("relationship/vows", readDiskBody)).toBe(
68
+ "disk body for relationship/vows",
111
69
  );
112
-
113
- // By slug-derived title token.
114
- expect(needle.query("meet", 5)).toContain("skills/meet-join");
115
- // By summary token.
116
- expect(needle.query("transcribe", 5)).toContain("skills/meet-join");
117
70
  });
118
71
  });