@vellumai/assistant 0.9.0 → 0.9.1-staging.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 (222) hide show
  1. package/ARCHITECTURE.md +18 -34
  2. package/bun.lock +7 -8
  3. package/docs/activation-funnel-telemetry.md +4 -4
  4. package/docs/architecture/security.md +29 -28
  5. package/docs/stt-provider-onboarding.md +3 -5
  6. package/docs/workflows-testing.md +13 -44
  7. package/docs/workflows.md +3 -5
  8. package/node_modules/@vellumai/ces-client/src/__tests__/ces-client.test.ts +47 -0
  9. package/node_modules/@vellumai/ces-client/src/rpc-client.ts +28 -5
  10. package/node_modules/@vellumai/environments/src/seeds.ts +2 -5
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +17 -6
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +119 -0
  13. package/node_modules/@vellumai/gateway-client/src/types.ts +15 -84
  14. package/openapi.yaml +135 -59
  15. package/package.json +2 -1
  16. package/scripts/sync-llm-catalog.ts +6 -15
  17. package/scripts/sync-web-search-catalog.ts +3 -11
  18. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +14 -26
  19. package/src/__tests__/agent-loop-compaction-strip.test.ts +240 -0
  20. package/src/__tests__/agent-loop-output-hooks.test.ts +69 -0
  21. package/src/__tests__/agent-loop-override-profile.test.ts +25 -0
  22. package/src/__tests__/always-loaded-tools-guard.test.ts +2 -3
  23. package/src/__tests__/app-dir-path-guard.test.ts +0 -1
  24. package/src/__tests__/assistant-feature-flag-guard.test.ts +1 -4
  25. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +0 -2
  26. package/src/__tests__/avatar-identity-sync.test.ts +2 -27
  27. package/src/__tests__/btw-routes.test.ts +6 -8
  28. package/src/__tests__/checker.test.ts +0 -3
  29. package/src/__tests__/config-loader-backfill.test.ts +103 -6
  30. package/src/__tests__/config-watcher.test.ts +0 -18
  31. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +22 -0
  32. package/src/__tests__/credential-broker.test.ts +449 -1
  33. package/src/__tests__/credential-execution-tools.test.ts +0 -1
  34. package/src/__tests__/credential-prompt-route.test.ts +3 -4
  35. package/src/__tests__/credential-routes.test.ts +360 -0
  36. package/src/__tests__/credential-security-invariants.test.ts +2 -13
  37. package/src/__tests__/dynamic-page-surface.test.ts +101 -1
  38. package/src/__tests__/fixtures/credential-security-fixtures.ts +2 -33
  39. package/src/__tests__/gateway-only-guard.test.ts +3 -7
  40. package/src/__tests__/identity-routes.test.ts +0 -189
  41. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  42. package/src/__tests__/invite-redemption-service.test.ts +4 -4
  43. package/src/__tests__/llm-callsite-catalog.test.ts +5 -6
  44. package/src/__tests__/llm-catalog-parity.test.ts +0 -22
  45. package/src/__tests__/llm-resolver.test.ts +49 -24
  46. package/src/__tests__/oauth-provider-seed-logos.test.ts +4 -6
  47. package/src/__tests__/onboarding-persona-write.test.ts +1 -1
  48. package/src/__tests__/persona-resolver.test.ts +11 -14
  49. package/src/__tests__/plugin-api-model-profiles.test.ts +178 -0
  50. package/src/__tests__/registry.test.ts +2 -7
  51. package/src/__tests__/schedule-routes-workflow-validation.test.ts +1 -10
  52. package/src/__tests__/schedule-routes.test.ts +0 -30
  53. package/src/__tests__/schedule-tools.test.ts +2 -18
  54. package/src/__tests__/skill-execute-input.test.ts +46 -1
  55. package/src/__tests__/skill-runtime-path.test.ts +2 -3
  56. package/src/__tests__/subagent-tools.test.ts +116 -0
  57. package/src/__tests__/surface-completion-nudge-hook.test.ts +367 -0
  58. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -29
  59. package/src/__tests__/token-manager.test.ts +519 -0
  60. package/src/__tests__/tool-executor.test.ts +0 -79
  61. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  62. package/src/__tests__/trusted-contact-verification.test.ts +6 -6
  63. package/src/__tests__/voice-invite-redemption.test.ts +2 -2
  64. package/src/__tests__/web-search-catalog-parity.test.ts +6 -25
  65. package/src/__tests__/workspace-greetings.test.ts +152 -0
  66. package/src/agent/loop.ts +25 -5
  67. package/src/api/README.md +6 -6
  68. package/src/api/responses/conversation-message.ts +2 -4
  69. package/src/api/responses/home.ts +0 -4
  70. package/src/approvals/guardian-request-resolvers.ts +2 -2
  71. package/src/calls/relay-access-wait.ts +1 -1
  72. package/src/calls/voice-session-bridge.ts +2 -2
  73. package/src/cli/commands/plugins.ts +143 -2
  74. package/src/cli/lib/__tests__/diff-plugin.test.ts +443 -0
  75. package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +313 -0
  76. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +253 -2
  77. package/src/cli/lib/diff-plugin.ts +346 -0
  78. package/src/cli/lib/install-from-github.ts +105 -17
  79. package/src/cli/lib/merge-plugin-tree.ts +228 -0
  80. package/src/cli/lib/plugin-fingerprint.ts +14 -0
  81. package/src/cli/lib/upgrade-plugin.ts +270 -10
  82. package/src/cli/program.ts +0 -2
  83. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  84. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  85. package/src/config/bundled-skills/workflows/SKILL.md +0 -1
  86. package/src/config/bundled-tool-registry.ts +2 -7
  87. package/src/config/call-site-defaults.ts +12 -2
  88. package/src/config/feature-flag-registry.json +1 -17
  89. package/src/config/inference-profile-validation.ts +26 -0
  90. package/src/config/loader.ts +4 -0
  91. package/src/config/profile-order.ts +28 -0
  92. package/src/config/schemas/elevenlabs.ts +0 -1
  93. package/src/config/schemas/platform.ts +0 -8
  94. package/src/config/seed-inference-profiles.ts +25 -20
  95. package/src/contacts/contact-store.ts +87 -96
  96. package/src/contacts/contacts-write.ts +5 -21
  97. package/src/context/compactor.ts +2 -2
  98. package/src/credential-execution/process-manager.ts +55 -14
  99. package/src/credential-execution/prompted-credential.ts +2 -3
  100. package/src/daemon/config-watcher.ts +0 -4
  101. package/src/daemon/conversation-agent-loop.ts +15 -4
  102. package/src/daemon/conversation-slash.ts +2 -23
  103. package/src/daemon/conversation-tool-setup.ts +11 -3
  104. package/src/daemon/conversation.ts +2 -0
  105. package/src/daemon/handlers/config-channels.ts +20 -16
  106. package/src/daemon/handlers/config-slack-channel.ts +2 -3
  107. package/src/daemon/lifecycle.ts +0 -7
  108. package/src/daemon/message-types/conversations.ts +3 -3
  109. package/src/daemon/message-types/sync.ts +0 -1
  110. package/src/daemon/orphan-reaper.test.ts +0 -19
  111. package/src/daemon/orphan-reaper.ts +2 -24
  112. package/src/daemon/server.ts +0 -10
  113. package/src/home/relationship-state.ts +2 -4
  114. package/src/memory/__tests__/memory-retrospective-job.test.ts +195 -401
  115. package/src/memory/bookmark-crud.ts +1 -2
  116. package/src/memory/db-init.ts +7 -17
  117. package/src/memory/embedding-backend.ts +23 -0
  118. package/src/memory/embedding-billing-breaker.ts +96 -0
  119. package/src/memory/jobs-store.ts +25 -13
  120. package/src/memory/jobs-worker.ts +52 -0
  121. package/src/memory/memory-retrospective-constants.ts +4 -4
  122. package/src/memory/memory-retrospective-job.ts +19 -227
  123. package/src/memory/migrations/291-contact-channels-renormalize-addresses.ts +62 -0
  124. package/src/memory/migrations/__tests__/291-contact-channels-renormalize-addresses.test.ts +311 -0
  125. package/src/memory/migrations/__tests__/run-migrations.test.ts +52 -0
  126. package/src/memory/migrations/index.ts +1 -0
  127. package/src/memory/migrations/run-migrations.ts +41 -0
  128. package/src/memory/migrations/validate-migration-state.ts +1 -1
  129. package/src/memory/schema/contacts.ts +0 -4
  130. package/src/messaging/providers/slack/adapter.ts +1 -1
  131. package/src/notifications/adapters/shared.ts +29 -0
  132. package/src/notifications/adapters/slack.ts +5 -32
  133. package/src/notifications/adapters/telegram.ts +2 -20
  134. package/src/notifications/broadcaster.ts +10 -1
  135. package/src/notifications/home-feed-side-effect.ts +4 -3
  136. package/src/notifications/notification-utils.ts +17 -19
  137. package/src/notifications/types.ts +7 -0
  138. package/src/oauth/AGENTS.md +5 -24
  139. package/src/plugin-api/constants.ts +1 -1
  140. package/src/plugin-api/index.ts +6 -1
  141. package/src/plugin-api/model-profiles.ts +33 -0
  142. package/src/plugin-api/types.ts +50 -2
  143. package/src/plugins/defaults/index.ts +25 -0
  144. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +54 -2
  145. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +107 -7
  146. package/src/plugins/defaults/surface-completion-nudge/hooks/post-model-call.ts +276 -0
  147. package/src/plugins/defaults/surface-completion-nudge/hooks/stop.ts +22 -0
  148. package/src/plugins/defaults/surface-completion-nudge/nudge-state-store.ts +46 -0
  149. package/src/plugins/defaults/surface-completion-nudge/package.json +14 -0
  150. package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +1 -1
  151. package/src/prompts/persona-resolver.ts +2 -2
  152. package/src/runtime/AGENTS.md +0 -1
  153. package/src/runtime/actor-trust-resolver.ts +12 -44
  154. package/src/runtime/btw-sidechain.ts +3 -6
  155. package/src/runtime/channel-approval-types.ts +18 -45
  156. package/src/runtime/channel-invite-transports/telegram.ts +4 -4
  157. package/src/runtime/channel-verification-service.ts +4 -3
  158. package/src/runtime/invite-redemption-service.ts +3 -3
  159. package/src/runtime/routes/__tests__/plugins-routes.test.ts +218 -1
  160. package/src/runtime/routes/app-routes.ts +1 -1
  161. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +2 -2
  162. package/src/runtime/routes/assets/vellum-design-system.css +1959 -0
  163. package/src/runtime/routes/btw-routes.ts +1 -27
  164. package/src/runtime/routes/conversation-compaction-routes.ts +1 -1
  165. package/src/runtime/routes/conversation-routes.ts +2 -2
  166. package/src/runtime/routes/credential-routes.ts +40 -16
  167. package/src/runtime/routes/empty-state-greeting-cache.ts +1 -2
  168. package/src/runtime/routes/identity-routes.ts +1 -296
  169. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +1 -1
  170. package/src/runtime/routes/plugins-routes.ts +171 -5
  171. package/src/runtime/routes/schedule-routes.ts +0 -22
  172. package/src/runtime/routes/workflow-routes.test.ts +4 -43
  173. package/src/runtime/routes/workflow-routes.ts +0 -28
  174. package/src/runtime/routes/workspace-greetings.ts +55 -0
  175. package/src/runtime/sync/resource-sync-events.ts +1 -11
  176. package/src/schedule/inference-profile.ts +2 -14
  177. package/src/subagent/manager.ts +6 -0
  178. package/src/subagent/types.ts +6 -0
  179. package/src/tools/AGENTS.md +3 -3
  180. package/src/tools/browser/browser-execution.ts +1 -1
  181. package/src/tools/network/web-search-error.ts +1 -1
  182. package/src/tools/permission-checker.ts +1 -1
  183. package/src/tools/schedule/create.ts +3 -9
  184. package/src/tools/schedule/update.ts +2 -10
  185. package/src/tools/side-effects.ts +2 -17
  186. package/src/tools/skills/execute.ts +34 -0
  187. package/src/tools/subagent/spawn.ts +34 -9
  188. package/src/tools/tool-approval-handler.ts +1 -3
  189. package/src/tools/tool-manifest.ts +0 -2
  190. package/src/tools/ui-surface/definitions.ts +39 -1
  191. package/src/tools/workflows/run-workflow.test.ts +8 -18
  192. package/src/util/platform.ts +2 -2
  193. package/src/workflows/capabilities.ts +2 -3
  194. package/src/workflows/run-manager.test.ts +0 -25
  195. package/src/workflows/run-manager.ts +2 -24
  196. package/src/__tests__/app-control-no-global-cgevent.test.ts +0 -98
  197. package/src/__tests__/credential-security-e2e.test.ts +0 -362
  198. package/src/__tests__/credential-vault-unit.test.ts +0 -1528
  199. package/src/__tests__/credential-vault.test.ts +0 -1706
  200. package/src/__tests__/identity-intro-cache.test.ts +0 -315
  201. package/src/__tests__/secret-onetime-send.test.ts +0 -182
  202. package/src/cli/commands/__tests__/task.test.ts +0 -914
  203. package/src/cli/commands/task.ts +0 -771
  204. package/src/config/bundled-skills/personal-page/SKILL.md +0 -57
  205. package/src/config/bundled-skills/personal-page/TOOLS.json +0 -27
  206. package/src/config/bundled-skills/personal-page/tools/app-refresh.ts +0 -17
  207. package/src/config/preloaded-apps/personal-page/src/components/About.tsx +0 -22
  208. package/src/config/preloaded-apps/personal-page/src/components/App.tsx +0 -16
  209. package/src/config/preloaded-apps/personal-page/src/components/Features.tsx +0 -77
  210. package/src/config/preloaded-apps/personal-page/src/components/Hero.tsx +0 -57
  211. package/src/config/preloaded-apps/personal-page/src/components/Pending.tsx +0 -28
  212. package/src/config/preloaded-apps/personal-page/src/components/animations.tsx +0 -234
  213. package/src/config/preloaded-apps/personal-page/src/components/icons.tsx +0 -48
  214. package/src/config/preloaded-apps/personal-page/src/components/media.ts +0 -16
  215. package/src/config/preloaded-apps/personal-page/src/index.html +0 -20
  216. package/src/config/preloaded-apps/personal-page/src/main.tsx +0 -7
  217. package/src/config/preloaded-apps/personal-page/src/profile-data.ts +0 -82
  218. package/src/config/preloaded-apps/personal-page/src/styles.css +0 -759
  219. package/src/memory/__tests__/preloaded-apps.test.ts +0 -85
  220. package/src/memory/preloaded-apps.ts +0 -116
  221. package/src/runtime/routes/identity-intro-cache.ts +0 -172
  222. package/src/tools/credentials/vault.ts +0 -712
