@frockbot/plugin-shell 0.3.10 → 0.3.12

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 (37) hide show
  1. package/package.json +34 -32
  2. package/src/agent.test.ts +66 -0
  3. package/src/agent.ts +107 -2
  4. package/src/backend-configuration.test.ts +15 -9
  5. package/src/backend-package-catalog.ts +75 -26
  6. package/src/backend-runner.ts +19 -2
  7. package/src/backend.ts +118 -27
  8. package/src/client/FrockBotApp.vue +405 -134
  9. package/src/client/activity-trail.test.ts +205 -0
  10. package/src/client/activity-trail.ts +227 -0
  11. package/src/client/index.test.ts +25 -5
  12. package/src/client/index.ts +191 -47
  13. package/src/client/model-presentation.test.ts +3 -3
  14. package/src/client/no-bot-model-label.test.ts +7 -7
  15. package/src/client/skill-invocation.test.ts +34 -0
  16. package/src/client/skill-invocation.ts +22 -0
  17. package/src/client/styles.css +134 -89
  18. package/src/client/transcript-cache.test.ts +125 -0
  19. package/src/client/transcript-cache.ts +190 -0
  20. package/src/compaction-scheduler.test.ts +96 -0
  21. package/src/compaction-scheduler.ts +108 -0
  22. package/src/compaction-transcript.test.ts +174 -0
  23. package/src/compaction.test.ts +596 -0
  24. package/src/compaction.ts +539 -0
  25. package/src/focus.test.ts +222 -0
  26. package/src/focus.ts +93 -0
  27. package/src/history.ts +86 -8
  28. package/src/legacy-frock-model-id.test.ts +148 -0
  29. package/src/notification-id.test.ts +26 -0
  30. package/src/notification-id.ts +0 -0
  31. package/src/run-protocol.test.ts +37 -0
  32. package/src/run-protocol.ts +148 -38
  33. package/src/settings-links.test.ts +8 -2
  34. package/src/settings-links.ts +17 -2
  35. package/src/shared.ts +36 -0
  36. package/src/unread.ts +23 -1
  37. package/tsconfig.json +1 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-shell",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -27,6 +27,7 @@
27
27
  "./client/FrockBotApp.vue": "./src/client/FrockBotApp.vue",
28
28
  "./client/notify": "./src/client/notify.ts",
29
29
  "./client/styles.css": "./src/client/styles.css",
30
+ "./focus": "./src/focus.ts",
30
31
  "./frockbot.json": "./frockbot.json",
31
32
  "./host": "./src/host.ts",
32
33
  "./manifest": "./src/manifest.ts",
@@ -48,45 +49,46 @@
48
49
  },
