@fastagent-sh/fastagent 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +12 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  22. package/dist/channels/feishu/feishu-api.js +1 -1
  23. package/dist/channels/feishu/feishu.d.ts +1 -1
  24. package/dist/channels/feishu/feishu.js +45 -15
  25. package/dist/channels/feishu/invoke-turn.d.ts +9 -3
  26. package/dist/channels/feishu/invoke-turn.js +152 -34
  27. package/dist/channels/feishu/parse.js +6 -0
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +16 -111
  34. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  35. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
  36. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
  37. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  38. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  39. package/dist/channels/kit/state.d.ts +6 -0
  40. package/dist/channels/{state.js → kit/state.js} +4 -24
  41. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  42. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  43. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  44. package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
  45. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
  46. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  47. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  48. package/dist/channels/lark/lark.d.ts +1 -1
  49. package/dist/channels/serve.d.ts +73 -0
  50. package/dist/channels/serve.js +243 -0
  51. package/dist/channels/slack/bot-auth.js +3 -14
  52. package/dist/channels/slack/context-buffer.d.ts +2 -2
  53. package/dist/channels/slack/context-buffer.js +2 -2
  54. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  55. package/dist/channels/slack/invoke-turn.js +9 -6
  56. package/dist/channels/slack/parse.js +2 -2
  57. package/dist/channels/slack/preview.d.ts +1 -1
  58. package/dist/channels/slack/preview.js +11 -3
  59. package/dist/channels/slack/slack-api.js +1 -1
  60. package/dist/channels/slack/slack.d.ts +1 -1
  61. package/dist/channels/slack/slack.js +8 -12
  62. package/dist/channels/slack/welcomed.js +1 -1
  63. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  64. package/dist/channels/telegram/context-buffer.js +2 -2
  65. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  66. package/dist/channels/telegram/invoke-turn.js +2 -2
  67. package/dist/channels/telegram/parse.js +2 -2
  68. package/dist/channels/telegram/preview.d.ts +1 -1
  69. package/dist/channels/telegram/preview.js +6 -2
  70. package/dist/channels/telegram/telegram.d.ts +1 -1
  71. package/dist/channels/telegram/telegram.js +3 -3
  72. package/dist/channels/telegram/turn-store.d.ts +2 -2
  73. package/dist/channels/telegram/turn-store.js +2 -2
  74. package/dist/cli/commands/add.js +1 -1
  75. package/dist/cli/commands/deploy.js +13 -5
  76. package/dist/cli/commands/dev.js +26 -14
  77. package/dist/cli/commands/fire.js +2 -2
  78. package/dist/cli/commands/info.js +28 -5
  79. package/dist/cli/commands/invoke.js +1 -1
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +64 -95
  82. package/dist/cli/commands/tool.js +5 -7
  83. package/dist/cli/program.js +1 -1
  84. package/dist/cli/serve.d.ts +18 -57
  85. package/dist/cli/serve.js +57 -243
  86. package/dist/cli/shared.d.ts +5 -3
  87. package/dist/cli/shared.js +14 -7
  88. package/dist/collect.d.ts +30 -4
  89. package/dist/collect.js +39 -6
  90. package/dist/core.d.ts +3 -5
  91. package/dist/core.js +7 -5
  92. package/dist/deploy/preflight.d.ts +8 -2
  93. package/dist/deploy/preflight.js +22 -4
  94. package/dist/deploy/secrets.d.ts +3 -0
  95. package/dist/deploy/secrets.js +6 -0
  96. package/dist/dev-supervisor.js +16 -4
  97. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  98. package/dist/engines/pi/agent-session-factory.js +314 -0
  99. package/dist/engines/pi/config.d.ts +4 -4
  100. package/dist/engines/pi/config.js +1 -1
  101. package/dist/engines/pi/create.d.ts +56 -42
  102. package/dist/engines/pi/create.js +150 -75
  103. package/dist/engines/pi/definition.d.ts +41 -7
  104. package/dist/engines/pi/definition.js +115 -11
  105. package/dist/engines/pi/invoke-session.d.ts +35 -0
  106. package/dist/engines/pi/invoke-session.js +357 -0
  107. package/dist/engines/pi/login.js +7 -4
  108. package/dist/engines/pi/models.d.ts +59 -11
  109. package/dist/engines/pi/models.js +64 -9
  110. package/dist/engines/pi/open.d.ts +9 -6
  111. package/dist/engines/pi/open.js +20 -10
  112. package/dist/engines/pi/report.d.ts +0 -4
  113. package/dist/engines/pi/report.js +0 -7
  114. package/dist/engines/pi/service.d.ts +23 -0
  115. package/dist/engines/pi/service.js +19 -0
  116. package/dist/engines/pi/session-builder.js +88 -90
  117. package/dist/engines/pi/session-control.d.ts +11 -9
  118. package/dist/engines/pi/session-control.js +114 -77
  119. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  120. package/dist/engines/pi/session-inheritance.js +242 -0
  121. package/dist/engines/pi/session-settings.d.ts +14 -2
  122. package/dist/engines/pi/session-settings.js +18 -0
  123. package/dist/engines/pi/session-store.d.ts +70 -0
  124. package/dist/engines/pi/session-store.js +313 -0
  125. package/dist/engines/pi/tool-context.d.ts +18 -8
  126. package/dist/engines/pi/tool-context.js +23 -1
  127. package/dist/engines/pi/tool.d.ts +5 -5
  128. package/dist/engines/pi/tool.js +1 -1
  129. package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
  130. package/dist/engines/pi/turn-kit.js +188 -0
  131. package/dist/engines/pi/wake-tool.js +1 -1
  132. package/dist/index.d.ts +1 -0
  133. package/dist/index.js +1 -0
  134. package/dist/log.d.ts +3 -0
  135. package/dist/log.js +5 -1
  136. package/dist/node.d.ts +16 -0
  137. package/dist/node.js +20 -0
  138. package/dist/paths.d.ts +13 -0
  139. package/dist/paths.js +16 -0
  140. package/dist/pi.d.ts +15 -8
  141. package/dist/pi.js +7 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  143. package/dist/scaffold/templates/persona.md +2 -2
  144. package/dist/schedule/state.js +4 -9
  145. package/dist/schedule/wakeups.d.ts +1 -1
  146. package/dist/schedule/wakeups.js +1 -1
  147. package/dist/service.d.ts +158 -0
  148. package/dist/service.js +354 -0
  149. package/dist/session-remote.d.ts +1 -1
  150. package/dist/session-remote.js +10 -2
  151. package/dist/session.d.ts +4 -2
  152. package/dist/session.js +1 -1
  153. package/package.json +14 -5
  154. package/dist/channels/state.d.ts +0 -16
  155. package/dist/engines/pi/harness.d.ts +0 -112
  156. package/dist/engines/pi/harness.js +0 -153
  157. package/dist/engines/pi/invoke.js +0 -618
  158. package/dist/engines/pi/read-image.d.ts +0 -4
  159. package/dist/engines/pi/read-image.js +0 -62
  160. package/dist/engines/pi/sessions.d.ts +0 -44
  161. package/dist/engines/pi/sessions.js +0 -182
  162. package/dist/host/node.js +0 -52
  163. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  164. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  165. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  166. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  167. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  168. /package/dist/channels/{text.js → kit/text.js} +0 -0
  169. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  170. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -1,7 +1,8 @@