@@ -14,7 +14,10 @@ import { v4 as uuid } from "uuid";
14
14
  import { getConversation } from "../memory/conversation-crud.js";
15
15
  import type { ApprovalUIMetadata } from "../runtime/channel-approval-types.js";
16
16
  import { getLogger } from "../util/logger.js";
17
- import { buildAccessRequestContractText } from "./access-request-copy.js";
17
+ import {
18
+ buildAccessRequestContractText,
19
+ parseAccessRequestPayload,
20
+ } from "./access-request-copy.js";
18
21
  import { isGuardianSensitiveEvent } from "./adapters/macos.js";
19
22
  import { pairDeliveryWithConversation } from "./conversation-pairing.js";
20
23
  import { composeFallbackCopy } from "./copy-composer.js";
@@ -181,6 +184,11 @@ export class NotificationBroadcaster {
181
184
  > | null = null;
182
185
 
183
186
  const approvalContext = resolveApprovalContext(signal);
187
+ const accessRequestContext =
188
+ signal.sourceEventName === "ingress.access_request" &&
189
+ signal.contextPayload
190
+ ? parseAccessRequestPayload(signal.contextPayload)
191
+ : undefined;
184
192
  const results: NotificationDeliveryResult[] = [];
185
193
 
186
194
  for (const channel of orderedChannels) {
@@ -411,6 +419,7 @@ export class NotificationBroadcaster {
411
419
  contextPayload: signal.contextPayload,
412
420
  urgency: signal.attentionHints.urgency,
413
421
  approvalContext,
422
+ accessRequestContext,
414
423
  };
415
424
 
416
425
  // Compute conversation decision audit fields for the delivery record
@@ -217,9 +217,10 @@ function resolveHomeFeedMirror(
217
217
  sourceConversationId?: string;
218
218
  sourceScheduleJobId?: string;
219
219
  } {
220
- let sourceRow:
221
- | { conversationType?: string; scheduleJobId?: string | null }
222
- | null = null;
220
+ let sourceRow: {
221
+ conversationType?: string;
222
+ scheduleJobId?: string | null;
223
+ } | null = null;
223
224
  if (signal.sourceContextId) {
224
225
  try {
225
226
  sourceRow = getConversation(signal.sourceContextId) ?? null;
@@ -29,38 +29,36 @@ export function readPayloadString(
29
29
  return typeof value === "string" ? value : undefined;
30
30
  }
31
31
 
32
+ /** Truncate `text` to `maxLength`, appending "…" when exceeded. */
33
+ export function truncate(text: string, maxLength: number): string {
34
+ if (text.length <= maxLength) return text;
35
+ return text.slice(0, maxLength - 1) + "…";
36
+ }
37
+
32
38
  // ── Sanitization ────────────────────────────────────────────────────────────
33
39
 
34
- const IDENTITY_FIELD_MAX_LENGTH = 120;
40
+ /** Strip control characters and newlines, then truncate to `maxLength`. */
41
+ function sanitize(value: string, maxLength: number): string {
42
+ return truncate(
43
+ value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ").trim(),
44
+ maxLength,
45
+ );
46
+ }
35
47
 
36
48
  /**
37
49
  * Sanitize an untrusted identity field for inclusion in notification copy.
38
- *
39
- * - Strips control characters (U+0000–U+001F, U+007F–U+009F) and newlines.
40
- * - Clamps to 120 characters.
50
+ * Strips control characters and clamps to 120 characters.
41
51
  */
42
52
  export function sanitizeIdentityField(value: string): string {
43
- const stripped = value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ").trim();
44
- const clamped =
45
- stripped.length > IDENTITY_FIELD_MAX_LENGTH
46
- ? stripped.slice(0, IDENTITY_FIELD_MAX_LENGTH) + "…"
47
- : stripped;
48
- return clamped;
53
+ return sanitize(value, 120);
49
54
  }
50
55
 
51
56
  export const MESSAGE_PREVIEW_MAX_LENGTH = 200;
52
57
 
53
58
  /**
54
59
  * Sanitize an untrusted message preview for inclusion in notification copy.
55
- *
56
- * Same as {@link sanitizeIdentityField} but uses a higher length limit
57
- * (200 chars) suited for message previews.
60
+ * Strips control characters and clamps to 200 characters.
58
61
  */
59
62
  export function sanitizeMessagePreview(value: string): string {
60
- const stripped = value.replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ").trim();
61
- const clamped =
62
- stripped.length > MESSAGE_PREVIEW_MAX_LENGTH
63
- ? stripped.slice(0, MESSAGE_PREVIEW_MAX_LENGTH) + "…"
64
- : stripped;
65
- return clamped;
63
+ return sanitize(value, MESSAGE_PREVIEW_MAX_LENGTH);
66
64
  }
@@ -8,6 +8,7 @@
8
8
  import type { ChannelPolicies } from "../channels/config.js";
9
9
  import type { ChannelId } from "../channels/types.js";
10
10
  import type { ApprovalUIMetadata } from "../runtime/channel-approval-types.js";
11
+ import type { ParsedAccessRequestPayload } from "./access-request-copy.js";
11
12
  import type { AttentionHints } from "./signal.js";
12
13
 
13
14
  /**
@@ -103,6 +104,12 @@ export interface ChannelDeliveryPayload {
103
104
  * `contextPayload`.
104
105
  */
105
106
  approvalContext?: ApprovalUIMetadata;
107
+ /**
108
+ * Pre-parsed access request context for `ingress.access_request` signals.
109
+ * Built centrally by the broadcaster so adapters can render structured
110
+ * access-request cards without re-parsing `contextPayload`.
111
+ */
112
+ accessRequestContext?: ParsedAccessRequestPayload;
106
113
  }
107
114
 
108
115
  /**
@@ -29,34 +29,15 @@ Then add the key to `ServicesSchema`:
29
29
 
30
30
  The key here **must** match the `managedServiceConfigKey` in `seed-providers.ts`. The cross-repo invariant test in `__tests__/seed-providers-managed.test.ts` will fail if they drift.
31
31
 
32
- ### 3. _(managed only)_ Enable by default during onboarding — `clients/macos/.../HatchingStepView.swift`
32
+ ### 3. _(managed only)_ Enable by default during onboarding
33
33
 
34
- In `buildOnboardingConfigValues()`, add a line so managed-sign-in users get the integration pre-enabled:
34
+ Managed-sign-in users should get the integration pre-enabled by setting `services.acme-oauth.mode` to `"managed"` in the client's onboarding config defaults.
35
35
 
36
- ```swift
37
- configValues["services.acme-oauth.mode"] = "managed"
38
- ```
39
-
40
- ### 4. Add a cached logo — `clients/shared/Resources/IntegrationLogos/`
41
-
42
- Drop a **vector PDF** named `{provider_key}.pdf` (e.g. `acme.pdf`) into the `IntegrationLogos/` directory. The file is automatically bundled via `.copy()` in `clients/Package.swift` and looked up at runtime by `IntegrationLogoBundle` using the provider key — no code changes needed.
43
-
44
- Most existing logos come from [Simple Icons](https://simpleicons.org) (CC0-licensed). To get a PDF from Simple Icons:
45
-
46
- 1. Find the icon slug on https://simpleicons.org (e.g. `slack`, `linear`).
47
- 2. Download the SVG: `curl -o acme.svg https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/acme.svg`
48
- 3. Convert to PDF using `rsvg-convert` (same tool used by `clients/scripts/sync-lucide-icons.sh`):
49
- ```bash
50
- # Install if needed: brew install librsvg
51
- rsvg-convert -f pdf -o clients/shared/Resources/IntegrationLogos/acme.pdf acme.svg
52
- ```
53
- 4. Add the provider key to `clients/shared/Resources/integration-logos-manifest.json`.
54
-
55
- If the service is not on Simple Icons, source or create an SVG and convert it the same way. The result must be a true vector PDF (not a rasterized image wrapped in PDF) so it scales cleanly.
36
+ ### 4. Set the logo URL — `seed-providers.ts`
56
37
 
57
- The `logoUrl` field in `seed-providers.ts` serves as the remote fallback (most providers use a Simple Icons CDN URL like `https://cdn.simpleicons.org/acme`). The client renders the local PDF first, then falls back to `logoUrl`, then to an initials avatar.
38
+ The `logoUrl` field in `seed-providers.ts` is the source of truth for a provider's logo. Most providers use a [Simple Icons](https://simpleicons.org) (CC0-licensed) CDN URL like `https://cdn.simpleicons.org/acme`. The web client resolves logos from this field (see `clients/web/src/components/integrations/integration-icon.tsx`) and falls back to an initials avatar.
58
39
 
59
- For brands Simple Icons doesn't host (e.g. Salesforce, which Simple Icons removed for trademark reasons), use the same `glincker/thesvg` source via jsDelivr — `https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/<key>/default.svg`. The recognised `logoUrl` prefixes are enforced by `oauth-provider-seed-logos.test.ts`; if you need a third source, extend that allowlist alongside the manifest in `clients/shared/Resources/integration-logos-manifest.json`.
40
+ For brands Simple Icons doesn't host (e.g. Salesforce, which Simple Icons removed for trademark reasons), use the `glincker/thesvg` source via jsDelivr — `https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/<key>/default.svg`. The recognised `logoUrl` prefixes are enforced by `oauth-provider-seed-logos.test.ts`; if you need a third source, extend that allowlist.
60
41
 
61
42
  ### 5. Secret patterns (if applicable) — `../security/secret-patterns.ts`
62
43
 
@@ -20,7 +20,7 @@ export const HOOKS = {
20
20
  SHUTDOWN: "shutdown",
21
21
  /** Fires once per user turn, immediately before the agent loop receives `runMessages`. */
22
22
  USER_PROMPT_SUBMIT: "user-prompt-submit",
23
- /** Fires immediately before each provider call. A hook may edit the outbound request (e.g. the system prompt) and opt the turn into deferred output streaming. */
23
+ /** Fires immediately before each provider call. A hook may edit the outbound request (e.g. the system prompt), route the call to a different inference profile, and opt the turn into deferred output streaming. */
24
24
  PRE_MODEL_CALL: "pre-model-call",
25
25
  /** Fires once per tool result, after the tool returns and before the result is sent to the provider. */
26
26
  POST_TOOL_USE: "post-tool-use",
@@ -32,6 +32,8 @@
32
32
  *
33
33
  * - {@link assistantEventHub} — the assistant's pub/sub hub for runtime events
34
34
  * - {@link getSecureKeyAsync} — read a secret from secure storage
35
+ * - {@link getModelProfiles} — list the workspace inference profiles a plugin
36
+ * can route to (e.g. a model router building its category → profile map)
35
37
  *
36
38
  * - {@link PluginInitContext} — passed to `init` hook at bootstrap
37
39
  * - {@link PluginShutdownContext} — passed to `shutdown` hook at teardown
@@ -40,7 +42,8 @@
40
42
  * - {@link PostCompactContext} — passed to `post-compact` hook, fired after
41
43
  * the agent loop compacts a conversation mid-turn to re-apply injections
42
44
  * - {@link PreModelCallContext} — passed to `pre-model-call` hook, fired
43
- * before each provider call to edit the request / defer output streaming
45
+ * before each provider call to edit the request, route it to a different
46
+ * inference profile, or defer output streaming
44
47
  * - {@link PostToolUseContext} — passed to `post-tool-use` hook, fired once
45
48
  * per tool result before it joins the provider-bound history
46
49
  * - {@link StopContext} — passed to `stop` hook, the definitive terminal hook
@@ -79,6 +82,7 @@ export type {
79
82
  } from "../providers/types.js";
80
83
  export type {
81
84
  AgentLoopExitReason,
85
+ ModelProfileInfo,
82
86
  PluginHookFn,
83
87
  PluginInitContext,
84
88
  PluginLogger,
@@ -110,3 +114,4 @@ export type {
110
114
  } from "../runtime/assistant-event-hub.js";
111
115
  export { assistantEventHub } from "../runtime/assistant-event-hub.js";
112
116
  export { getSecureKeyAsync } from "../security/secure-keys.js";
117
+ export { getModelProfiles } from "./model-profiles.js";
@@ -0,0 +1,33 @@
1
+ import { getConfig } from "../config/loader.js";
2
+ import { orderProfileKeys } from "../config/profile-order.js";
3
+ import type { ModelProfileInfo } from "./types.js";
4
+
5
+ /**
6
+ * List the workspace inference profiles a plugin can route to, in the order the
7
+ * `/model` picker presents them (`llm.profileOrder` first, then the rest
8
+ * alphabetically). Disabled profiles are included and flagged via
9
+ * {@link ModelProfileInfo.isDisabled}; weighted "mix" profiles are included and
10
+ * flagged via {@link ModelProfileInfo.isMix}, since a mix is itself a valid
11
+ * routing target (it resolves to one constituent per conversation).
12
+ *
13
+ * Reads the live in-memory config, so the result reflects the current profile
14
+ * set each time it is called.
15
+ */
16
+ export function getModelProfiles(): ModelProfileInfo[] {
17
+ const { llm } = getConfig();
18
+ const { profiles, activeProfile } = llm;
19
+ const result: ModelProfileInfo[] = [];
20
+ for (const key of orderProfileKeys(profiles, llm.profileOrder)) {
21
+ const entry = profiles[key];
22
+ if (entry == null) continue;
23
+ result.push({
24
+ key,
25
+ label: entry.label ?? key,
26
+ description: entry.description ?? null,
27
+ isActive: key === activeProfile,
28
+ isDisabled: entry.status === "disabled",
29
+ isMix: entry.mix != null,
30
+ });
31
+ }
32
+ return result;
33
+ }
@@ -94,6 +94,35 @@ export interface PluginInitContext {
94
94
  assistantVersion: string;
95
95
  }
96
96
 
97
+ // ─── Model profiles ──────────────────────────────────────────────────────────
98
+
99
+ /**
100
+ * A workspace inference profile a plugin can route to. Returned by
101
+ * {@link getModelProfiles}; {@link key} is the value a `pre-model-call` hook
102
+ * assigns to `PreModelCallContext.modelProfile` to route a call. A model router
103
+ * reads this list (typically at `init`) to learn which profiles exist before
104
+ * mapping a classified message onto one.
105
+ */
106
+ export interface ModelProfileInfo {
107
+ /** Profile key in `llm.profiles`; assignable to `PreModelCallContext.modelProfile`. */
108
+ readonly key: string;
109
+ /** Human-readable label, falling back to {@link key} when none is set. */
110
+ readonly label: string;
111
+ /** Author-supplied description, or `null` when none is set. */
112
+ readonly description: string | null;
113
+ /** Whether this is the workspace's active profile. */
114
+ readonly isActive: boolean;
115
+ /** Whether the profile is disabled; routing to it is rejected by the resolver. */
116
+ readonly isDisabled: boolean;
117
+ /**
118
+ * Whether this is a weighted "mix" profile — an A/B blend that resolves to one
119
+ * of its constituent profiles per conversation via a seeded weighted pick.
120
+ * Routing to its {@link key} is valid; it directs the call into the blend
121
+ * rather than at a single fixed model.
122
+ */
123
+ readonly isMix: boolean;
124
+ }
125
+
97
126
  // ─── Shutdown context ────────────────────────────────────────────────────────
98
127
 
99
128
  /**
@@ -438,8 +467,9 @@ export interface StopContext {
438
467
  * and compaction work can share a conversation), hooks MUST self-gate on
439
468
  * {@link callSite} / {@link conversationId} before acting.
440
469
  *
441
- * A hook may edit the outbound request by replacing {@link systemPrompt}, and may
442
- * opt this turn into deferred output streaming via {@link deferAssistantOutput}.
470
+ * A hook may edit the outbound request by replacing {@link systemPrompt}, route
471
+ * the call to a different inference profile via {@link modelProfile}, and opt
472
+ * this turn into deferred output streaming via {@link deferAssistantOutput}.
443
473
  * Mutate the context in place or return a new one; throwing is contained by the
444
474
  * loop (the call proceeds with the original request).
445
475
  */
@@ -456,6 +486,24 @@ export interface PreModelCallContext {
456
486
  * append a section); the loop sends the resulting value.
457
487
  */
458
488
  systemPrompt: string | null;
489
+ /**
490
+ * Inference profile to route THIS provider call to, named by its key in the
491
+ * workspace `llm.profiles`. Seeded with the call's already-resolved override
492
+ * profile, or `null` when none applies. A hook may replace it to select a
493
+ * different profile per call — the lever a model router uses to map a
494
+ * classified message onto a profile (model + provider connection + sampling
495
+ * settings). For the user-facing `mainAgent` call the resolver layers the
496
+ * named profile at the top of precedence (above the workspace active
497
+ * profile), so the hook's choice wins; a key with no matching profile falls
498
+ * through unchanged (no throw). Honored only when {@link callSite} is set.
499
+ * Set to `null` to apply no override.
500
+ *
501
+ * Context-window sizing and overflow recovery for this call are computed from
502
+ * the profile resolved before the hook runs. Routing a near-budget
503
+ * conversation to a profile with a smaller context window relies on the loop's
504
+ * overflow recovery (compact and retry) rather than proactive compaction.
505
+ */
506
+ modelProfile: string | null;
459
507
  /**
460
508
  * Seeded `false`. When a hook sets it `true`, the loop suppresses this turn's
461
509
  * live assistant `text_delta` stream; a `post-model-call` hook is then
@@ -54,6 +54,10 @@ import memoryRetrievalPkg from "./memory-retrieval/package.json" with { type: "j
54
54
  import memoryV3PostCompact from "./memory-v3-shadow/hooks/post-compact.js";
55
55
  import memoryV3UserPromptSubmit from "./memory-v3-shadow/hooks/user-prompt-submit.js";
56
56
  import memoryV3Pkg from "./memory-v3-shadow/package.json" with { type: "json" };
57
+ import surfaceCompletionNudgePostModelCall from "./surface-completion-nudge/hooks/post-model-call.js";
58
+ import surfaceCompletionNudgeStop from "./surface-completion-nudge/hooks/stop.js";
59
+ import { resetSurfaceCompletionNudgeStoreForTests } from "./surface-completion-nudge/nudge-state-store.js";
60
+ import surfaceCompletionNudgePkg from "./surface-completion-nudge/package.json" with { type: "json" };
57
61
  import taskProgressNudgePostToolUse, {
58
62
  resetTaskProgressNudgeStateForTests,
59
63
  } from "./task-progress-nudge/hooks/post-tool-use.js";
@@ -259,6 +263,25 @@ export const defaultTaskProgressNudgePlugin: Plugin = {
259
263
  },
260
264
  };
261
265
 
266
+ /**
267
+ * `surface-completion-nudge` — a `post-model-call` hook that, when a user-facing
268
+ * turn is about to end with a progress surface (a `task_progress` card or
269
+ * `work_result`) the model showed but never advanced to a terminal status or
270
+ * dismissed, nudges the model once to close it and re-queries so it can act; the
271
+ * `stop` hook clears the one-shot bound on a terminal stop so the next run
272
+ * nudges afresh.
273
+ */
274
+ export const defaultSurfaceCompletionNudgePlugin: Plugin = {
275
+ manifest: {
276
+ name: surfaceCompletionNudgePkg.name,
277
+ version: surfaceCompletionNudgePkg.version,
278
+ },
279
+ hooks: {
280
+ "post-model-call": surfaceCompletionNudgePostModelCall,
281
+ stop: surfaceCompletionNudgeStop,
282
+ },
283
+ };
284
+
262
285
  /**
263
286
  * `tool-result-truncate` — a `post-tool-use` hook that tail-drops an oversized
264
287
  * tool result down to a character budget derived from the model's context
@@ -289,6 +312,7 @@ function getAllDefaultPlugins(): readonly Plugin[] {
289
312
  defaultToolErrorPlugin,
290
313
  defaultExplorationDriftPlugin,
291
314
  defaultTaskProgressNudgePlugin,
315
+ defaultSurfaceCompletionNudgePlugin,
292
316
  defaultHistoryRepairPlugin,
293
317
  defaultImageRecoveryPlugin,
294
318
  defaultCompactionPlugin,
@@ -339,5 +363,6 @@ export function resetPluginRegistryAndRegisterDefaults(): void {
339
363
  resetImageRecoveryStoreForTests();
340
364
  resetExplorationDriftStateForTests();
341
365
  resetTaskProgressNudgeStateForTests();
366
+ resetSurfaceCompletionNudgeStoreForTests();
342
367
  registerDefaultPlugins();
343
368
  }
@@ -71,6 +71,7 @@ describe("maintainJob", () => {
71
71
  return makeIndex(slugs);
72
72
  },
73
73
  readPageBody: async (slug) => `body for ${slug}`,
74
+ readCapabilityBody: async (slug) => `capability body for ${slug}`,
74
75
  deleteSectionsForArticle: async (_config, article) => {
75
76
  calls.deleted.push(article);
76
77
  },
@@ -283,6 +284,9 @@ describe("maintainJob", () => {
283
284
  const { deps: d, calls } = deps({
284
285
  loadCoreSet: () => ["page-live", "page-gone", "skills/example"],
285
286
  listIndexedSlugs: async () => ["page-live", "skills/example"],
287
+ // The capability row is already in the store, so the reconcile stage
288
+ // no-ops here and this test exercises core-validation in isolation.
289
+ listSectionArticles: async () => ["skills/example"],
286
290
  });
287
291
  const outcome = await maintainJob(JOB, CONFIG, d);
288
292
 
@@ -317,8 +321,9 @@ describe("maintainJob", () => {
317
321
  });
318
322
  const outcome = await maintainJob(JOB, CONFIG, d);
319
323
  expect(outcome.danglingCoreSlugs).toEqual([]);
320
- // The prune stage reads the index once; the core stage adds no second read.
321
- expect(indexReads).toBe(1);
324
+ // The reconcile and prune stages each read the index once; the empty core
325
+ // stage adds no further read.
326
+ expect(indexReads).toBe(2);
322
327
  });
323
328
 
324
329
  test("a thrown core-validation stage is contained and does not abort lane invalidation", async () => {
@@ -351,6 +356,53 @@ describe("maintainJob", () => {
351
356
  expect(calls.invalidate).toBe(1);
352
357
  expect(outcome.invalidated).toBe(true);
353
358
  });
359
+
360
+ test("reconciles capability rows missing from the section store", async () => {
361
+ setOverridesForTesting({ [FLAG_SHADOW]: true });
362
+ // The index lists a real page and three capability rows; the store already
363
+ // holds one of the caps. The change-delta excludes capability rows, so
364
+ // without this stage a skill enabled after the one-time backfill never
365
+ // reaches the dense lane. selectChangedPages stays empty so `calls.built`
366
+ // reflects reconcile embeds only.
367
+ const { deps: d, calls } = deps({
368
+ selectChangedPages: async () => [],
369
+ listIndexedSlugs: async () => [
370
+ "page-a",
371
+ "skills/already",
372
+ "skills/workflows",
373
+ "skills/another",
374
+ ],
375
+ listSectionArticles: async () => ["page-a", "skills/already"],
376
+ });
377
+ const outcome = await maintainJob(JOB, CONFIG, d);
378
+
379
+ // Only the caps missing from the store are embedded; the real page (handled
380
+ // by the change-delta stage) and the already-stored cap are left alone.
381
+ expect(outcome.capabilitiesReconciled).toBe(2);
382
+ expect(outcome.reconcileFailures).toBe(0);
383
+ expect(calls.built).toEqual([["skills/workflows"], ["skills/another"]]);
384
+ expect(calls.deleted).toEqual(["skills/workflows", "skills/another"]);
385
+ expect(calls.upserted.flat().map((s) => s.article)).toEqual([
386
+ "skills/workflows",
387
+ "skills/another",
388
+ ]);
389
+ });
390
+
391
+ test("skips a cold capability row (empty body) without deleting its points", async () => {
392
+ setOverridesForTesting({ [FLAG_SHADOW]: true });
393
+ const { deps: d, calls } = deps({
394
+ listIndexedSlugs: async () => ["skills/cold"],
395
+ listSectionArticles: async () => [],
396
+ readCapabilityBody: async () => "", // capability store not seeded yet
397
+ });
398
+ const outcome = await maintainJob(JOB, CONFIG, d);
399
+
400
+ expect(outcome.capabilitiesReconciled).toBe(0);
401
+ expect(outcome.reconcileFailures).toBe(0);
402
+ // Never replace good points with a blank: no delete, no upsert.
403
+ expect(calls.deleted).toEqual([]);
404
+ expect(calls.upserted).toEqual([]);
405
+ });
354
406
  });
355
407
 
356
408
  describe("computeChangedPages", () => {
@@ -2,7 +2,7 @@
2
2
  * Memory v3 — `memory_v3_maintain` job handler.
3
3
  *
4
4
  * A flag-gated, best-effort self-maintenance pass over the v3 section dense
5
- * store and the in-memory lanes. It runs four independent stages, in order:
5
+ * store and the in-memory lanes. It runs five independent stages, in order:
6
6
  *
7
7
  * 1. **Section re-embed** — diff the page index by `modifiedAt` against the
8
8
  * last successful pass (the high-water mark below), and for every page that
@@ -15,19 +15,26 @@
15
15
  * captured before any potential mtime bumps) so a page is not re-embedded
16
16
  * forever, yet a page whose embed failed (and whose sections were therefore
17
17
  * left deleted) stays above the mark and is retried next pass.
18
- * 2. **Deleted-page prune** — diff the dense store's stored articles
18
+ * 2. **Capability reconcile** — embed capability rows (synthetic skill/CLI
19
+ * slugs) present in the page index but missing from the section store. The
20
+ * re-embed delta above EXCLUDES capability rows (they have `modifiedAt` 0,
21
+ * no mtime to diff), so the only other embedder is the one-time
22
+ * `backfillAllSections`. Without this stage a skill enabled AFTER that
23
+ * backfill (e.g. a flag-gated skill flipped on at runtime) lands in the
24
+ * index but never reaches the dense lane. See {@link reconcileCapabilityRows}.
25
+ * 3. **Deleted-page prune** — diff the dense store's stored articles
19
26
  * (`listSectionArticles`) against the live page-index slugs and
20
27
  * `deleteSectionsForArticle` for any article that is no longer in the
21
28
  * index. A deleted page's slug never reaches the re-embed delta selector
22
29
  * (it only names live pages), so without this its section points would
23
30
  * linger in Qdrant and the dense lane could still surface the deleted page.
24
31
  * Synthetic capability rows are in the page index, so they are never pruned.
25
- * 3. **Core-set validation** — load the maintainer-curated core set
32
+ * 4. **Core-set validation** — load the maintainer-curated core set
26
33
  * (`memory/core-pages.md`) and report entries whose page no longer exists
27
34
  * in the page index (dangling slugs) via the log + outcome. The file is
28
35
  * maintainer-owned, so this stage NEVER edits it — the maintainer fixes
29
36
  * dangling entries at the next consolidation pass.
30
- * 4. **Lane invalidation** — `invalidateLanes()` so the next turn rebuilds the
37
+ * 5. **Lane invalidation** — `invalidateLanes()` so the next turn rebuilds the
31
38
  * in-memory section index, needle, and edge graph from the freshly-updated
32
39
  * pages.
33
40
  *
@@ -110,6 +117,13 @@ export interface MaintainJobDeps {
110
117
  buildSectionIndex: typeof realBuildSectionIndex;
111
118
  /** Read a page's frontmatter-stripped body for `buildSectionIndex`. */
112
119
  readPageBody: (slug: Slug) => Promise<string>;
120
+ /**
121
+ * Capability-aware body reader for the reconcile stage: synthetic skill/CLI
122
+ * slugs resolve their rendered capability content; real pages read from disk.
123
+ * The re-embed stage uses `readPageBody` (disk-only) since it only processes
124
+ * real pages — the reconcile stage needs capability bodies.
125
+ */
126
+ readCapabilityBody: (slug: Slug) => Promise<string>;
113
127
  /** Clear an article's stale section points before re-upserting. */
114
128
  deleteSectionsForArticle: typeof realDeleteSectionsForArticle;
115
129
  /** Embed + upsert an article's current sections into the dense store. */
@@ -198,6 +212,15 @@ export interface MaintainOutcome {
198
212
  reembedded: number;
199
213
  /** Pages whose re-embed threw (and was contained). */
200
214
  reembedFailures: number;
215
+ /**
216
+ * Capability rows (skills/CLI) present in the index but missing from the
217
+ * section store that were embedded this pass — how a skill enabled after the
218
+ * one-time backfill reaches the dense lane (the re-embed delta excludes
219
+ * capability rows).
220
+ */
221
+ capabilitiesReconciled: number;
222
+ /** Capability reconcile embeds that threw (and were contained). */
223
+ reconcileFailures: number;
201
224
  /**
202
225
  * Deleted-page articles whose lingering section points were pruned this pass
203
226
  * (present in the dense store but absent from the page index).
@@ -302,6 +325,8 @@ function defaultDeps(config: AssistantConfig): MaintainJobDeps {
302
325
  selectChangedPages: () => selectChangedPagesFromWorkspace(workspaceDir),
303
326
  buildSectionIndex: realBuildSectionIndex,
304
327
  readPageBody: (slug) => readPageBodyFromWorkspace(workspaceDir, slug),
328
+ readCapabilityBody: (slug) =>
329
+ backfillPageBodyFromWorkspace(workspaceDir, slug),
305
330
  deleteSectionsForArticle: realDeleteSectionsForArticle,
306
331
  upsertSections: realUpsertSections,
307
332
  commitEmbedHighWater,
@@ -357,6 +382,58 @@ async function reembedChangedPages(
357
382
  return { reembedded, reembedFailures };
358
383
  }
359
384
 
385
+ /**
386
+ * Embed capability rows (synthetic skill/CLI slugs) present in the page index
387
+ * but missing from the section dense store. The incremental re-embed selector
388
+ * ({@link computeChangedPages}) excludes capability rows — they have no on-disk
389
+ * mtime to delta against — so the ONLY other embedder is the one-time
390
+ * {@link backfillAllSections}. Without this, a skill enabled AFTER that backfill
391
+ * (e.g. a flag-gated skill flipped on at runtime) lands in the page index but
392
+ * never reaches the dense lane. This stage makes the periodic maintain pass
393
+ * self-heal: diff the live capability slugs against the stored section articles
394
+ * and embed the missing ones.
395
+ *
396
+ * Each row is independent: a single build/upsert throw is logged and counted in
397
+ * `reconcileFailures` without aborting the rest. A capability row whose body
398
+ * resolves empty (its store has not seeded yet) is skipped WITHOUT deleting any
399
+ * points — never replace good points with a blank — and is retried next pass.
400
+ */
401
+ async function reconcileCapabilityRows(
402
+ deps: MaintainJobDeps,
403
+ ): Promise<{ capabilitiesReconciled: number; reconcileFailures: number }> {
404
+ const [indexedSlugs, storedArticles] = await Promise.all([
405
+ deps.listIndexedSlugs(),
406
+ deps.listSectionArticles(),
407
+ ]);
408
+ const stored = new Set(storedArticles);
409
+ const missing = indexedSlugs.filter(
410
+ (slug) => isCapabilitySlug(slug) && !stored.has(slug),
411
+ );
412
+
413
+ let capabilitiesReconciled = 0;
414
+ let reconcileFailures = 0;
415
+ for (const slug of missing) {
416
+ try {
417
+ const body = await deps.readCapabilityBody(slug);
418
+ if (body.trim().length === 0) continue; // store cold — retry next pass
419
+ const index = await deps.buildSectionIndex(
420
+ [slug],
421
+ deps.readCapabilityBody,
422
+ );
423
+ await deps.deleteSectionsForArticle(deps.config, slug);
424
+ await deps.upsertSections(deps.config, index.sections);
425
+ capabilitiesReconciled += 1;
426
+ } catch (err) {
427
+ reconcileFailures += 1;
428
+ log.warn(
429
+ { slug, err: err instanceof Error ? err.message : String(err) },
430
+ "memory-v3 maintain: capability reconcile embed failed (non-fatal)",
431
+ );
432
+ }
433
+ }
434
+ return { capabilitiesReconciled, reconcileFailures };
435
+ }
436
+
360
437
  /**
361
438
  * Prune section points for articles that no longer exist in the page index. A
362
439
  * deleted concept page leaves `getPageIndex` (so the needle and edge lanes both
@@ -537,6 +614,8 @@ export async function maintainJob(
537
614
  disabled: false,
538
615
  reembedded: 0,
539
616
  reembedFailures: 0,
617
+ capabilitiesReconciled: 0,
618
+ reconcileFailures: 0,
540
619
  pruned: 0,
541
620
  pruneFailures: 0,
542
621
  danglingCoreSlugs: [],
@@ -587,7 +666,26 @@ export async function maintainJob(
587
666
  );
588
667
  }
589
668
 
590
- // Stage 2: prune section points for pages deleted from the index. A deleted
669
+ // Stage 2: embed capability rows (skills/CLI) present in the index but missing
670
+ // from the section store. They have modifiedAt 0, so computeChangedPages never
671
+ // selects them; the one-time backfill is otherwise their only embedder, so a
672
+ // skill enabled after that backfill stays invisible to the dense lane until
673
+ // this self-heals it. Contained: a failure is logged + recorded without
674
+ // aborting later stages.
675
+ try {
676
+ const { capabilitiesReconciled, reconcileFailures } =
677
+ await reconcileCapabilityRows(deps);
678
+ outcome.capabilitiesReconciled = capabilitiesReconciled;
679
+ outcome.reconcileFailures = reconcileFailures;
680
+ } catch (err) {
681
+ outcome.failures.push("capability-reconcile");
682
+ log.warn(
683
+ { err: err instanceof Error ? err.message : String(err) },
684
+ "memory-v3 maintain: capability reconcile failed (non-fatal)",
685
+ );
686
+ }
687
+
688
+ // Stage 3: prune section points for pages deleted from the index. A deleted
591
689
  // page's slug never appears in `selectChangedPages` (the delta selector only
592
690
  // names live pages), so its lingering points are cleared by diffing the dense
593
691
  // store's stored articles against the live page-index slugs. Contained: a
@@ -604,7 +702,7 @@ export async function maintainJob(
604
702
  );
605
703
  }
606
704
 
607
- // Stage 3: validate the maintainer-curated core set. Diff `core-pages.md`
705
+ // Stage 4: validate the maintainer-curated core set. Diff `core-pages.md`
608
706
  // entries against the live page-index slugs and REPORT dangling entries
609
707
  // (pages that were renamed or deleted) through the log + outcome — the file
610
708
  // is maintainer-owned, so it is never auto-edited; the maintainer fixes it at
@@ -632,7 +730,7 @@ export async function maintainJob(
632
730
  );
633
731
  }
634
732
 
635
- // Stage 4: rebuild section index + needle + edge graph on the next turn.
733
+ // Stage 5: rebuild section index + needle + edge graph on the next turn.
636
734
  try {
637
735
  deps.invalidateLanes();
638
736
  outcome.invalidated = true;
@@ -648,6 +746,8 @@ export async function maintainJob(
648
746
  {
649
747
  reembedded: outcome.reembedded,
650
748
  reembedFailures: outcome.reembedFailures,
749
+ capabilitiesReconciled: outcome.capabilitiesReconciled,
750
+ reconcileFailures: outcome.reconcileFailures,
651
751
  pruned: outcome.pruned,
652
752
  pruneFailures: outcome.pruneFailures,
653
753
  danglingCoreSlugs: outcome.danglingCoreSlugs,