49
50
  "dependencies": {
50
51
  "@cordisjs/plugin-webui": "0.8.2",
51
- "@frockbot/agent-runtime": "0.3.10",
52
- "@frockbot/application-foundation": "0.3.10",
53
- "@frockbot/catalog-core": "0.3.10",
54
- "@frockbot/client-core": "0.3.10",
55
- "@frockbot/client-ui": "0.3.10",
56
- "@frockbot/computer-core": "0.3.10",
57
- "@frockbot/computer-host-protocol": "0.3.10",
58
- "@frockbot/configuration-core": "0.3.10",
59
- "@frockbot/connection-core": "0.3.10",
60
- "@frockbot/kernel-agent-loop": "0.3.10",
61
- "@frockbot/kernel-composition": "0.3.10",
62
- "@frockbot/kernel-contracts": "0.3.10",
63
- "@frockbot/kernel-do": "0.3.10",
64
- "@frockbot/machine-protocol": "0.3.10",
65
- "@frockbot/plugin-applets": "0.3.10",
66
- "@frockbot/plugin-authoring": "0.3.10",
67
- "@frockbot/plugin-bot-template": "0.3.10",
68
- "@frockbot/plugin-computer": "0.3.10",
69
- "@frockbot/plugin-flock": "0.3.10",
70
- "@frockbot/plugin-image": "0.3.10",
71
- "@frockbot/plugin-machine-messages": "0.3.10",
72
- "@frockbot/plugin-mcp": "0.3.10",
73
- "@frockbot/plugin-memory": "0.3.10",
74
- "@frockbot/plugin-package-catalog": "0.3.10",
75
- "@frockbot/plugin-routines": "0.3.10",
76
- "@frockbot/plugin-skills": "0.3.10",
77
- "@frockbot/plugin-subagents": "0.3.10",
78
- "@frockbot/plugin-user-machine": "0.3.10",
79
- "@frockbot/protocol": "0.3.10",
52
+ "@frockbot/agent-runtime": "0.3.12",
53
+ "@frockbot/application-foundation": "0.3.12",
54
+ "@frockbot/catalog-core": "0.3.12",
55
+ "@frockbot/client-core": "0.3.12",
56
+ "@frockbot/client-ui": "0.3.12",
57
+ "@frockbot/computer-core": "0.3.12",
58
+ "@frockbot/computer-host-protocol": "0.3.12",
59
+ "@frockbot/configuration-core": "0.3.12",
60
+ "@frockbot/connection-core": "0.3.12",
61
+ "@frockbot/kernel-agent-loop": "0.3.12",
62
+ "@frockbot/kernel-composition": "0.3.12",
63
+ "@frockbot/kernel-contracts": "0.3.12",
64
+ "@frockbot/kernel-do": "0.3.12",
65
+ "@frockbot/machine-protocol": "0.3.12",
66
+ "@frockbot/plugin-applets": "0.3.12",
67
+ "@frockbot/plugin-authoring": "0.3.12",
68
+ "@frockbot/plugin-bot-template": "0.3.12",
69
+ "@frockbot/plugin-computer": "0.3.12",
70
+ "@frockbot/plugin-flock": "0.3.12",
71
+ "@frockbot/plugin-image": "0.3.12",
72
+ "@frockbot/plugin-machine-messages": "0.3.12",
73
+ "@frockbot/plugin-mcp": "0.3.12",
74
+ "@frockbot/plugin-memory": "0.3.12",
75
+ "@frockbot/plugin-package-catalog": "0.3.12",
76
+ "@frockbot/plugin-routines": "0.3.12",
77
+ "@frockbot/plugin-skills": "0.3.12",
78
+ "@frockbot/plugin-subagents": "0.3.12",
79
+ "@frockbot/plugin-user-machine": "0.3.12",
80
+ "@frockbot/protocol": "0.3.12",
80
81
  "cordis": "4.0.0-rc.8",
81
82
  "vue": "3.5.41"
82
83
  },
83
84
  "devDependencies": {
84
85
  "@cloudflare/workers-types": "latest",
85
- "@frockbot/plugin-tools": "0.3.10",
86
+ "@frockbot/plugin-prompt": "0.3.12",
87
+ "@frockbot/plugin-tools": "0.3.12",
86
88
  "@types/bun": "1.3.6",
87
89
  "@types/node": "26.2.0",
88
90
  "@vitejs/plugin-vue": "6.0.8",
89
- "typescript": "5.9.3",
91
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
90
92
  "vite": "8.2.2",
91
93
  "vue-tsc": "3.3.10"
92
94
  },
package/src/agent.test.ts CHANGED
@@ -8,9 +8,12 @@ import {
8
8
  type ToolExecutionContext,
9
9
  type TurnTypeV1,
10
10
  } from "@frockbot/kernel-contracts";
11
+ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
11
12
  import { ToolRegistry } from "@frockbot/plugin-tools";
12
13
  import { Context } from "cordis";