1
1
  import { log } from "../../log.js";
2
- import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
+ import { BUFFER_ATTACH_MAX } from "../kit/context-buffer.js";
3
4
  import { parseContent } from "./parse.js";
4
- import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
5
+ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../kit/text.js";
5
6
  /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
6
7
  * concept — what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
7
8
  * natural fit for LLM output — steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
@@ -10,14 +11,126 @@ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
10
11
  const REPLY_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card. This reply is " +
11
12
  "delivered to the current chat by the channel itself: do not call a send tool to answer the " +
12
13
  "current chat.)";
14
+ /** How far up a reply chain the walk reads, beyond the replied-to message itself. The chain's natural
15
+ * end is its ROOT — the platform threads every reply back to one — so this is an IO guard, not a
16
+ * semantic boundary: each ancestor costs one serial `getMessage`, and a pathological chain must not
17
+ * stall the turn. Field chains are 1–3 long; a capped walk says so in the block. */
18
+ const MAX_CHAIN_ANCESTORS = 8;
19
+ /** Attribution for a FETCHED message. getMessage's sender is `{ id, id_type, sender_type }` — a
20
+ * DIFFERENT shape from the event's sender (`{ sender_id: { open_id } }`) — so the label is built
21
+ * here, not via parse.senderLabel.
22
+ *
23
+ * OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"` is
24
+ * true for every one of them — matching on it alone would tell the model it wrote another bot's
25
+ * message. The identity to compare is the app id, because an app sender carries `id_type: "app_id"`:
26
+ * the cached bot open_id answers a different question (who was @mentioned) and would never match
27
+ * here. A missing or unexpected id fails CLOSED — labelled by id, never claimed as the agent's own.
28
+ * And an app is not a person: labelling another bot's message "user cli_…" is the same
29
+ * misattribution in a quieter form, so the noun follows the sender type. */
30
+ function fetchedSenderLabel(sender, appId) {
31
+ const appSender = sender?.sender_type === "app";
32
+ const senderId = sender?.id;
33
+ if (appSender && senderId === appId)
34
+ return "you, the agent";
35
+ return senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
36
+ }
37
+ /**
38
+ * Walk the reply chain ABOVE the replied-to message, to its root. Quoting a reply points at one link
39
+ * of an exchange; the pointer is only fully resolved when the model can read what that link was
40
+ * replying to — all the way up, because the platform defines where the chain ends (its root), which
41
+ * is what makes the walk bounded by STRUCTURE rather than by a level count someone picked.
42
+ *
43
+ * This is pointer resolution, not history. Session memory — what this place already knows — is a
44
+ * different track (design/participant-model.md §8): a one-hop version of this walk was removed once
45
+ * for trying to be that substitute; it returns doing only the pointer's job, which is also why it
46
+ * walks through ANY author's message — the chain is the platform's structure, not a conversation the
47
+ * agent took part in. The repetition this implies (an established session re-reads chain text it may
48
+ * already hold, each reply turn) is accepted deliberately and bounded: ancestors are CONTEXT, not
49
+ * the ask, so their text shares ONE further `REFERENT_MAX_CODE_POINTS` budget across the whole chain
50
+ * — the walk costs at most one more referent — while the pointed-at referent keeps its own full
51
+ * fidelity bound.
52
+ *
53
+ * Fail-open at every edge, but never silently at the model: any walk that ends short of the root —
54
+ * the ancestor cap, an exhausted text budget, an unreadable ancestor, a cycle — leaves the same
55
+ * neutral truncation line at the top of the block, because a chain rendered without it READS as
56
+ * complete and the model would take the oldest fetched node for the original ask. Unreadable
57
+ * ancestors and cycles also warn the operator; a cycle is corrupt platform data (reply chains are
58
+ * temporally acyclic by construction — a reply can only point at an EARLIER message — so one firing
59
+ * means the data, not the walk, is wrong).
60
+ */
61
+ async function walkReplyChain(t, start, visited) {
62
+ const nodes = [];
63
+ const images = [];
64
+ const files = [];
65
+ // No parent above the referent = no chain — not a truncated one. The marker below is only for
66
+ // walks that END SHORT of a root that exists.
67
+ if (start === undefined)
68
+ return { block: "", images, files, ids: [] };
69
+ let reachedRoot = false;
70
+ let textBudget = REFERENT_MAX_CODE_POINTS;
71
+ let next = start;
72
+ while (next !== undefined) {
73
+ if (visited.has(next)) {
74
+ log.warn(`${t.label} reply chain points back to already-visited message ${next} — corrupt platform data; the walk ends here`);
75
+ break;
76
+ }
77
+ if (nodes.length >= MAX_CHAIN_ANCESTORS || textBudget <= 0)
78
+ break;
79
+ // The annotation breaks a control-flow-analysis cycle (id → msg → next → id) that trips TS7022.
80
+ const id = next;
81
+ visited.add(id);
82
+ let failure;
83
+ const msg = await t.api.getMessage(id).catch((error) => {
84
+ failure = String(error);
85
+ return undefined;
86
+ });
87
+ if (!msg) {
88
+ log.warn(`${t.label} could not read reply-chain message ${id} (${failure ?? "no such message"}) — the chain is rendered up to it`);
89
+ break;
90
+ }
91
+ const parsed = parseContent({
92
+ message_type: msg.msg_type ?? "unknown",
93
+ content: msg.body?.content ?? "",
94
+ mentions: msg.mentions,
95
+ });
96
+ const label = fetchedSenderLabel(msg.sender, t.appId);
97
+ const from = label ?? "reply chain";
98
+ for (const key of parsed.imageKeys)
99
+ images.push({ messageId: id, key, from });
100
+ for (const ref of parsed.fileRefs)
101
+ files.push({ messageId: id, key: ref.key, name: ref.name, from });
102
+ const text = truncateCodePointPrefix(parsed.text, textBudget) || "(empty)";
103
+ textBudget -= [...text].length;
104
+ nodes.push({ id, label, text });
105
+ if (msg.parent_id === undefined)
106
+ reachedRoot = true;
107
+ next = msg.parent_id;
108
+ }
109
+ nodes.reverse(); // fetched leaf→root; rendered oldest first, the way a transcript reads
110
+ const lines = nodes.map((node) => `(msg ${node.id}${node.label ? `, from ${node.label}` : ""}): ${node.text}`);
111
+ // One line for every way of ending short of the root — cap, budget, unreadable, cycle. It names no
112
+ // cause on purpose: the model needs the SHAPE (there is more above), the operator log has the why.
113
+ if (!reachedRoot)
114
+ lines.unshift("(…the chain continues above this point)");
115
+ return {
116
+ block: `\n[reply chain above it, oldest first:\n${lines.join("\n")}]`,
117
+ images,
118
+ files,
119
+ // Walked (fetched) order = nearest first — nodes were reversed for RENDERING above, so read the
120
+ // hint order off the rendered list backwards.
121
+ ids: nodes.map((node) => node.id).reverse(),
122
+ };
123
+ }
13
124
  /**
14
- * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
15
- * (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
125
+ * Resolve a turn's inputs (module header): fetch the reply referent's content and resolve its reply
126
+ * chain, then load every image (vision) and file (disk). Primary failures throw; buffered resources
127
+ * degrade independently.
16
128
  */
