@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -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-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  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 +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -1,464 +0,0 @@
1
- /**
2
- * Session persistence — the K-axis port and its first two backends.
3
- *
4
- * PiSessionStore is the consumer-owned port: open-or-create by opaque session id, plus one creation
5
- * option — inheritance. pi's full SessionRepo surface (list/open/create/delete) stays behind the
6
- * adapters; `fork` is surfaced only through {@link SessionInheritance}, never raw. The `Pi` prefix
7
- * is honest — `openOrCreate` returns pi's `Session`, so this is pi-coupled, not a neutral
8
- * persistence contract.
9
- *
10
- * Continuity = same backing store + same session id: in-memory continuity dies with the instance;
11
- * jsonl survives process restarts (disk is the truth).
12
- */
13
- import { mkdir, rename, stat } from "node:fs/promises";
14
- import { basename, dirname, join, resolve } from "node:path";
15
- import { InMemorySessionRepo } from "@earendil-works/pi-agent-core";
16
- import { JsonlSessionRepo, NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
17
- import { log } from "../../log.js";
18
- /**
19
- * Crash-safety reconciliation, run on every OPEN of an existing session.
20
- *
21
- * A turn that dies mid tool-execution leaves an assistant `tool_use` with no matching result (pi
22
- * persists the assistant message before the tool runs). The next turn would then hand the provider an
23
- * `assistant(tool_use) -> user` sequence that Anthropic/OpenAI reject — the session is poisoned. We
24
- * append an honest "interrupted" error result for each dangling call, restoring a valid transcript.
25
- * Tool side-effect idempotency stays the tool's responsibility (SPEC §6); this only restores
26
- * transcript validity, not exactly-once execution.
27
- *
28
- * Pairing is TURN-LOCAL: a tool_use is paired only by a toolResult that immediately follows it (up to
29
- * the next non-toolResult). tool-call ids are not unique across turns (a local model may restart ids
30
- * each response), so matching against the whole transcript could falsely settle a leaf call against an
31
- * earlier turn's identical id. Append-only logs can only repair a gap AT THE LEAF (last assistant
32
- * followed by nothing but its own results); an earlier gap is surfaced via log.warn rather than
33
- * "fixed" with an orphaned result that appending cannot place.
34
- *
35
- * The synthetic result splits its audiences: `content` (read by the model, may reach the end user)
36
- * stays neutral — it must NOT say "aborted" (pi's word for a user cancellation) or leak infra detail;
37
- * `details` carries the operational marker for developers and is never sent to the provider.
38
- */
39
- async function reconcileInterruptedToolCalls(session) {
40
- const { messages } = await session.buildContext();
41
- let leafIdx = -1;
42
- for (let i = messages.length - 1; i >= 0; i--) {
43
- if (messages[i]?.role === "assistant") {
44
- leafIdx = i;
45
- break;
46
- }
47
- }
48
- if (leafIdx === -1)
49
- return; // no assistant turn yet
50
- const leafReparable = messages.slice(leafIdx + 1).every((m) => m.role === "toolResult");
51
- const toRepair = [];
52
- const orphaned = [];
53
- messages.forEach((m, idx) => {
54
- if (m.role !== "assistant")
55
- return;
56
- const paired = new Set();
57
- for (let j = idx + 1; j < messages.length; j++) {
58
- const next = messages[j];
59
- if (next?.role !== "toolResult")
60
- break;
61
- paired.add(next.toolCallId);
62
- }
63
- for (const block of m.content) {
64
- if (block.type !== "toolCall" || paired.has(block.id))
65
- continue;
66
- if (idx === leafIdx && leafReparable)
67
- toRepair.push({ id: block.id, name: block.name });
68
- else
69
- orphaned.push(block.id);
70
- }
71
- });
72
- if (orphaned.length > 0) {
73
- log.warn(`[fastagent] unmatched tool_use is not at the session leaf; leaving it unreconciled ` +
74
- `(an append-only log cannot repair a mid-history gap): toolCallIds=${orphaned.join(",")}`);
75
- }
76
- for (const { id, name } of toRepair) {
77
- const result = {
78
- role: "toolResult",
79
- toolCallId: id,
80
- toolName: name,
81
- content: [
82
- {
83
- type: "text",
84
- text: "This tool call did not complete and its result is unavailable. Re-run it if the result is still needed.",
85
- },
86
- ],
87
- details: { fastagent: "interrupted-tool-call" },
88
- isError: true,
89
- timestamp: Date.now(),
90
- };
91
- await session.appendMessage(result);
92
- }
93
- }
94
- /**
95
- * The entries on the session's ACTIVE path, root→leaf — what every last-wins read must walk.
96
- * `getEntries()` is the whole TREE: once `navigate` can move the leaf, the journal still carries
97
- * the abandoned branch, and reading it flat would run the session on a setting it moved away from.
98
- * Deliberately NOT `Session.getBranch()`: that walk is bounded by the last compaction's retained
99
- * window, which is the right bound for MODEL CONTEXT and the wrong one for settings — an override
100
- * recorded before a compaction is a preference, and it still governs the session after one.
101
- */
102
- export async function activePathEntries(session) {
103
- // LEAF FIRST, then the journal — the order is load-bearing, not incidental: `getEntries()` is a
104
- // SNAPSHOT, so reading it first would let any concurrent append (i.e. any turn in progress) leave
105
- // a leaf the snapshot cannot contain, and the integrity throw below would fire on a live session
106
- // instead of a corrupt one. This way the snapshot is always a superset of the leaf's chain.
107
- const leafId = await session.getLeafId();
108
- const entries = await session.getEntries();
109
- // A null leaf is pi's ROOT position (what `moveTo(null)` sets), so the active path is EMPTY — not
110
- // "the whole journal", which on a branched session would mean every abandoned branch at once.
111
- if (leafId === null)
112
- return [];
113
- const byId = new Map(entries.map((e) => [e.id, e]));
114
- // A chain that is not intact THROWS: any other disposition returns a short path that reads like a
115
- // short session (every override and activation above the gap gone, the next turn silently on
116
- // assembly defaults). pi's storages already reject a leaf outside the journal in `getLeafId()`,
117
- // but the port is swappable — so the walk names it here rather than dying on `undefined.parentId`.
118
- const start = byId.get(leafId);
119
- if (!start)
120
- throw new Error(`session leaf "${leafId}" is missing from the journal`);
121
- const path = [];
122
- for (let cur = start;;) {
123
- path.push(cur);
124
- // A path cannot be longer than the journal it walks; anything more is a parentId cycle, which
125
- // would otherwise hang the turn with no `failed` event at all.
126
- if (path.length > entries.length)
127
- throw new Error(`session entry "${cur.id}" cycles through its parents`);
128
- if (!cur.parentId)
129
- break;
130
- const parent = byId.get(cur.parentId);
131
- if (!parent)
132
- throw new Error(`session entry "${cur.parentId}" is missing from the journal (parent of "${cur.id}")`);
133
- cur = parent;
134
- }
135
- return path.reverse(); // walked leaf→root; every consumer reads it root→leaf
136
- }
137
- // ── Inheritance: fork-on-first-open ───────────────────────────────────────────────────────
138
- //
139
- // The mechanism behind participant-model.md §5's rule, engine-side and channel-neutral: a channel
140
- // only names WHERE a place branched from (scope.parentSession) and possibly at WHICH message
141
- // (branchHints); how much is inherited and where the boundaries sit is decided here.
142
- //
143
- // Shape: fork the parent's ACTIVE PATH up to the branch point (everything — text, images, tool
144
- // results — because they are session entries, not prompt text), then bound what the MODEL sees with
145
- // one mechanical compaction mark (a plain string; zero model calls). Disk keeps the full copy —
146
- // storage and context are different budgets (the fork is inspectable; the mark governs the window),
147
- // and pi's context assembly honors the mark the same as a real compaction.
148
- /** Inheritance window: at most this many exchanges of the parent reach the child's model context. */
149
- const INHERIT_MAX_EXCHANGES = 50;
150
- /** …and at most roughly this many tokens (~1/4 of a 200K context: generous, not everything). Both
151
- * limits govern how far the window EXTENDS into older history — the newest exchange is a FLOOR,
152
- * kept whole even when it alone exceeds the budget: the mark's boundary is entry-granular, and an
153
- * inheritance that drops the exchange the thread branched off would be no inheritance at all. */
154
- const INHERIT_MAX_TOKENS = 50_000;
155
- /** Branch hints are IDS, not payloads: each one costs a scan over the parent's serialized path, and
156
- * the wire accepts arbitrary arrays — so the engine caps them where the cost lives. */
157
- const MAX_BRANCH_HINTS = 16;
158
- const MAX_BRANCH_HINT_CHARS = 128;
159
- /** A vision image is priced FLAT — what a provider bills for a resized image, roughly — because its
160
- * base64 length (~1M chars for a photo) measures storage, not context: pricing it by chars would
161
- * let one photo evict the whole text window. */
162
- const INHERIT_IMAGE_TOKENS = 1_600;
163
- /** The fork reads the whole parent journal into memory; beyond this it is skipped (empty session +
164
- * warn) rather than stalling the thread's first turn. */
165
- const FORK_MAX_BYTES = 32 * 1024 * 1024;
166
- function isUserMessage(entry) {
167
- return entry?.type === "message" && entry.message.role === "user";
168
- }
169
- /** Rough token estimate for windowing — text at chars/4, images flat. Precision is not the point:
170
- * the window is a budget, and being 20% off moves a boundary by an exchange, not correctness. */
171
- function estimateMessageTokens(message) {
172
- // AgentMessage is a role-keyed union and not every member carries `content` — read it loosely.
173
- const content = message.content;
174
- if (typeof content === "string")
175
- return Math.ceil(content.length / 4);
176
- if (!Array.isArray(content))
177
- return 0;
178
- let tokens = 0;
179
- // Blocks are role-dependent unions; the estimate only needs `type`/`text`, so read them loosely.
180
- for (const block of content) {
181
- if (block.type === "image")
182
- tokens += INHERIT_IMAGE_TOKENS;
183
- else if (typeof block.text === "string")
184
- tokens += Math.ceil(block.text.length / 4);
185
- else
186
- tokens += Math.ceil(JSON.stringify(block).length / 4);
187
- }
188
- return tokens;
189
- }
190
- function estimateEntryTokens(entry) {
191
- if (entry.type !== "message")
192
- return 0;
193
- return estimateMessageTokens(entry.message);
194
- }
195
- /** A compaction entry's summary and retained tail DO reach the model — they are the floor under
196
- * every window that starts above the compaction, so the budget must count them. */
197
- function estimateCompactionTokens(entry) {
198
- if (entry?.type !== "compaction")
199
- return 0;
200
- let tokens = Math.ceil(entry.summary.length / 4);
201
- for (const message of entry.retainedTail ?? [])
202
- tokens += estimateMessageTokens(message);
203
- return tokens;
204
- }
205
- /**
206
- * Find the fork target on the parent's active path: the LAST message whose content carries a hint
207
- * (the most recent turn that talked about that message), extended forward to the end of its exchange
208
- * — forking mid-exchange would inherit a question without its answer. Hints are tried in caller
209
- * order; the first that matches anywhere wins. No match → undefined (the caller forks the present).
210
- */
211
- function locateBranchPoint(path, hints) {
212
- const usable = hints
213
- .filter((hint) => hint.length > 0 && hint.length <= MAX_BRANCH_HINT_CHARS)
214
- .slice(0, MAX_BRANCH_HINTS);
215
- if (usable.length < hints.length) {
216
- log.warn(`[fastagent] ignored ${hints.length - usable.length} branch hint(s) (over ${MAX_BRANCH_HINTS} hints or ${MAX_BRANCH_HINT_CHARS} chars each) — hints are message ids, not payloads`);
217
- }
218
- if (usable.length === 0)
219
- return undefined;
220
- // Serialize each message ONCE — the scan is hints × entries, and stringify must not sit in the
221
- // inner loop. The whole message, not just content: shape-agnostic, and a hint is a platform id —
222
- // a false positive would need the id to appear outside content, which is where ids live anyway.
223
- const serialized = path.map((entry) => (entry.type === "message" ? JSON.stringify(entry.message) : ""));
224
- for (const hint of usable) {
225
- for (let i = path.length - 1; i >= 0; i--) {
226
- if (!serialized[i]?.includes(hint))
227
- continue;
228
- let j = i + 1;
229
- while (j < path.length && !isUserMessage(path[j]))
230
- j++;
231
- return path[j - 1]?.id;
232
- }
233
- }
234
- return undefined;
235
- }
236
- /**
237
- * Bound what the child's MODEL CONTEXT starts with: keep the newest exchange unconditionally, extend
238
- * older while both window limits hold, and mark the boundary with a mechanical compaction entry.
239
- * Entries above the parent's own last compaction are already outside model context and need no mark;
240
- * a child whose visible history fits the window gets no mark at all.
241
- */
242
- async function markInheritanceWindow(child) {
243
- const path = await activePathEntries(child);
244
- let scanFrom = 0;
245
- for (let i = path.length - 1; i >= 0; i--) {
246
- if (path[i]?.type === "compaction") {
247
- scanFrom = i + 1;
248
- break;
249
- }
250
- }
251
- const scanned = path.slice(scanFrom);
252
- // The compaction's own summary + retained tail reach the model regardless of where the window
253
- // lands, so they charge the budget as a base cost — not estimating them would over-admit.
254
- const baseTokens = estimateCompactionTokens(path[scanFrom - 1]);
255
- const starts = [];
256
- scanned.forEach((entry, i) => {
257
- if (isUserMessage(entry))
258
- starts.push(i);
259
- });
260
- if (starts.length <= 1)
261
- return; // zero or one visible exchange — nothing to cut
262
- const suffixTokens = new Array(scanned.length + 1).fill(0);
263
- for (let i = scanned.length - 1; i >= 0; i--) {
264
- const entry = scanned[i];
265
- suffixTokens[i] = (suffixTokens[i + 1] ?? 0) + (entry ? estimateEntryTokens(entry) : 0);
266
- }
267
- let chosen = starts.length - 1;
268
- for (let k = starts.length - 2; k >= 0; k--) {
269
- const exchanges = starts.length - k;
270
- const startIdx = starts[k];
271
- if (startIdx === undefined)
272
- break;
273
- if (exchanges > INHERIT_MAX_EXCHANGES || baseTokens + (suffixTokens[startIdx] ?? 0) > INHERIT_MAX_TOKENS)
274
- break;
275
- chosen = k;
276
- }
277
- if (chosen === 0)
278
- return; // the whole visible history fits the window
279
- const boundaryIdx = starts[chosen];
280
- if (boundaryIdx === undefined)
281
- return;
282
- const boundary = scanned[boundaryIdx];
283
- if (boundary === undefined)
284
- return;
285
- await child.appendCompaction(`Inherited from the parent conversation; ${chosen} earlier exchange(s) are not shown.`, boundary.id, (suffixTokens[0] ?? 0) - (suffixTokens[boundaryIdx] ?? 0));
286
- }
287
- /**
288
- * The create-with-parent path (the open path never reaches here) — SEMANTICS only: where to branch
289
- * and what the newborn is born with. Writing and publishing it is
290
- * {@link SessionBackend.createAtomically}'s contract.
291
- *
292
- * Every failure lands on "start empty + warn": a thread must not lose its first turn to an
293
- * inheritance edge.
294
- */
295
- async function createInheriting(backend, id, inherit, parentId, maxBytes) {
296
- const startEmpty = () => backend.createAtomically(id, undefined, async () => { });
297
- const parentMeta = await backend.find(parentId);
298
- if (!parentMeta) {
299
- log.warn(`[fastagent] session "${id}" names parent "${parentId}", which does not exist — starting empty`);
300
- return startEmpty();
301
- }
302
- if (backend.bytes) {
303
- const bytes = await backend.bytes(parentMeta).catch(() => 0);
304
- if (bytes > maxBytes) {
305
- log.warn(`[fastagent] parent session "${parentId}" is ${bytes} bytes (limit ${maxBytes}) — starting empty rather than stalling the first turn`);
306
- return startEmpty();
307
- }
308
- }
309
- try {
310
- const parent = await backend.open(parentMeta);
311
- const path = await activePathEntries(parent);
312
- const leaf = path[path.length - 1];
313
- if (leaf !== undefined) {
314
- const hints = inherit.branchHints ?? [];
315
- const at = locateBranchPoint(path, hints);
316
- if (at === undefined && hints.length > 0) {
317
- log.warn(`[fastagent] no branch hint matched in parent "${parentId}" — inheriting from its present instead of the branch point`);
318
- }
319
- // The one repair that must run before the child is visible: a mid-turn parent forks with a
320
- // dangling tool call at its leaf, which would hand the provider an invalid transcript.
321
- return await backend.createAtomically(id, { meta: parentMeta, atEntryId: at ?? leaf.id }, async (draft) => {
322
- await reconcileInterruptedToolCalls(draft);
323
- await markInheritanceWindow(draft);
324
- });
325
- }
326
- }
327
- catch (error) {
328
- // Unattributed on purpose: this spans reading the parent AND writing the child, so the fault may
329
- // belong to either — a torn parent journal, or a store that cannot publish.
330
- log.warn(`[fastagent] could not inherit from "${parentId}" into "${id}" (${String(error)}) — starting empty`);
331
- }
332
- return startEmpty();
333
- }
334
- /** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
335
- export function inMemorySessionStore() {
336
- const repo = new InMemorySessionRepo();
337
- const backend = {
338
- find: async (id) => (await repo.list()).find((m) => m.id === id),
339
- open: (m) => repo.open(m),
340
- // No draft realm needed: nothing partial outlives the process, and `fill` still runs before the
341
- // session reaches any caller.
342
- createAtomically: async (id, from, fill) => {
343
- const draft = from
344
- ? await repo.fork(from.meta, { id, entryId: from.atEntryId, position: "at" })
345
- : await repo.create({ id });
346
- await fill(draft);
347
- return draft;
348
- },
349
- };
350
- return {
351
- async openOrCreate(sessionId, inherit) {
352
- const existing = (await repo.list()).find((m) => m.id === sessionId);
353
- if (!existing) {
354
- if (inherit)
355
- return createInheriting(backend, sessionId, inherit, inherit.parentSession, FORK_MAX_BYTES);
356
- return repo.create({ id: sessionId });
357
- }
358
- const session = await repo.open(existing);
359
- await reconcileInterruptedToolCalls(session);
360
- return session;
361
- },
362
- async openIfExists(sessionId) {
363
- const existing = (await repo.list()).find((m) => m.id === sessionId);
364
- return existing ? repo.open(existing) : undefined;
365
- },
366
- };
367
- }
368
- /**
369
- * Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
370
- * `cwd` is recorded in session metadata; defaults to process.cwd().
371
- */
372
- export function jsonlSessionStore(options) {
373
- const cwd = options.cwd ?? process.cwd();
374
- const forkMaxBytes = options.forkMaxBytes ?? FORK_MAX_BYTES;
375
- // Resolve ONCE, by pi's rule (`NodeExecutionEnv.absolutePath` is `resolve(cwd, path)`): the direct
376
- // fs calls below resolve against process.cwd() instead, and a relative `dir` would straddle both.
377
- const root = resolve(cwd, options.dir);
378
- const repo = new JsonlSessionRepo({ fs: new NodeExecutionEnv({ cwd }), sessionsRoot: root });
379
- // Where {@link SessionBackend.createAtomically} stages: a sibling root INSIDE the store root but
380
- // OUTSIDE every lookup — `list({ cwd })` scans only `<root>/<encodedCwd>`, and a cwd-less `list()`
381
- // scans `<root>/*/​*.jsonl`, one level, which `.drafts/<encodedCwd>/*.jsonl` sits below.
382
- //
383
- // A crash, or a handled failure once the draft file exists (a throw from `fill`, or from the
384
- // rename), leaves it behind. Drafts are never resumed, so staleness cannot poison anything — but
385
- // nothing unlinks them either.
386
- const draftRepo = new JsonlSessionRepo({
387
- fs: new NodeExecutionEnv({ cwd }),
388
- sessionsRoot: join(root, ".drafts"),
389
- });
390
- const backend = {
391
- find: async (id) => (await repo.list({ cwd })).find((m) => m.id === id),
392
- open: (m) => repo.open(m),
393
- createAtomically: async (id, from, fill) => {
394
- // `fork` opens the source by its metadata's absolute path, so the draft repo reads the real
395
- // repo's parent file directly while writing into its own root.
396
- const draft = from
397
- ? await draftRepo.fork(from.meta, { cwd, id, entryId: from.atEntryId, position: "at" })
398
- : await draftRepo.create({ id, cwd });
399
- await fill(draft);
400
- // The interface erases the metadata generic; a jsonl draft's metadata always carries `path`.
401
- const draftPath = (await draft.getMetadata()).path;
402
- // `<root>/.drafts/<encodedCwd>/<file>` → `<root>/<encodedCwd>/<file>`: the draft's own
403
- // parent directory NAME is pi's cwd encoding, already computed — read it back rather than
404
- // re-deriving it, and rather than borrowing the parent session's path (a parentless draft has
405
- // none). Same filesystem, so the rename is atomic; the real directory may not exist yet when
406
- // this store has never created a session for this cwd.
407
- const target = join(root, basename(dirname(draftPath)), basename(draftPath));
408
- await mkdir(dirname(target), { recursive: true });
409
- await rename(draftPath, target);
410
- const published = (await repo.list({ cwd })).find((m) => m.path === target);
411
- if (!published)
412
- throw new Error(`published session vanished: ${target}`);
413
- return repo.open(published);
414
- },
415
- bytes: async (m) => (await stat(m.path)).size,
416
- };
417
- return {
418
- async openOrCreate(sessionId, inherit) {
419
- // Caller-provided ids land in jsonl FILENAMES — encode anything unsafe before it reaches disk.
420
- const id = encodeSessionId(sessionId);
421
- // Scope the lookup to this store's cwd: two stores sharing a sessionsRoot must not open each
422
- // other's sessions (pi groups sessions by project dir).
423
- const existing = (await repo.list({ cwd })).find((m) => m.id === id);
424
- if (!existing) {
425
- if (inherit)
426
- return createInheriting(backend, id, inherit, encodeSessionId(inherit.parentSession), forkMaxBytes);
427
- // Straight into the store, unstaged: an empty session is complete the moment its file
428
- // exists, so there is no half for a reader to catch.
429
- return repo.create({ id, cwd });
430
- }
431
- const session = await repo.open(existing);
432
- await reconcileInterruptedToolCalls(session);
433
- return session;
434
- },
435
- async openIfExists(sessionId) {
436
- const id = encodeSessionId(sessionId);
437
- const existing = (await repo.list({ cwd })).find((m) => m.id === id);
438
- return existing ? repo.open(existing) : undefined;
439
- },
440
- };
441
- }
442
- /**
443
- * Filename-safe encoding, INJECTIVE: `[A-Za-z0-9._-]` verbatim, everything else `%XX` (one byte) or
444
- * `%uXXXX` (above it). Two different ids must never encode alike: the encoded id is what
445
- * `openOrCreate` matches on, so a collision is two conversations resolving to ONE session, and —
446
- * since `parentSession` comes through the same encoder — one inheriting from the wrong room. Not the
447
- * same thing as a filename clash: a file is `<timestamp>_<id>.jsonl`, and identity is the `id` its
448
- * metadata carries, not the name on disk.
449
- *
450
- * Injectivity is what the previous form lacked: it padded to a MINIMUM of two hex digits, so an
451
- * escape run could be re-split — `"\u0100"` and `"\u0010" + "0"` both produced `"%100"`. Every
452
- * escape now has a self-describing width, so no two inputs can produce one output. ASCII ids — every
453
- * id the built-in channels mint — encode exactly as before, so existing session FILES keep their
454
- * names; only non-ASCII ids (a custom `route()` could mint one) change, and those are the ones that
455
- * were unsafe anyway.
456
- */
457
- function encodeSessionId(id) {
458
- return id.replace(/[^A-Za-z0-9._-]/g, (c) => {
459
- const code = c.charCodeAt(0);
460
- return code < 0x100
461
- ? `%${code.toString(16).toUpperCase().padStart(2, "0")}`
462
- : `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
463
- });
464
- }
package/dist/host/node.js DELETED
@@ -1,52 +0,0 @@
1
- /**
2
- * Node host (K-axis): mount a route table of Fetch handlers on a node:http server. Post-ACK work
3
- * (e.g. a webhook channel's fire-and-forget turns) runs on this process's event loop and is lost on
4
- * shutdown (the accepted tradeoff until durable execution exists).
5
- */
6
- import { createServer } from "node:http";
7
- import { nodeListener } from "../channels/http.js";
8
- import { text } from "../channels/respond.js";
9
- /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method). */
10
- export function parseRouteKey(key) {
11
- const sp = key.indexOf(" ");
12
- return sp === -1 ? { path: key } : { method: key.slice(0, sp).toUpperCase(), path: key.slice(sp + 1) };
13
- }
14
- /**
15
- * Compose a {@link Routes} table into one handler: exact pathname match (optionally method-qualified),
16
- * 405 when the path exists under another method, 404 otherwise. No params/wildcards.
17
- */
18
- export function router(routes) {
19
- const entries = Object.entries(routes).map(([key, handler]) => ({
20
- ...parseRouteKey(key),
21
- handler,
22
- }));
23
- return (req) => {
24
- const { pathname } = new URL(req.url);
25
- const onPath = entries.filter((e) => e.path === pathname);
26
- if (onPath.length === 0)
27
- return text("not found\n", 404);
28
- const match = onPath.find((e) => e.method === undefined || e.method === req.method);
29
- if (!match)
30
- return text("method not allowed\n", 405);
31
- return match.handler(req);
32
- };
33
- }
34
- /**
35
- * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
36
- * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
37
- * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
38
- * `host` is the bind address; unset means all interfaces (what containers need).
39
- */
40
- export function serveNode(handler, options) {
41
- const server = createServer(nodeListener(async (req) => handler(req)));
42
- const listening = new Promise((resolve, reject) => {
43
- server.once("error", reject); // a bind failure surfaces here, before "listening"
44
- server.listen({ port: options.port, host: options.host }, () => {
45
- server.off("error", reject);
46
- resolve(server.address().port);
47
- });
48
- });
49
- const close = () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
50
- const closeAllConnections = () => server.closeAllConnections();
51
- return { listening, close, closeAllConnections };
52
- }
File without changes
File without changes
File without changes
File without changes