13
14
  import {
15
+ CONVERSATION_PROMPT_SECTION_V1,
16
+ CONVERSATION_PROMPT_TEXT_V1,
14
17
  shellAdmissionCeilingV1,
15
18
  shellAgentPlugin,
16
19
  PARENT_HANDOFF_CAPABILITY_V1,
@@ -31,6 +34,7 @@ interface Mounted {
31
34
  async function mount(): Promise<Mounted> {
32
35
  const root = new Context();
33
36
  await root.plugin(SessionStore);
37
+ await root.plugin(SystemPromptRegistry);
34
38
  await root.plugin(ToolRegistry);
35
39
  const session = root.sessions.create(SESSION_ID);
36
40
  session.appendBatch([
@@ -382,3 +386,65 @@ describe("wake_parent", () => {
382
386
  }
383
387
  });
384
388
  });
389
+
390
+ // The conversational contract is a prompt section and a tool description, and
391
+ // the two have to say the same thing: a model that read one and not the other
392
+ // would have half the rule.
393
+ describe("the conversation prompt section", () => {
394
+ test("is assembled into the system prompt the model reads", async () => {
395
+ const mounted = await mount();
396
+ try {
397
+ const assembled = await mounted.root.systemPrompt.assemble({
398
+ sessionId: SESSION_ID,
399
+ provider: "test",
400
+ model: "test-model",
401
+ turnType: "chat",
402
+ });
403
+
404
+ const section = assembled.sections.find(
405
+ (candidate) => candidate.id === CONVERSATION_PROMPT_SECTION_V1,
406
+ );
407
+ expect(section?.text).toBe(CONVERSATION_PROMPT_TEXT_V1);
408
+ expect(assembled.text).toContain(CONVERSATION_PROMPT_TEXT_V1);
409
+ } finally {
410
+ await mounted.dispose();
411
+ }
412
+ });
413
+
414
+ test("says acknowledge first, then beats, and never the steps between", () => {
415
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain("On it.");
416
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
417
+ "send only on a real beat: the result, a decision only the user can make, or a blocker",
418
+ );
419
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
420
+ "Never narrate what you are doing",
421
+ );
422
+ expect(CONVERSATION_PROMPT_TEXT_V1).toContain(
423
+ "Never leave a question or a request hanging",
424
+ );
425
+ // Short enough to be read as guidance rather than skimmed as a document.
426
+ expect(CONVERSATION_PROMPT_TEXT_V1.split("\n").length).toBeLessThanOrEqual(
427
+ 12,
428
+ );
429
+ });
430
+
431
+ test("matches what the send tool's own description tells the model", async () => {
432
+ const mounted = await mount();
433
+ try {
434
+ const schema = mounted.root.tools
435
+ .schemas({ turnType: "chat" })
436
+ .find((tool) => tool.name === SEND_TO_USER_TOOL_V1);
437
+ const description = schema?.description ?? "";
438
+
439
+ expect(description).toContain("only way to say anything the user sees");
440
+ // When to call it, not only what it does.
441
+ expect(description).toContain("Call it once, immediately");
442
+ expect(description).toContain("Call it again only on a real");
443
+ expect(description).toContain("narrate a step or a tool");
444
+ expect(description).toContain("never end your Turn leaving the");
445
+ expect(description).toContain("keep it short");
446
+ } finally {
447
+ await mounted.dispose();
448
+ }
449
+ });
450
+ });
package/src/agent.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  //
4
4
  // Two tools, and no authority of its own:
5
5
  //
6
+ // 0. One prompt section, `conversation`: when to speak and when not to. It
7
+ // is contributed beside the tool so the section and the tool description
8
+ // cannot drift into telling the model two different things.
9
+ //
6
10
  // 1. `send_to_user` (legacy alias `send_message`) — parity register row 57b.
7
11
  // One tool carrying the typed payload union, admitted on chat turns only,
8
12
  // recording each send as `send/to-user` on the durable log. Row 57c: a
@@ -35,7 +39,15 @@ import {
35
39
  } from "@frockbot/kernel-contracts";
36
40
  // Merges the Agent loop's event declarations into the cordis Context type.
37
41
  import type {} from "@frockbot/kernel-agent-loop/agent";
38
- import { automationParentPointerV1, turnScopedMessagesV1 } from "./history.js";
42
+ import {
43
+ automationParentPointerV1,
44
+ chatWindowV1,
45
+ CHAT_HISTORY_BUDGET_CHARS_V1,
46
+ turnScopedMessagesV1,
47
+ turnTypesByTurnV1,
48
+ } from "./history.js";
49
+ import { runCompactionV1 } from "./compaction.js";
50
+ import { compactionWorkV1 } from "./compaction-scheduler.js";
39
51
  import type { Plugin } from "cordis";