17
129
  async function resolveTurnInputs(t, attachments) {
18
130
  const images = [...attachments.primary.images];
19
131
  const files = [...attachments.primary.files];
20
132
  let referentBlock = "";
133
+ let chain = { block: "", images: [], files: [], ids: [] };
21
134
  if (attachments.primary.parentId !== undefined) {
22
135
  const parentId = attachments.primary.parentId;
23
136
  // A referent is CONTEXT, not the ask. Losing it (deleted, restricted, unreadable) must not cost
@@ -49,35 +162,13 @@ async function resolveTurnInputs(t, attachments) {
49
162
  images.push({ msg: parentId, key });
50
163
  for (const ref of parsed.fileRefs)
51
164
  files.push({ msg: parentId, key: ref.key, name: ref.name });
52
- // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
53
- // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
54
- //
55
- // OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"`
56
- // is true for every one of them — matching on it alone would tell the model it wrote another
57
- // bot's message. The identity to compare is the app id, because an app sender carries
58
- // `id_type: "app_id"`: the cached bot open_id answers a different question (who was @mentioned)
59
- // and would never match here. A missing or unexpected id fails CLOSED — labelled by id, never
60
- // claimed as the agent's own.
61
- const appSender = parent.sender?.sender_type === "app";
62
- const senderId = parent.sender?.id;
63
- const ownMessage = appSender && senderId === t.appId;
64
- // An app is not a person: labelling another bot's message "user cli_…" is the same misattribution
65
- // in a quieter form, so the noun follows the sender type.
66
- const from = ownMessage ? "you, the agent" : senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
165
+ const from = fetchedSenderLabel(parent.sender, t.appId);
67
166
  referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
68
- // The chain STOPS here, at the one message the user pointed at. Walking further — to what that
69
- // message was itself replying to — was built and removed: it reconstructs HISTORY out of reply
70
- // pointers, and history is the session's job. That framing has no non-arbitrary answers (how
71
- // many levels? what about the level above that? how is it deduplicated against what the session
72
- // already holds? how does an IMAGE two levels up become prompt text at all?), and every one of
73
- // those questions is a symptom of solving a session-layer problem in the prompt layer. The real
74
- // gap it was papering over — a thread opened on a room answer starts with an EMPTY session while
75
- // the room's session holds the exchange — belongs to memory inheritance (design/participant-
76
- // model.md §8, rungs 3-4), where images and tool results come along for free because they are
77
- // already in the history rather than being re-serialised into a prompt string.
167
+ // The referent's own parent starts the chain walk; the referent id seeds the cycle guard.
168
+ chain = await walkReplyChain(t, parent.parent_id, new Set([parentId]));
169
+ referentBlock += chain.block;
78
170
  }
79
171
  }
80
- // Primary first and fail-fast: these are resources the current user explicitly pointed at.
81
172
  const imageRefs = [];
82
173
  for (const ref of images)
83
174
  imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
@@ -88,8 +179,28 @@ async function resolveTurnInputs(t, attachments) {
88
179
  // downloaded twice or rendered twice in the manifest.
89
180
  const primaryImages = new Set(images.map((ref) => `${ref.msg}\u0000${ref.key}`));
90
181
  const primaryFiles = new Set(files.map((ref) => `${ref.msg}\u0000${ref.key}`));
91
- const bufferedImages = attachments.buffered.images.filter((ref) => !primaryImages.has(`${ref.messageId}\u0000${ref.key}`));
92
- const bufferedFiles = attachments.buffered.files.filter((ref) => !primaryFiles.has(`${ref.messageId}\u0000${ref.key}`));
182
+ // Chain ancestors and the context buffer share ONE background budget: BUFFER_ATTACH_MAX per kind.
183
+ // The cap is part of the tier's meaning, not an accident of who collected the ref a rich-text
184
+ // ancestor must not turn the walk into an unbounded fan-out of downloads. Chain refs take slots
185
+ // FIRST: they are the direct upstream of the message the user pointed at, buffer refs are ambient
186
+ // discussion. Duplicates (a chain that points back into still-buffered discussion) count once, and
187
+ // what the cap drops is counted into the missing-attachments note like every other unloaded ref.
188
+ const capMerge = (chainRefs, bufferRefs, primary) => {
189
+ const seen = new Set();
190
+ const merged = [];
191
+ for (const ref of [...chainRefs, ...bufferRefs]) {
192
+ const identity = `${ref.messageId}\u0000${ref.key}`;
193
+ if (primary.has(identity) || seen.has(identity))
194
+ continue;
195
+ seen.add(identity);
196
+ merged.push(ref);
197
+ }
198
+ return { kept: merged.slice(0, BUFFER_ATTACH_MAX), dropped: Math.max(0, merged.length - BUFFER_ATTACH_MAX) };
199
+ };
200
+ const mergedImages = capMerge(chain.images, attachments.buffered.images, primaryImages);
201
+ const mergedFiles = capMerge(chain.files, attachments.buffered.files, primaryFiles);
202
+ const bufferedImages = mergedImages.kept;
203
+ const bufferedFiles = mergedFiles.kept;
93
204
  const backgroundImages = [];
94
205
  const backgroundFiles = [];
95
206
  let lost = 0;
@@ -114,7 +225,7 @@ async function resolveTurnInputs(t, attachments) {
114
225
  log.warn(`${t.label} could not load an earlier (buffered) attachment: ${String(result.reason)}`);
115
226
  }
116
227
  }
117
- const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped);
228
+ const missingNote = missingAttachmentsNote(lost + attachments.buffered.skipped + mergedImages.dropped + mergedFiles.dropped);
118
229
  const backgroundImageManifest = backgroundImagesManifest(imageRefs.length, backgroundImages.map(({ ref }) => ref));
119
230
  const allFiles = [
120
231
  ...downloaded,
@@ -127,6 +238,7 @@ async function resolveTurnInputs(t, attachments) {
127
238
  return {
128
239
  images: allImages.length ? allImages : undefined,
129
240
  promptSuffix: `${referentBlock}${missingNote}${backgroundImageManifest}${attachedFilesManifest(allFiles)}`,
241
+ referentIds: [...(attachments.primary.parentId !== undefined ? [attachments.primary.parentId] : []), ...chain.ids],
130
242
  };
131
243
  }
132
244
  /**
@@ -144,5 +256,11 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
144
256
  return;
145
257
  }
146
258
  const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
147
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
259
+ // A thread turn names its lineage: parent place + the message ids that can locate the branch point
260
+ // (the referent and its chain — nearest first). The engine reads them ONCE, when the thread's
261
+ // session does not exist yet; on every later turn they ride along inertly.
262
+ const scope = transport.parentSession === undefined
263
+ ? { session }
264
+ : { session, parentSession: transport.parentSession, branchHints: resolved.referentIds };
265
+ yield* streamTurnWithBusyRetry(agent, scope, prompt, { label: transport.label, onCompleted, busyRetry });
148
266
  }
@@ -52,6 +52,12 @@ export function cloudEnvelope(event, tag) {
52
52
  `chat ${message.chat_id} (${message.chat_type})`,
53
53
  message.thread_id ? `topic ${message.thread_id}` : undefined,
54
54
  from ? `from ${from}` : undefined,
55
+ // The message's own id is LOAD-BEARING, not decoration: it is the only way this message's id
56
+ // enters the session transcript, and session inheritance locates a thread's branch point by
57
+ // searching the parent transcript for exactly these ids (scope.branchHints — sessions.ts).
58
+ // Remove it and every thread quietly inherits from the room's present instead of the branch
59
+ // point. It also lets the model name what it is answering in a busy chat.
60
+ `msg ${message.message_id}`,
55
61
  ]
56
62
  .filter(Boolean)
57
63
  .join(", ");
@@ -1,6 +1,6 @@
1
1
  import type { AgentEvent } from "../../agent.ts";
2
2
  import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
3
- import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
3
+ import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
4
4
  /** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
5
5
  export type FeishuFailure = ChannelFailure;
6
6
  export { defaultErrorMessage };
@@ -25,8 +25,8 @@ import { setTimeout as sleep } from "node:timers/promises";
25
25
  import { log } from "../../log.js";
26
26
  import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
27
27
  import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
28
- import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
29
- import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
28
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../kit/preview-kit.js";
29
+ import { truncateCodePointPrefix, truncateUtf8 } from "../kit/text.js";
30
30
  export { defaultErrorMessage };
31
31
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
32
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
@@ -286,7 +286,11 @@ export async function streamFeishuReply(events, api, target, formatError, initia
286
286
  // we throw below regardless.
287
287
  finalized = true;
288
288
  {
289
- const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
289
+ const msg = formatError({
290
+ details: e.details,
291
+ retryable: e.retryable,
292
+ ...(e.code !== undefined ? { code: e.code } : {}),
293
+ }) ?? "";
290
294
  try {
291
295
  await settle(msg);
292
296
  }
@@ -1,4 +1,4 @@
1
- import type { LongConnection } from "../../host/node.ts";
1
+ import type { LongConnection } from "../../channel.ts";
2
2
  import type { FeishuCloudKind } from "./cloud.ts";
3
3
  import type { FeishuMessageEvent } from "./parse.ts";
4
4
  interface FeishuWsClient {
@@ -1,5 +1,5 @@
1
1
  import type { Schema } from "@octokit/webhooks-types";
2
- import type { ChannelModule } from "../../host/node.ts";
2
+ import type { ChannelModule } from "../../channel.ts";
3
3
  /** A verified GitHub webhook event. Header fields plus the official typed payload. */
4
4
  export interface GithubEvent {
5
5
  /** `X-GitHub-Event` (e.g. "pull_request", "issue_comment"). */
@@ -1,4 +1,14 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ /**
2
+ * HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
3
+ *
4
+ * The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
5
+ * embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
6
+ * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
+ * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
+ *
9
+ * Serving it is somebody else's job: the node:http bridge and the route table belong to the HOST
10
+ * (`./serve.ts`). This file knows only the Agent contract and the wire shape of one stream.
11
+ */
2
12
  import type { Agent } from "../agent.ts";
3
13
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
4
14
  * dispatch imports it), so the two caps cannot drift apart. */
@@ -19,9 +29,3 @@ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hell
19
29
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
20
30
  */
21
31
  export declare function createInvokeHandler(agent: Agent): (req: Request) => Promise<Response>;
22
- /**
23
- * node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
24
- * Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
25
- * cancels both the request signal and the response stream (→ invoke cancellation).
26
- */
27
- export declare function nodeListener(handler: (req: Request) => Promise<Response>): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,17 +1,5 @@
1
- /**
2
- * HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
3
- *
4
- * The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
5
- * embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
6
- * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
- * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
- *
9
- * `nodeListener` is the thin node:http adapter for the embedded `fastagent dev/start` server.
10
- */
11
- import { Readable } from "node:stream";
12
- import { log } from "../log.js";
13
1
  import { readBodyCapped } from "./body.js";
14
- import { text, textHeaders } from "./respond.js";
2
+ import { text } from "./respond.js";
15
3
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
16
4
  * dispatch imports it), so the two caps cannot drift apart. */