40
52
  import manifest from "../frockbot.json" with { type: "json" };
41
53
 
@@ -81,6 +93,11 @@ function refusal(reason: string): ToolExecutionResult {
81
93
  return { content: reason, isError: true };
82
94
  }
83
95
 
96
+ /** Adapts a cordis fiber to the plain disposer this Package's list holds. */
97
+ function disposeFiber(fiber: { dispose(): unknown }): () => void {
98
+ return () => void fiber.dispose();
99
+ }
100
+
84
101
  /**
85
102
  * The open step a Shell event belongs to. The session log is the
86
103
  * reconstruction surface, so a send without its turn and step would not
@@ -127,8 +144,38 @@ function sendAcknowledgement(payload: SendToUserPayloadV1): string {
127
144
  }
128
145
  }
129
146
 
147
+ /**
148
+ * The Bot's conversational contract, in the words the model reads.
149
+ *
150
+ * It lives here rather than in a prompt Package because it is the same rule
151
+ * the send tool's own description states: one place to write it, so the
152
+ * section and the tool cannot drift into telling the model two things. The
153
+ * Shell already owns the voice; it owns how the voice is used.
154
+ */
155
+ export const CONVERSATION_PROMPT_SECTION_V1 = "conversation";
156
+ /** Ordered after identity (0), before anything a Package contributes. */
157
+ export const CONVERSATION_PROMPT_ORDER_V1 = 1;
158
+
159
+ export const CONVERSATION_PROMPT_TEXT_V1 = [
160
+ "## Talking to the user",
161
+ "",
162
+ "Everything the user sees is a `send_to_user` call; nothing else reaches them.",
163
+ 'When a request will take more than a moment, send one short line first — "On it." or "Looking into that." — then go quiet and work.',
164
+ "After that, send only on a real beat: the result, a decision only the user can make, or a blocker you cannot get past.",
165
+ "Never narrate what you are doing, what you are about to do, or which tool you are using.",
166
+ "Never leave a question or a request hanging: before you stop, the user must have the answer, the result, or the reason there isn't one.",
167
+ "When the work is finished, send the result itself, not an account of how you got it.",
168
+ "Keep every message short — a line or two, no preamble and no sign-off.",
169
+ "Don't say the same thing twice.",
170
+ ].join("\n");
171
+
130
172
  const SEND_TO_USER_DESCRIPTION = [
131
173
  "Speak to the user. This is the only way to say anything the user sees.",
174
+ "Call it once, immediately, with one short line when the request will take",
175
+ "more than a moment, then work in silence. Call it again only on a real",
176
+ "beat: the result, a decision only the user can make, or a blocker. Do not",
177
+ "call it to narrate a step or a tool, and never end your Turn leaving the",
178
+ "user's question unanswered. Each call is one message; keep it short.",
132
179
  "The payload is one of:",
133
180
  '{"type":"text","text":"…"}',
134
181
  '{"type":"attachment","url":"https://…","name":"…","mediaType":"…"}',
@@ -295,6 +342,13 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
295
342
  const userVoice = shellAdmissionCeilingV1(USER_VOICE_CAPABILITY_V1);
296
343
  const parentHandoff = shellAdmissionCeilingV1(PARENT_HANDOFF_CAPABILITY_V1);
297
344
  const disposers = [
345
+ // The voice and the rules for using it are contributed together, so a
346
+ // Composition that admits the send tool always carries the contract.
347
+ ctx.systemPrompt.register({
348
+ id: CONVERSATION_PROMPT_SECTION_V1,
349
+ order: CONVERSATION_PROMPT_ORDER_V1,
350
+ render: () => CONVERSATION_PROMPT_TEXT_V1,
351
+ }),
298
352
  ctx.tools.register(
299
353
  createSendToUserTool(SEND_TO_USER_TOOL_V1, ctx.sessions),
300
354
  userVoice ? { admissionCeiling: userVoice } : undefined,
@@ -307,6 +361,57 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
307
361
  createWakeParentTool(ctx.sessions),
308
362
  parentHandoff ? { admissionCeiling: parentHandoff } : undefined,
309
363
  ),
364
+ // ADR 0030. `ctx.inject` rather than a declared dependency: a host that
365
+ // mounts the Shell without a model still gets its tools and its transcript
366
+ // seam, and simply never compacts. The hook is evaluated after `turn/end`
367
+ // is on the log and flushed — but `agent/turn-stopping` is a hook the loop
368
+ // *awaits* inside its `finally`, so running the summariser here is exactly
369
+ // the latency ADR 0030 says a compaction never costs. It is handed to the
370
+ // detached scheduler instead and this returns at once: the Turn ends, the
371
+ // run settles, the response goes out, and the summariser carries on behind
372
+ // it. Nothing here may throw, and nothing here may wait.
373
+ disposeFiber(
374
+ ctx.inject(["llm"], (scoped) => {
375
+ scoped.on("agent/turn-stopping", async (agent, turn) => {
376
+ const session = agent.session;
377
+ const types = turnTypesByTurnV1(session.events);
378
+ if ((types.get(turn) ?? "chat") !== "chat") return;
379
+ compactionWorkV1(session.id).start(async (signal) => {
380
+ if (signal.aborted) return;
381
+ await runCompactionV1({
382
+ session,
383
+ window: chatWindowV1(session.events, session.deriveMessages()),
384
+ budget: CHAT_HISTORY_BUDGET_CHARS_V1,
385
+ currentTurn: turn,
386
+ newEffectId: () => `compaction-${crypto.randomUUID()}`,
387
+ summarise: async (request) => {
388
+ let text = "";
389
+ // Two deadlines, one call: the compaction's own, and the abort
390
+ // a newly admitted Turn raises when it takes the log back.
391
+ const cancelled = AbortSignal.any([request.signal, signal]);
392
+ for await (const event of scoped.llm.stream(
393
+ {
394
+ requestId: `compaction-${crypto.randomUUID()}`,
395
+ provider: request.provider,
396
+ model: request.model,
397
+ system: request.system,
398
+ messages: request.messages,
399
+ tools: [],
400
+ ...(request.modelBinding
401
+ ? { modelBinding: request.modelBinding }
402
+ : {}),
403
+ },
404
+ cancelled,
405
+ )) {
406
+ if (event.type === "text-delta") text += event.text;
407
+ }
408
+ return text;
409
+ },
410
+ });
411
+ });
412
+ });
413
+ }),
414
+ ),
310
415
  // Applied after the rest of the chain, so this Package has the last word on
311
416
  // what history a request carries — the one rule the visible transcript
312
417
  // rests on.
@@ -327,6 +432,6 @@ export const shellAgentPlugin: Plugin.Function = (ctx) => {
327
432
  for (const dispose of disposers.toReversed()) dispose();
328
433
  };
329
434
  };
330
- shellAgentPlugin.inject = ["tools", "sessions"];
435
+ shellAgentPlugin.inject = ["tools", "sessions", "systemPrompt"];
331
436
 
332
437
  export default shellAgentPlugin;
@@ -8,6 +8,7 @@ import type {
8
8
  import type { PackageSettingDefinition } from "@frockbot/kernel-composition";
9
9
  import { createShellBotBackendContribution } from "./backend.js";
10
10
  import { createIsolateCapabilityHost } from "./backend-isolate.js";
11
+ import { notificationIdV1 } from "./notification-id.js";
11
12
 
12
13
  class MemoryStorage {
13
14
  readonly values = new Map<string, unknown>();
@@ -149,7 +150,7 @@ function configuredUser(): UserSettingsViewV1 {
149
150
  connectionId: "flock-ai-ambient",
150
151
  packageId: "provider-flock-ai",
151
152
  connectionTypeId: "flock-ai-account",
152
- displayName: "Flock AI",
153
+ displayName: "Frock AI",
153
154
  state: "ready",
154
155
  providerType: "flock-ai",
155
156
  generation: "foundation-generation-1",
@@ -159,7 +160,7 @@ function configuredUser(): UserSettingsViewV1 {
159
160
  state: "fresh",
160
161
  models: [
161
162
  {
162
- providerModelId: "@flock/auto",
163
+ providerModelId: "@frock/auto",
163
164
  displayName: "Platform",
164
165
  capabilities: {
165
166
  tools: true,
@@ -173,7 +174,7 @@ function configuredUser(): UserSettingsViewV1 {
173
174
  safeMetadata: {},
174
175
  },
175
176
  ],
176
- platformModel: model("flock-ai-ambient", "@flock/auto"),
177
+ platformModel: model("flock-ai-ambient", "@frock/auto"),
177
178
  };
178
179
  }
179
180
 
@@ -193,7 +194,7 @@ function host(storage: MemoryStorage, readUser: () => UserSettingsViewV1) {
193
194
  },
194
195
  MEMORY_FILES: {},
195
196
  MEMORY_INDEX: {},
196
- FLOCK_AI: {
197
+ FROCK_AI: {
197
198
  autoRoute: "flock-auto",
198
199
  runChatCompletion: () =>
199
200
  Promise.resolve(
@@ -526,7 +527,7 @@ describe("generic per-Turn model resolution", () => {
526
527
  connectionId: "flock-ai-ambient",
527
528
  packageId: "provider-flock-ai",
528
529
  provider: "flock-ai",
529
- providerModelId: "@flock/auto",
530
+ providerModelId: "@frock/auto",
530
531
  connectionGeneration: "foundation-generation-1",
531
532
  catalogGeneration: "catalog-1",
532
533
  });
@@ -601,9 +602,14 @@ describe("generic per-Turn model resolution", () => {
601
602
  await expect(contribution.listNotifications()).resolves.toEqual([
602
603
  expect.objectContaining({
603
604
  // Colons would fail the acknowledge decoder, so the mint replaces
604
- // them; a notification nobody can acknowledge 400s forever.
605
- notificationId:
606
- "package-connection-unavailable-run-1-bot-authored-flock-ai-ambient",
605
+ // them and folds in a digest of the raw parts; a notification nobody
606
+ // can acknowledge 400s forever.
607
+ notificationId: notificationIdV1(
608
+ "package-connection-unavailable",
609
+ "run-1",
610
+ "bot-authored",
611
+ "flock-ai-ambient",
612
+ ),
607
613
  title: "Connection unavailable",
608
614
  }),
609
615
  ]);
@@ -645,7 +651,7 @@ describe("generic per-Turn model resolution", () => {
645
651
  ),
646
652
  };
647
653
  expect(await contribution.resolveConfiguration(identity)).toMatchObject({
648
- model: model("flock-ai-ambient", "@flock/auto"),
654
+ model: model("flock-ai-ambient", "@frock/auto"),
649
655
  });
650
656
  expect(await contribution.getSettings(identity)).toMatchObject({
651
657
  packageValues: {
@@ -520,12 +520,9 @@ export function createPackageCatalogHost(
520
520
  index,
521
521
  catalogId: provenance.catalogId,
522
522
  });
523
- // A first-party member pins no bundle, so "the entry still offers
524
- // what the target generation recorded" is the two hashes agreeing,
525
- // both present or both absent.
526
523
  if (
527
- !targetEntry ||
528
- targetEntry.bundle?.contentHash !== provenance.contentHash
524
+ !targetEntry?.bundle ||
525
+ targetEntry.bundle.contentHash !== provenance.contentHash
529
526
  ) {
530
527
  return undoRefused(
531
528
  request,
@@ -541,9 +538,7 @@ export function createPackageCatalogHost(
541
538
  version: targetCatalogMember.version,
542
539
  catalogId: provenance.catalogId,
543
540
  catalogGeneration: provenance.catalogGeneration,
544
- ...(provenance.contentHash === undefined
545
- ? {}
546
- : { contentHash: provenance.contentHash }),
541
+ contentHash: provenance.contentHash,
547
542
  };
548
543
  }
549
544
  const receipt = await options.user.execute(command);
@@ -596,6 +591,13 @@ export function createPackageCatalogHost(
596
591
  let user: UserSettingsViewV1;
597
592
  let base: CompositionGenerationV1;
598
593
  let intent: PackageCatalogChangeIntentV1;
594
+ /**
595
+ * The entry names a compiled-in Package that is already required core in
596
+ * this Bot's Composition, so installing it is a User settings change and
597
+ * nothing else. Set on the install path and re-derived on a resumed
598
+ * intent, which carries no bundle hash precisely when this is true.
599
+ */
600
+ let firstParty = false;
599
601
  try {
600
602
  user = await options.user.read();
601
603
  base = await options.composition.lastKnownGood();
@@ -616,6 +618,12 @@ export function createPackageCatalogHost(
616
618
  );
617
619
  entry = resumed;
618
620
  intent = existingIntent;
621
+ firstParty =
622
+ existingIntent.action !== "remove" &&
623
+ !resumed.bundle &&
624
+ base.members.find(
625
+ (candidate) => candidate.packageId === resumed.packageId,
626
+ )?.provenance.kind === "first-party";
619
627
  } else if (action === "remove") {
620
628
  const packageId = request.change.input.packageId;
621
629
  const member = base.members.find(
@@ -697,7 +705,27 @@ export function createPackageCatalogHost(
697
705
  const member = base.members.find(
698
706
  (candidate) => candidate.packageId === entry.packageId,
699
707
  );
700
- if (action === "install" && member) {
708
+ // Installing a first-party entry changes the *User's* settings, not
709
+ // the Composition: the compiled-in Package is already required core
710
+ // in the bootstrap generation (every one of them is), and
711
+ // `assertRequiredCoreSet` refuses any proposal that restates a
712
+ // first-party member with catalog provenance. So "already a member"
713
+ // is the normal state here, not a conflict — the guard below is
714
+ // about a *bundle-backed* entry, whose code really would be added
715
+ // twice.
716
+ firstParty =
717
+ !entry.bundle && member?.provenance.kind === "first-party";
718
+ if (firstParty) {
719
+ // The one integrity check left for a member nothing re-hashes at
720
+ // mount: the entry must name the manifest the application actually
721
+ // compiled in, or the Catalog has drifted from this deployment.
722
+ if (member!.manifestHash !== entry.manifestHash) {
723
+ throw new Error(
724
+ `Catalog entry "${entry.catalogId}" names manifest "${entry.manifestHash}", but this deployment ships "${member!.manifestHash}"`,
725
+ );
726
+ }
727
+ }
728
+ if (action === "install" && member && !firstParty) {
701
729
  throw new Error(
702
730
  `Package "${entry.packageId}" is already in this Bot's Composition`,
703
731
  );
@@ -757,9 +785,12 @@ export function createPackageCatalogHost(
757
785
  const bundle = entry.bundle;
758
786
  await options.storage.put({
759
787
  [packageCatalogChangeIntentKey(request.effectId)]: intent,
760
- // A bundle-less entry has no manifest document to store; the mount
761
- // resolves it from the compiled-in application instead, still
762
- // against the `manifestHash` the generation records.
788
+ // A bundle-less entry stores no manifest document, because it adds
789
+ // no Composition member to mount one for: the compiled-in Package is
790
+ // already required core, running the manifest this deployment
791
+ // shipped. The entry's `manifestHash` was checked against that
792
+ // member's above, so a Catalog that has drifted from this deployment
793
+ // is refused rather than silently recorded.
763
794
  ...(bundle
764
795
  ? {
765
796
  [authorshipManifestKey(intent.manifestHash)]: {
@@ -805,11 +836,35 @@ export function createPackageCatalogHost(
805
836
 
806
837
  const parent =
807
838
  (await options.composition.read(intent.baseGenerationId)) ?? base;
839
+ // A first-party entry contributes no member: it is already required core
840
+ // in the bootstrap generation, and restating it with catalog provenance
841
+ // is exactly what `assertRequiredCoreSet` refuses. The install is the
842
+ // User settings command above, which is all the Plugins page does too,
843
+ // so the Composition is left on the generation it was already running.
844
+ if (firstParty) {
845
+ const outcome: PackageCatalogChangeOutcomeRecordV1 = {
846
+ schemaVersion: 1,
847
+ status: "recorded",
848
+ effectId: request.effectId,
849
+ action,
850
+ packageId: intent.packageId,
851
+ displayName: intent.displayName,
852
+ version: intent.version,
853
+ generationId: parent.generationId,
854
+ missingConnectionTypes: intent.missingConnectionTypes,
855
+ recordedAt: intent.recordedAt,
856
+ };
857
+ await options.storage.put({
858
+ [packageCatalogChangeOutcomeKey(request.effectId)]: outcome,
859
+ });
860
+ return recordedOutcome(outcome);
861
+ }
862
+
808
863
  const members = parent.members.filter(
809
864
  (member) => member.packageId !== intent.packageId,
810
865
  );
811
866
  if (action !== "remove") {
812
- const bundle = entry.bundle;
867
+ const bundle = entry.bundle!;
813
868
  const member: CompositionMemberV1 = {
814
869
  packageId: entry.packageId,
815
870
  specifier: `catalog:${entry.catalogId}`,
@@ -821,20 +876,14 @@ export function createPackageCatalogHost(
821
876
  version: entry.version,
822
877
  catalogId: entry.catalogId,
823
878
  catalogGeneration: intent.catalogGeneration,
824
- ...(bundle ? { contentHash: bundle.contentHash } : {}),
879
+ contentHash: bundle.contentHash,
880
+ },
881
+ artifact: {
882
+ contentHash: bundle.contentHash,
883
+ size: bundle.size,
884
+ mediaType: bundle.mediaType,
885
+ bundlerVersion: bundle.bundlerVersion,
825
886
  },
826
- // No artifact ⇒ the member mounts in the kernel isolate from the
827
- // compiled-in Package, exactly as the UI install records it.
828
- ...(bundle
829
- ? {
830
- artifact: {
831
- contentHash: bundle.contentHash,
832
- size: bundle.size,
833
- mediaType: bundle.mediaType,
834
- bundlerVersion: bundle.bundlerVersion,
835
- },
836
- }
837
- : {}),
838
887
  };
839
888
  members.push(member);
840
889
  }
@@ -17,6 +17,10 @@ import {
17
17
  BotTurnRecoveryRequiredError,
18
18
  } from "@frockbot/kernel-do";
19
19
  import type { BotTurnCommand, BotTurnCompletion } from "./backend-contracts.js";
20
+ import {
21
+ compactionInFlightV1,
22
+ whenCompactionSettledV1,
23
+ } from "./compaction-scheduler.js";
20
24
 
21
25
  export {
22
26
  BotTurnExecutionError,
@@ -370,11 +374,24 @@ export async function executeBotTurn(
370
374
  await runtime.agent.agent.whenIdle();
371
375
  return settleBotTurn(runtime.agent, command, previousEvents);
372
376
  } catch (error) {
373
- turnExecutionError(error, previousEvents, [
377
+ return turnExecutionError(error, previousEvents, [
374
378
  ...runtime.agent.agent.session.events,
375
379
  ]);
376
380
  } finally {
377
- await composition.dispose();
381
+ // ADR 0030: a compaction outlives the Turn that triggered it, and it runs
382
+ // on this Composition's model binding — so the Composition outlives the
383
+ // Turn too, and only by as long as the compaction does. Awaiting the
384
+ // disposal here would put the summariser back in the latency path, which
385
+ // is the whole defect. The next admission aborts anything still running,
386
+ // so this can never stack up.
387
+ if (compactionInFlightV1(command.sessionId)) {
388
+ void whenCompactionSettledV1(command.sessionId).then(
389
+ () => composition.dispose(),
390
+ () => composition.dispose(),
391
+ );
392
+ } else {
393
+ await composition.dispose();
394
+ }
378
395
  }
379
396
  }
380
397