17
5
  export const MAX_BODY_BYTES = 1 << 20;
@@ -56,14 +44,27 @@ export function createInvokeHandler(agent) {
56
44
  catch {
57
45
  return text("invalid json\n", 400);
58
46
  }
59
- const { session, text: promptText } = (payload ?? {});
47
+ const { session, text: promptText, parentSession, branchHints, } = (payload ?? {});
60
48
  if (typeof session !== "string" || typeof promptText !== "string") {
61
49
  return text('need { "session": string, "text": string }\n', 400);
62
50
  }
63
51
  // ^ the request shape INVOKE_EXAMPLE_BODY (below) must keep satisfying.
52
+ // The OPTIONAL lineage extension (Scope): malformed values are a 400, not a silent drop — a
53
+ // caller that sent them meant them.
54
+ if (parentSession !== undefined && typeof parentSession !== "string") {
55
+ return text('"parentSession" must be a string\n', 400);
56
+ }
57
+ if (branchHints !== undefined && !(Array.isArray(branchHints) && branchHints.every((h) => typeof h === "string"))) {
58
+ return text('"branchHints" must be an array of strings\n', 400);
59
+ }
64
60
  // Take the iterator explicitly so the stream's cancel() (consumer disconnect) can return() it and
65
61
  // run invoke's cancellation cleanup (SPEC MUST 3). pull = backpressure: the next event is produced on demand.
66
- const iterator = agent.invoke({ session }, { text: promptText })[Symbol.asyncIterator]();
62
+ const iterator = agent
63
+ .invoke({
64
+ session,
65
+ ...(parentSession !== undefined ? { parentSession } : {}),
66
+ ...(branchHints !== undefined ? { branchHints } : {}),
67
+ }, { text: promptText })[Symbol.asyncIterator]();
67
68
  // Heartbeats: a QUIET stream (a long tool call, no events) is normal here — remote consumers
68
69
  // distinguish "quiet but alive" from a dead connection by byte arrival, so silence must not
69
70
  // look identical to a black hole (SSE comments are ignored by spec-conforming parsers).
@@ -96,99 +97,3 @@ export function createInvokeHandler(agent) {
96
97
  });
97
98
  };
98
99
  }
99
- /**
100
- * node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
101
- * Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
102
- * cancels both the request signal and the response stream (→ invoke cancellation).
103
- */
104
- export function nodeListener(handler) {
105
- return (req, res) => {
106
- void pump(handler, req, res); // safe: pump is TOTAL (never rejects) — see its contract below
107
- };
108
- }
109
- /**
110
- * Consume ONE request and drive its response to a terminal state. pump is TOTAL: a SINGLE try/catch wraps
111
- * the whole request→response→stream path, so EVERY failure — a handler throw, a non-Response return
112
- * (`response.headers` undefined), a header Node rejects, `getReader`, or a body stream that errors
113
- * mid-flight — ends the response and the returned promise NEVER rejects, which is what lets the
114
- * `void pump(...)` above be safe. Before any byte goes out (headers not sent) it is a clean 500; once the
115
- * response is streaming, the only honest signal left is to destroy the socket (truncated stream, not a
116
- * hang). The process installs no `unhandledRejection` handler by design: robustness against a background
117
- * throw is each fire-and-forget's OWN contract (fail into a terminal HTTP response here), not a global net
118
- * that would blanket-swallow.
119
- */
120
- async function pump(handler, req, res) {
121
- const controller = new AbortController();
122
- res.on("close", () => controller.abort());
123
- try {
124
- const method = req.method ?? "GET";
125
- const hasBody = method !== "GET" && method !== "HEAD";
126
- const headers = new Headers();
127
- for (const [k, v] of Object.entries(req.headers)) {
128
- if (Array.isArray(v))
129
- for (const vv of v)
130
- headers.append(k, vv);
131
- else if (v != null)
132
- headers.set(k, v);
133
- }
134
- const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
135
- method,
136
- headers,
137
- body: hasBody ? Readable.toWeb(req) : undefined,
138
- duplex: "half",
139
- signal: controller.signal,
140
- });
141
- const response = await handler(request);
142
- const outHeaders = {};
143
- response.headers.forEach((value, key) => {
144
- outHeaders[key] = value;
145
- });
146
- res.writeHead(response.status, outHeaders);
147
- if (!response.body) {
148
- res.end();
149
- return;
150
- }
151
- const reader = response.body.getReader();
152
- res.on("close", () => void reader.cancel());
153
- for (;;) {
154
- const { done, value } = await reader.read();
155
- if (done || res.destroyed)
156
- break;
157
- // Backpressure: wait for drain, but ALSO resolve on close. A client disconnect after write()
158
- // returned false never emits 'drain' on the closed socket, so waiting on 'drain' alone would
159
- // suspend pump() forever (leaking the request/stream).
160
- if (!res.write(value)) {
161
- await new Promise((resolve) => {
162
- const settle = () => {
163
- res.off("drain", settle);
164
- res.off("close", settle);
165
- resolve();
166
- };
167
- res.once("drain", settle);
168
- res.once("close", settle);
169
- });
170
- }
171
- }
172
- if (!res.destroyed)
173
- res.end(); // normal completion
174
- }
175
- catch (error) {
176
- // The ONE totality boundary: every failure above lands here, so pump never rejects (see the header
177
- // doc) — which REQUIRES the catch itself not to throw. Don't leak the internal message to the client.
178
- log.error(`[host] request failed: ${String(error)}`);
179
- // Never touch an already-terminal res: a client that disconnects during the handler await destroys res
180
- // (headers not yet sent), and writeHead/end on a dead socket can throw ERR_STREAM_DESTROYED here — which
181
- // WOULD be the unhandled rejection this boundary exists to kill. One named gate states the invariant;
182
- // with it the catch is provably non-throwing (writeHead only when !headersSent && !destroyed, destroy is
183
- // idempotent).
184
- if (res.destroyed)
185
- return;
186
- if (res.headersSent) {
187
- res.destroy(error instanceof Error ? error : undefined); // streaming → truncate (not a hang)
188
- }
189
- else {
190
- res.writeHead(500, textHeaders); // pre-header → a clean 500
191
- res.end("internal error\n");
192
- }
193
- }
194
- }
@@ -4,7 +4,7 @@
4
4
  * un-summoned messages per conversation "place", kept under a char budget and folded into the next
5
5
  * answered turn in that place, so a summoned agent has the discussion it didn't see turn-by-turn.
6
6
  *
7
- * Channel-neutral and generic over the entry shape (like ../turn-store.ts): the channel supplies its
7
+ * Channel-neutral and generic over the entry shape (like ../kit/turn-store.ts): the channel supplies its
8
8
  * entry type, the shape validator (state files are an IO boundary — valid JSON of the WRONG shape
9
9
  * must degrade exactly like a corrupt file: warn + empty, never flow in as trusted data), the
10
10
  * fold-line renderer, and its log label. What stays per channel: the entry type itself, place-key
@@ -21,7 +21,7 @@
21
21
  * message that arrives while the turn runs survives for the next answered turn (a whole-bucket
22
22
  * delete would lose it).
23
23
  */
24
- import { log } from "../log.js";
24
+ import { log } from "../../log.js";
25
25
  import { loadStateFile, saveStateFile } from "./state.js";
26
26
  /** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
27
27
  * the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
@@ -7,10 +7,14 @@
7
7
  * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
8
  * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
9
  *
10
+ * None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
11
+ * states what it found; deciding whether to open a file is the agent's, and one assembled without a
12
+ * file tool answers that it cannot — visibly, at the moment it is asked.
13
+ *
10
14
  * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
15
  * message-scoped Feishu keys, Slack file objects) are real differences.
12
16
  */
13
- import { type Agent, type AgentEvent, type Prompt } from "../agent.ts";
17
+ import { type Agent, type AgentEvent, type Prompt, type Scope } from "../../agent.ts";
14
18
  /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
15
19
  * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
16
20
  * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
@@ -35,7 +39,10 @@ export declare const DEFAULT_BUSY_RETRY: BusyRetry;
35
39
  * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
36
40
  * is ever re-run.
37
41
  */
38
- export declare function streamTurnWithBusyRetry(agent: Agent, session: string, prompt: Prompt, options: {
42
+ export declare function streamTurnWithBusyRetry(agent: Agent,
43
+ /** The full scope, not a session string — channels that set extension fields (lineage) pass them
44
+ * through here; channels that don't pass `{ session }` and nothing changes. */
45
+ scope: Scope, prompt: Prompt, options: {
39
46
  label: string;
40
47
  onCompleted?: () => void;
41
48
  busyRetry?: BusyRetry;
@@ -46,8 +53,15 @@ export interface ManifestFile {
46
53
  size: number;
47
54
  path: string;
48
55
  }
49
- /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
50
- * Empty input renders nothing. */
56
+ /**
57
+ * The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
58
+ *
59
+ * It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
60
+ * assumption about the reader, and an assumption has to be verified — which is where a capability
61
+ * flag threaded through eight files came from. An agent with a file tool decides for itself whether
62
+ * to open one, and how much of it; an agent without one says so. Neither needs this line to have
63
+ * guessed first.
64
+ */
51
65
  export declare function attachedFilesManifest(files: readonly ManifestFile[]): string;
52
66
  /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
53
67
  * the way the fold attributes text lines. */
@@ -7,12 +7,16 @@
7
7
  * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
8
  * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
9
  *
10
+ * None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
11
+ * states what it found; deciding whether to open a file is the agent's, and one assembled without a
12
+ * file tool answers that it cannot — visibly, at the moment it is asked.
13
+ *
10
14
  * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
15
  * message-scoped Feishu keys, Slack file objects) are real differences.
12
16
  */
13
- import { SESSION_BUSY_CODE } from "../agent.js";
14
- import { log } from "../log.js";
15
- // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
17
+ import { SESSION_BUSY_CODE } from "../../agent.js";
18
+ import { log } from "../../log.js";
19
+ // Each retry is a lease-check-level reject (tryAcquire runs before the session is bound) — waiting is nearly
16
20
  // free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
17
21
  // real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
18
22
  // this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
@@ -33,13 +37,17 @@ export const DEFAULT_BUSY_RETRY = { delayMs: 5_000, maxWaitMs: 600_000 };
33
37
  * busy retries — a fail-fast reject is the only shape the engine emits it in, so nothing that started
34
38
  * is ever re-run.
35
39
  */
36
- export async function* streamTurnWithBusyRetry(agent, session, prompt, options) {
40
+ export async function* streamTurnWithBusyRetry(agent,
41
+ /** The full scope, not a session string — channels that set extension fields (lineage) pass them
42
+ * through here; channels that don't pass `{ session }` and nothing changes. */
43
+ scope, prompt, options) {
37
44
  const { label, onCompleted, busyRetry = DEFAULT_BUSY_RETRY } = options;
45
+ const session = scope.session;
38
46
  const deadline = Date.now() + busyRetry.maxWaitMs;
39
47
  for (;;) {
40
48
  let retryBusy = false;
41
49
  let first = true;
42
- for await (const e of agent.invoke({ session }, prompt)) {
50
+ for await (const e of agent.invoke(scope, prompt)) {
43
51
  if (first && e.type === "failed" && e.code === SESSION_BUSY_CODE && Date.now() + busyRetry.delayMs < deadline) {
44
52
  retryBusy = true; // fail-fast reject — the stream ends after this event; wait and re-invoke
45
53
  break;
@@ -55,11 +63,18 @@ export async function* streamTurnWithBusyRetry(agent, session, prompt, options)
55
63
  await new Promise((r) => setTimeout(r, busyRetry.delayMs));
56
64
  }
57
65
  }
58
- /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
59
- * Empty input renders nothing. */
66
+ /**
67
+ * The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
68
+ *
69
+ * It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
70
+ * assumption about the reader, and an assumption has to be verified — which is where a capability
71
+ * flag threaded through eight files came from. An agent with a file tool decides for itself whether
72
+ * to open one, and how much of it; an agent without one says so. Neither needs this line to have
73
+ * guessed first.
74
+ */
60
75
  export function attachedFilesManifest(files) {
61
76
  return files.length
62
- ? `\n\n[attached files — read them with your tools:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
77
+ ? `\n\n[attached files:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
63
78
  : "";
64
79
  }
65
80
  /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
@@ -8,11 +8,13 @@
8
8
  * everything platform-independent lives here, so a new event type or a wording change lands in ONE
9
9
  * place instead of one hunk per channel.
10
10
  */
11
- import type { AgentEvent, Json } from "../agent.ts";
11
+ import type { AgentEvent, Json } from "../../agent.ts";
12
12
  /** A terminal failure, as a channel hands it to its `onError`. */
13
13
  export interface ChannelFailure {
14
14
  details: string;
15
15
  retryable: boolean;
16
+ /** The engine's failure code, when it set one. */
17
+ code?: string;
16
18
  }
17
19
  /** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
18
20
  * offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
@@ -1,5 +1,5 @@
1
1
  /** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
2
- import { log } from "../log.js";
2
+ import { log } from "../../log.js";
3
3
  import { loadStateFile, saveStateFile } from "./state.js";
4
4
  export function createSeenRing(path, label, cap = 2000) {
5
5
  const raw = loadStateFile(path);
@@ -0,0 +1,6 @@
1
+ /** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
2
+ export declare function ensureStateHome(dir: string): void;
3
+ /** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
4
+ * caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
5
+ export declare function loadStateFile(path: string): unknown;
6
+ export declare function saveStateFile(path: string, value: unknown): void;