@fastagent-sh/fastagent 0.19.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 (131) hide show
  1. package/dist/atomic-write.d.ts +11 -7
  2. package/dist/atomic-write.js +16 -11
  3. package/dist/channels/agentcore-limits.d.ts +9 -0
  4. package/dist/channels/agentcore-limits.js +9 -0
  5. package/dist/channels/agentcore-service.d.ts +12 -11
  6. package/dist/channels/agentcore-service.js +14 -18
  7. package/dist/channels/agentcore.d.ts +11 -19
  8. package/dist/channels/agentcore.js +14 -7
  9. package/dist/channels/control.d.ts +50 -12
  10. package/dist/channels/control.js +239 -96
  11. package/dist/channels/discover.d.ts +26 -4
  12. package/dist/channels/discover.js +12 -23
  13. package/dist/channels/feishu/feishu-api.js +5 -8
  14. package/dist/channels/feishu/feishu.js +35 -18
  15. package/dist/channels/kit/attachment-path.d.ts +12 -0
  16. package/dist/channels/kit/attachment-path.js +43 -0
  17. package/dist/channels/kit/context-buffer.d.ts +9 -0
  18. package/dist/channels/kit/context-buffer.js +11 -0
  19. package/dist/channels/kit/signature.d.ts +12 -0
  20. package/dist/channels/kit/signature.js +17 -0
  21. package/dist/channels/kit/stop-command.js +3 -3
  22. package/dist/channels/kit/tasks.d.ts +1 -1
  23. package/dist/channels/kit/tasks.js +10 -4
  24. package/dist/channels/kit/turn-store.d.ts +19 -3
  25. package/dist/channels/kit/turn-store.js +29 -2
  26. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  27. package/dist/channels/slack/invoke-turn.js +5 -1
  28. package/dist/channels/slack/onboard.js +5 -5
  29. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  30. package/dist/channels/slack/onboarding-state.js +13 -20
  31. package/dist/channels/slack/register-webhook.js +1 -1
  32. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  33. package/dist/channels/slack/slack-api.js +22 -54
  34. package/dist/channels/slack/slack.js +13 -13
  35. package/dist/channels/telegram/parse.d.ts +21 -1
  36. package/dist/channels/telegram/parse.js +65 -11
  37. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  38. package/dist/channels/telegram/telegram-api.js +5 -6
  39. package/dist/channels/telegram/telegram.d.ts +2 -2
  40. package/dist/channels/telegram/telegram.js +57 -69
  41. package/dist/cli/add-slack.js +6 -5
  42. package/dist/cli/commands/add.js +1 -5
  43. package/dist/cli/commands/attach.d.ts +2 -4
  44. package/dist/cli/commands/attach.js +17 -15
  45. package/dist/cli/commands/deploy.d.ts +50 -5
  46. package/dist/cli/commands/deploy.js +183 -134
  47. package/dist/cli/commands/dev.js +4 -31
  48. package/dist/cli/commands/info.js +1 -2
  49. package/dist/cli/commands/init.js +1 -2
  50. package/dist/cli/commands/start.js +31 -41
  51. package/dist/cli/serve.d.ts +12 -21
  52. package/dist/cli/serve.js +20 -25
  53. package/dist/cli/shared.d.ts +40 -10
  54. package/dist/cli/shared.js +51 -4
  55. package/dist/deploy/agentcore/logs.d.ts +2 -2
  56. package/dist/deploy/agentcore/logs.js +2 -2
  57. package/dist/deploy/agentcore/plan.d.ts +12 -10
  58. package/dist/deploy/agentcore/plan.js +39 -31
  59. package/dist/deploy/agentcore/run.d.ts +5 -4
  60. package/dist/deploy/agentcore/run.js +13 -36
  61. package/dist/deploy/channel-ingress.d.ts +73 -0
  62. package/dist/deploy/channel-ingress.js +101 -0
  63. package/dist/deploy/docker/plan.d.ts +3 -7
  64. package/dist/deploy/docker/plan.js +4 -15
  65. package/dist/deploy/docker/run.d.ts +18 -1
  66. package/dist/deploy/docker/run.js +10 -0
  67. package/dist/deploy/fly/plan.d.ts +4 -5
  68. package/dist/deploy/fly/plan.js +7 -22
  69. package/dist/deploy/fly/run.d.ts +9 -23
  70. package/dist/deploy/fly/run.js +32 -68
  71. package/dist/deploy/preflight.d.ts +6 -7
  72. package/dist/deploy/preflight.js +26 -22
  73. package/dist/deploy/railway/plan.d.ts +4 -5
  74. package/dist/deploy/railway/plan.js +11 -26
  75. package/dist/deploy/railway/run.d.ts +9 -9
  76. package/dist/deploy/railway/run.js +40 -40
  77. package/dist/deploy/runner.js +22 -2
  78. package/dist/deploy/secrets.d.ts +18 -9
  79. package/dist/deploy/secrets.js +64 -12
  80. package/dist/dev-supervisor.js +3 -4
  81. package/dist/engines/pi/agent-session-factory.js +20 -39
  82. package/dist/engines/pi/auth.js +43 -43
  83. package/dist/engines/pi/config.d.ts +1 -1
  84. package/dist/engines/pi/create.d.ts +14 -2
  85. package/dist/engines/pi/create.js +2 -2
  86. package/dist/engines/pi/definition.d.ts +1 -1
  87. package/dist/engines/pi/definition.js +3 -4
  88. package/dist/engines/pi/login.js +32 -16
  89. package/dist/engines/pi/open.d.ts +1 -2
  90. package/dist/engines/pi/open.js +1 -1
  91. package/dist/engines/pi/search-tools.js +1 -1
  92. package/dist/engines/pi/service.d.ts +1 -1
  93. package/dist/engines/pi/service.js +8 -0
  94. package/dist/engines/pi/session-builder.js +6 -33
  95. package/dist/engines/pi/session-control.d.ts +19 -6
  96. package/dist/engines/pi/session-control.js +565 -470
  97. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  98. package/dist/engines/pi/session-inheritance.js +95 -76
  99. package/dist/engines/pi/session-markers.d.ts +48 -0
  100. package/dist/engines/pi/session-markers.js +59 -0
  101. package/dist/engines/pi/session-settings.d.ts +4 -4
  102. package/dist/engines/pi/session-settings.js +7 -4
  103. package/dist/engines/pi/session-store.d.ts +83 -13
  104. package/dist/engines/pi/session-store.js +408 -55
  105. package/dist/engines/pi/tool-context.d.ts +21 -8
  106. package/dist/engines/pi/tool-context.js +29 -4
  107. package/dist/engines/pi/tool.js +2 -2
  108. package/dist/env.js +1 -2
  109. package/dist/loader.d.ts +49 -7
  110. package/dist/loader.js +79 -18
  111. package/dist/paths.d.ts +19 -0
  112. package/dist/paths.js +27 -2
  113. package/dist/scaffold/add-channel.js +2 -2
  114. package/dist/scaffold/init.js +7 -2
  115. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  116. package/dist/schedule/discover.js +3 -15
  117. package/dist/schedule/wake-alarm.d.ts +13 -7
  118. package/dist/schedule/wake-alarm.js +88 -46
  119. package/dist/schedule/wakeups.d.ts +1 -1
  120. package/dist/schedule/wakeups.js +10 -7
  121. package/dist/service.d.ts +1 -2
  122. package/dist/service.js +27 -5
  123. package/dist/session-remote.d.ts +7 -2
  124. package/dist/session-remote.js +162 -92
  125. package/dist/session.d.ts +226 -92
  126. package/dist/session.js +61 -23
  127. package/dist/telegram.d.ts +1 -1
  128. package/dist/telegram.js +1 -1
  129. package/dist/tunnel.d.ts +18 -3
  130. package/dist/tunnel.js +32 -40
  131. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { SessionManager } from "@earendil-works/pi-coding-agent";
1
+ import type { SessionManager } from "@earendil-works/pi-coding-agent";
2
2
  /** What a Caller names when a new session should start from an existing one. */
3
3
  export interface SessionInheritance {
4
4
  /** The session to inherit from. Missing or unreadable → start empty, with a warn: context is not
@@ -19,26 +19,12 @@ export declare function inheritanceCut(parent: SessionManager, branchHints?: str
19
19
  * Copy the parent's path up to `at` into `child`, entry by entry — what a backend with no FILE to
20
20
  * fork has to do instead. Kinds pi models as facts about an entry rather than positions (labels,
21
21
  * the session name) are not copied: they describe the parent's record, not the thread's history.
22
- */
23
- export declare function copyBranchInto(parent: SessionManager, child: SessionManager, at: string): void;
24
- /**
25
- * Fork `parent` into a record named `id`, up to the branch point the hints locate, in `stagingDir`.
26
- *
27
- * Two pi calls rather than one, because neither alone does it: `createBranchedSession` copies
28
- * exactly the path to an entry but names the result with a generated id, and `forkFrom` takes an id
29
- * but copies everything. The intermediate is deleted; it exists for one call.
30
22
  *
31
- * STAGED, not published: the caller finishes the record (crash reconciliation) and moves it into
32
- * place. Publishing here and finishing after would leave a half-prepared record under the id on any
33
- * later failure and the fallback path would then create a SECOND record with the same id, making
34
- * which one a lookup finds a matter of directory order.
35
- *
36
- * Returns undefined when inheritance cannot be honored — the caller starts the session empty.
23
+ * The COPY only. Bounding what the child's model sees is {@link markInheritanceWindow}, which only
24
+ * {@link copyBranchForInheritance} applies: a lifecycle fork that marked a window would hide the
25
+ * exact entries its user forked to keep.
37
26
  */
38
- export declare function forkForInheritance(options: {
39
- parent: SessionManager;
40
- id: string;
41
- cwd: string;
42
- stagingDir: string;
43
- branchHints?: string[];
44
- }): SessionManager | undefined;
27
+ export declare function copyBranchInto(parent: SessionManager, child: SessionManager, at: string): void;
28
+ /** {@link copyBranchInto} plus the inheritance window — what a new THREAD gets and a fork does not.
29
+ * Both backends call THIS one, so neither can drift on where a child's context begins. */
30
+ export declare function copyBranchForInheritance(parent: SessionManager, child: SessionManager, at: string): void;
@@ -1,22 +1,5 @@
1
- /**
2
- * Where a NEW thread starts from, when it names a parent — participant-model.md §5's rule ("a thread
3
- * starts from what the room knew"), on pi's `SessionManager`.
4
- *
5
- * Shape: copy the parent's ACTIVE PATH up to the branch point (everything — text, images, tool
6
- * results — because they are entries, not prompt text), then bound what the MODEL sees with one
7
- * mechanical compaction mark (a plain string; zero model calls). Disk keeps the full copy — storage
8
- * and context are different budgets — and pi honors the mark exactly as it honors a real compaction.
9
- *
10
- * Read ONLY on the create path. An existing session ignores it entirely, which is what makes
11
- * inheritance one-time by construction: no marker to persist, no decision to retry per turn; the
12
- * session existing IS the record that the decision was taken.
13
- *
14
- * Every failure lands on "start empty + warn": a thread must not lose its first turn to an
15
- * inheritance edge.
16
- */
17
- import { unlinkSync } from "node:fs";
18
- import { SessionManager } from "@earendil-works/pi-coding-agent";
19
1
  import { log } from "../../log.js";
2
+ import { isPlaneMarker } from "./session-markers.js";
20
3
  /** Inheritance window: at most this many exchanges of the parent reach the child's model context. */
21
4
  const INHERIT_MAX_EXCHANGES = 50;
22
5
  /** …and at most roughly this many tokens (~1/4 of a 200K context: generous, not everything). Both
@@ -37,8 +20,7 @@ function isUserMessage(entry) {
37
20
  }
38
21
  /** Rough token estimate for windowing — text at chars/4, images flat. Precision is not the point:
39
22
  * the window is a budget, and being 20% off moves a boundary by an exchange, not correctness. */
40
- function estimateMessageTokens(message) {
41
- const content = message.content;
23
+ function estimateContentTokens(content) {
42
24
  if (typeof content === "string")
43
25
  return Math.ceil(content.length / 4);
44
26
  if (!Array.isArray(content))
@@ -54,17 +36,40 @@ function estimateMessageTokens(message) {
54
36
  }
55
37
  return tokens;
56
38
  }
39
+ /** Both entry kinds pi projects into model context from a copied path: a `custom_message` is an
40
+ * extension's injection INTO the conversation, so it charges the budget like any message. */
57
41
  function estimateEntryTokens(entry) {
58
- return entry.type === "message" && entry.message ? estimateMessageTokens(entry.message) : 0;
42
+ if (entry.type === "message")
43
+ return estimateContentTokens(entry.message?.content);
44
+ if (entry.type === "custom_message")
45
+ return estimateContentTokens(entry.content);
46
+ return 0;
59
47
  }
60
- /** A compaction entry's summary and retained tail DO reach the model they are the floor under
61
- * every window that starts above the compaction, so the budget must count them. */
62
- function estimateCompactionTokens(entry) {
63
- if (entry?.type !== "compaction")
48
+ /** A compaction entry's summary AND its retained tail the entries from `firstKeptEntryId` up to
49
+ * the compaction DO reach the model. They are the floor under every window that starts above the
50
+ * compaction, so the budget must count them; the tail is read off the path, since pi-coding-agent's
51
+ * SessionManager stores it as a POINTER and not as messages on the entry (0.84: `appendCompaction`
52
+ * writes `firstKeptEntryId`, and both of AgentSession's compaction paths go through it).
53
+ *
54
+ * pi-agent-core's harness writes the newer self-contained form instead — `retainedTail` on the entry,
55
+ * no pointer. If pi-coding-agent adopts it the pointer stops resolving, and this function prices the
56
+ * tail at zero while {@link copyBranchInto} drops it. Adopting it the way pi-agent-core did, by
57
+ * REPLACING the pointer, fails to typecheck at `Entry.firstKeptEntryId` rather than running quietly;
58
+ * a `retainedTail` added BESIDE the pointer would not, and is the case to watch on the next bump.
59
+ * Either way this is the place to change — not a reason to read both shapes today. */
60
+ function estimateCompactionTokens(path, compactionIdx) {
61
+ const compaction = path[compactionIdx];
62
+ if (compaction?.type !== "compaction")
64
63
  return 0;
65
- let tokens = Math.ceil((entry.summary ?? "").length / 4);
66
- for (const message of entry.retainedTail ?? [])
67
- tokens += estimateMessageTokens(message);
64
+ let tokens = Math.ceil((compaction.summary ?? "").length / 4);
65
+ const firstKept = path.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
66
+ if (firstKept < 0)
67
+ return tokens; // an unresolvable pointer keeps nothing — pi's own reading
68
+ for (let i = firstKept; i < compactionIdx; i++) {
69
+ const entry = path[i];
70
+ if (entry)
71
+ tokens += estimateEntryTokens(entry);
72
+ }
68
73
  return tokens;
69
74
  }
70
75
  /**
@@ -116,7 +121,7 @@ function markInheritanceWindow(child) {
116
121
  const scanned = path.slice(scanFrom);
117
122
  // The compaction's own summary + retained tail reach the model regardless of where the window
118
123
  // lands, so they charge the budget as a base cost — not estimating them would over-admit.
119
- const baseTokens = estimateCompactionTokens(path[scanFrom - 1]);
124
+ const baseTokens = estimateCompactionTokens(path, scanFrom - 1);
120
125
  const starts = [];
121
126
  scanned.forEach((entry, i) => {
122
127
  if (isUserMessage(entry))
@@ -169,74 +174,88 @@ export function inheritanceCut(parent, branchHints) {
169
174
  * Copy the parent's path up to `at` into `child`, entry by entry — what a backend with no FILE to
170
175
  * fork has to do instead. Kinds pi models as facts about an entry rather than positions (labels,
171
176
  * the session name) are not copied: they describe the parent's record, not the thread's history.
177
+ *
178
+ * The COPY only. Bounding what the child's model sees is {@link markInheritanceWindow}, which only
179
+ * {@link copyBranchForInheritance} applies: a lifecycle fork that marked a window would hide the
180
+ * exact entries its user forked to keep.
172
181
  */
173
182
  export function copyBranchInto(parent, child, at) {
183
+ /** Parent entry id → the child's id for that entry: the copy mints its own, and a compaction
184
+ * points BACK into the path it was appended to. */
185
+ const copied = new Map();
186
+ /** Ids of entries this copy did NOT append. A compaction's `firstKeptEntryId` routinely names one:
187
+ * pi walks the cut point backwards onto the metadata entries adjacent to it, which carry no
188
+ * context. They resolve to the next entry that survived — the retained tail starts there — so a
189
+ * dropped anchor moves the boundary by an invisible entry instead of erasing the whole tail. */
190
+ let unanchored = [];
191
+ const record = (parentId, childId) => {
192
+ for (const id of unanchored)
193
+ copied.set(id, childId);
194
+ unanchored = [];
195
+ copied.set(parentId, childId);
196
+ };
174
197
  for (const raw of parent.getBranch(at)) {
175
198
  const entry = raw;
199
+ let childId;
176
200
  switch (entry.type) {
177
201
  case "message":
178
- if (entry.message)
179
- child.appendMessage(entry.message);
202
+ if (entry.message) {
203
+ childId = child.appendMessage(entry.message);
204
+ }
205
+ break;
206
+ case "custom_message":
207
+ // Model-visible history, unlike the `custom` entries below it: an extension injected it INTO
208
+ // the conversation, and the assistant messages answering it are being copied.
209
+ childId = child.appendCustomMessageEntry(entry.customType ?? "", entry.content, entry.display ?? false, entry.details);
180
210
  break;
181
211
  case "compaction":
182
- child.appendCompaction(entry.summary ?? "", child.getLeafId() ?? "", entry.tokensBefore ?? 0, entry.details);
212
+ // `firstKeptEntryId` is where the RETAINED TAIL starts — the entries pi did not summarize,
213
+ // which still reach the model. Translated through the copy rather than pinned to the child's
214
+ // leaf: pinning kept exactly ONE entry, and when that entry was a toolResult (a compaction
215
+ // lands wherever the turn ended) the child's first request opened with a tool result whose
216
+ // call had been summarized away, which every provider rejects. An id the copy never saw
217
+ // keeps nothing, which is what pi itself does with a pointer it cannot resolve.
218
+ childId = child.appendCompaction(entry.summary ?? "", copied.get(entry.firstKeptEntryId ?? "") ?? "", entry.tokensBefore ?? 0, entry.details);
183
219
  break;
184
220
  case "model_change":
185
221
  if (entry.provider && entry.modelId)
186
- child.appendModelChange(entry.provider, entry.modelId);
222
+ childId = child.appendModelChange(entry.provider, entry.modelId);
187
223
  break;
188
224
  case "thinking_level_change":
189
225
  if (entry.thinkingLevel)
190
- child.appendThinkingLevelChange(entry.thinkingLevel);
226
+ childId = child.appendThinkingLevelChange(entry.thinkingLevel);
191
227
  break;
192
228
  case "custom":
193
- if (entry.customType)
194
- child.appendCustomEntry(entry.customType, entry.data);
229
+ // The plane's markers describe the parent's RECORD, not the thread's history: a copied
230
+ // provenance would make a fork of a fork claim its grandparent's branch point (the
231
+ // idempotency check reads that value), and a copied leaf anchor would pin the child's head
232
+ // to a position its own history never chose. Every other custom entry is history and travels
233
+ // — the engine's tool-activation delta above all, since the copied assistant messages call
234
+ // the tools it records.
235
+ if (entry.customType && !isPlaneMarker(entry)) {
236
+ childId = child.appendCustomEntry(entry.customType, entry.data);
237
+ }
195
238
  break;
196
239
  default:
197
240
  break; // label / session_info / branch_summary: the parent's facts, not the thread's history
198
241
  }
242
+ if (childId === undefined)
243
+ unanchored.push(entry.id);
244
+ else
245
+ record(entry.id, childId);
199
246
  }
247
+ }
248
+ /** {@link copyBranchInto} plus the inheritance window — what a new THREAD gets and a fork does not.
249
+ * Both backends call THIS one, so neither can drift on where a child's context begins. */
250
+ export function copyBranchForInheritance(parent, child, at) {
251
+ copyBranchInto(parent, child, at);
200
252
  markInheritanceWindow(child);
201
253
  }
202
- /**
203
- * Fork `parent` into a record named `id`, up to the branch point the hints locate, in `stagingDir`.
204
- *
205
- * Two pi calls rather than one, because neither alone does it: `createBranchedSession` copies
206
- * exactly the path to an entry but names the result with a generated id, and `forkFrom` takes an id
207
- * but copies everything. The intermediate is deleted; it exists for one call.
208
- *
209
- * STAGED, not published: the caller finishes the record (crash reconciliation) and moves it into
210
- * place. Publishing here and finishing after would leave a half-prepared record under the id on any
211
- * later failure — and the fallback path would then create a SECOND record with the same id, making
212
- * which one a lookup finds a matter of directory order.
213
- *
214
- * Returns undefined when inheritance cannot be honored — the caller starts the session empty.
254
+ /*
255
+ * There is deliberately NO file-level fork here. pi can copy a path into a new file
256
+ * (`createBranchedSession` + `forkFrom`), but that pair writes the intermediate only when the copied
257
+ * path contains an ASSISTANT message so forking at a user entry hands `forkFrom` a path that does
258
+ * not exist, and the failure reads as retryable for a condition no retry can change. Copying entries
259
+ * is what a backend with no file to fork has to do anyway, so both share these functions and one
260
+ * semantics; a fork is not hot enough to buy a second path back.
215
261
  */
216
- export function forkForInheritance(options) {
217
- const { parent, id, cwd, stagingDir: dir } = options;
218
- const cut = inheritanceCut(parent, options.branchHints);
219
- if (!cut)
220
- return undefined;
221
- let branched;
222
- try {
223
- branched = parent.createBranchedSession(cut.at);
224
- if (!branched)
225
- return undefined; // a non-persisting parent has no file to fork from
226
- const child = SessionManager.forkFrom(branched, cwd, dir, { id });
227
- markInheritanceWindow(child);
228
- return child;
229
- }
230
- finally {
231
- if (branched) {
232
- try {
233
- unlinkSync(branched);
234
- }
235
- catch (error) {
236
- // The intermediate is inert (it is never listed as this store's record), so a failed
237
- // cleanup costs a stray file, not correctness — but silence would hide a full disk.
238
- log.warn(`[fastagent] could not remove the intermediate fork ${branched}: ${String(error)}`);
239
- }
240
- }
241
- }
242
- }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which journal entries are POSITIONS and which are the control plane's own bookkeeping.
3
+ *
4
+ * pi's journal has one shape for everything, so the plane writes what it needs to remember into the
5
+ * same log the conversation lives in: what fork a record is, and the anchor that makes a leaf move
6
+ * survive a reopen. Both the record store and the history copier have to agree on which is which —
7
+ * one publishes and navigates, the other copies — and a disagreement is invisible until a fork comes
8
+ * back missing something. Hence one module, imported by both, depending on neither.
9
+ */
10
+ import type { SessionManager } from "@earendil-works/pi-coding-agent";
11
+ /** What pins a leaf move to disk. pi's `branch()` writes nothing — the leaf is runtime state, and
12
+ * `open()` puts it back on the file's last entry — so a move that no other write follows is
13
+ * forgotten. Carries no data: its PARENT is the position, which is the whole point of appending it. */
14
+ export declare const LEAF_ANCHOR = "fastagent.leaf";
15
+ /**
16
+ * The CONTROL PLANE's bookkeeping — not a place in a conversation, so never published, never
17
+ * navigable, and never copied by a fork: these describe THIS record, not the history it holds.
18
+ *
19
+ * An EXACT list, not a `fastagent` prefix. The prefix was tried and was wrong for one entry, at a
20
+ * cost worth remembering: `fastagent:tool-activation` is written by the ENGINE and is thread history
21
+ * (which deferred tools this conversation discovered), so matching it here dropped it from every
22
+ * fork and every inherited thread — while the assistant messages that call those tools came along.
23
+ * A new marker joins this list deliberately, which is the point of it being a list.
24
+ */
25
+ export declare function isPlaneMarker(entry: {
26
+ type?: string;
27
+ customType?: string;
28
+ }): boolean;
29
+ /** Every position a client may move the branch head to, and everything `entries()` publishes — ONE
30
+ * predicate, so "anything published is navigable" holds by construction. `label` is metadata ABOUT
31
+ * an entry rather than a place; {@link isPlaneMarker} is ours rather than the conversation's. */
32
+ export declare function isNavigable(entry: {
33
+ type?: string;
34
+ customType?: string;
35
+ }): boolean;
36
+ /**
37
+ * The head a client SEES: the last publishable entry on the active path.
38
+ *
39
+ * Not pi's leaf, which may be a marker — a solo leaf move anchors itself with one — so reporting
40
+ * pi's answer would hand back an id the client did not ask for and cannot find in `entries()`.
41
+ */
42
+ export declare function publishedLeaf(record: SessionManager): string | undefined;
43
+ /** Stamp a fresh fork with where it came from. */
44
+ export declare function stampProvenance(record: SessionManager, provenance: string): void;
45
+ /** What fork this record IS, or undefined for a record that was not forked. The LAST stamp wins — a
46
+ * fork of a fork carries its own — and the whole journal is read rather than the active path, so a
47
+ * later leaf move cannot make a fork stop being one. */
48
+ export declare function forkProvenance(record: SessionManager): string | undefined;
@@ -0,0 +1,59 @@
1
+ /** "This record is a fork of X at Y." Its own entry rather than pi's header `parentSession`, which
2
+ * pi fills from its own fork path and names a FILE, not the branch point idempotency needs. */
3
+ const FORK_PROVENANCE = "fastagent.fork";
4
+ /** What pins a leaf move to disk. pi's `branch()` writes nothing — the leaf is runtime state, and
5
+ * `open()` puts it back on the file's last entry — so a move that no other write follows is
6
+ * forgotten. Carries no data: its PARENT is the position, which is the whole point of appending it. */
7
+ export const LEAF_ANCHOR = "fastagent.leaf";
8
+ /**
9
+ * The CONTROL PLANE's bookkeeping — not a place in a conversation, so never published, never
10
+ * navigable, and never copied by a fork: these describe THIS record, not the history it holds.
11
+ *
12
+ * An EXACT list, not a `fastagent` prefix. The prefix was tried and was wrong for one entry, at a
13
+ * cost worth remembering: `fastagent:tool-activation` is written by the ENGINE and is thread history
14
+ * (which deferred tools this conversation discovered), so matching it here dropped it from every
15
+ * fork and every inherited thread — while the assistant messages that call those tools came along.
16
+ * A new marker joins this list deliberately, which is the point of it being a list.
17
+ */
18
+ export function isPlaneMarker(entry) {
19
+ return entry.type === "custom" && (entry.customType === FORK_PROVENANCE || entry.customType === LEAF_ANCHOR);
20
+ }
21
+ /** Every position a client may move the branch head to, and everything `entries()` publishes — ONE
22
+ * predicate, so "anything published is navigable" holds by construction. `label` is metadata ABOUT
23
+ * an entry rather than a place; {@link isPlaneMarker} is ours rather than the conversation's. */
24
+ export function isNavigable(entry) {
25
+ return entry.type !== "label" && !isPlaneMarker(entry);
26
+ }
27
+ /**
28
+ * The head a client SEES: the last publishable entry on the active path.
29
+ *
30
+ * Not pi's leaf, which may be a marker — a solo leaf move anchors itself with one — so reporting
31
+ * pi's answer would hand back an id the client did not ask for and cannot find in `entries()`.
32
+ */
33
+ export function publishedLeaf(record) {
34
+ const path = record.getBranch();
35
+ for (let i = path.length - 1; i >= 0; i--) {
36
+ const entry = path[i];
37
+ if (entry && isNavigable(entry))
38
+ return entry.id;
39
+ }
40
+ return undefined;
41
+ }
42
+ /** Stamp a fresh fork with where it came from. */
43
+ export function stampProvenance(record, provenance) {
44
+ record.appendCustomEntry(FORK_PROVENANCE, { provenance });
45
+ }
46
+ /** What fork this record IS, or undefined for a record that was not forked. The LAST stamp wins — a
47
+ * fork of a fork carries its own — and the whole journal is read rather than the active path, so a
48
+ * later leaf move cannot make a fork stop being one. */
49
+ export function forkProvenance(record) {
50
+ let found;
51
+ for (const raw of record.getEntries()) {
52
+ if (raw.type !== "custom" || raw.customType !== FORK_PROVENANCE)
53
+ continue;
54
+ const value = raw.data?.provenance;
55
+ if (typeof value === "string")
56
+ found = value;
57
+ }
58
+ return found;
59
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * What a session is SET TO, and what it may be set to. Model and thinking level are ONE setting —
3
3
  * which levels exist is a property of the model — so they resolve together, here, and `state()`, the
4
- * `set_thinking` gate and the per-invoke binding all read this rather than deriving their own.
4
+ * `update({ thinkingLevel })` gate and the per-invoke binding all read this rather than deriving their own.
5
5
  *
6
6
  * Read-only by design. The durable record may hold a level the current model cannot do; that record
7
7
  * is the user's PREFERENCE, so resolving per read restores it when the session returns to a capable
@@ -33,7 +33,7 @@ export interface SessionSettings {
33
33
  model: AnyModel;
34
34
  /** Already clamped to what {@link model} supports. */
35
35
  thinkingLevel: ThinkingLevel;
36
- /** What `set_thinking` accepts for this session. */
36
+ /** What `update({ thinkingLevel })` accepts for this session. */
37
37
  availableThinkingLevels: string[];
38
38
  /** Recorded but not honored — only the execution path reports it (as a warn). */
39
39
  dropped?: {
@@ -52,7 +52,7 @@ export declare function resolveSessionSettings(entries: OverrideEntryLike[], mod
52
52
  }): SessionSettings;
53
53
  /**
54
54
  * The entries on the session's ACTIVE path, root→leaf — what every last-wins settings read walks.
55
- * `getBranch()` is exactly that walk: the journal can hold abandoned branches after a `navigate`,
55
+ * `getBranch()` is exactly that walk: the journal can hold abandoned branches after a leaf move,
56
56
  * and reading it flat would run the session on a setting it moved away from.
57
57
  *
58
58
  * A chain that is not intact THROWS. `getBranch()` stops where a parent is missing and answers the
@@ -60,4 +60,4 @@ export declare function resolveSessionSettings(entries: OverrideEntryLike[], mod
60
60
  * turn silently on assembly defaults. The caller decides what to do with the fault (state() reports
61
61
  * the settings as absent, dispatch answers with a code); what it must not do is guess.
62
62
  */
63
- export declare function activePath(record: SessionManager): OverrideEntryLike[];
63
+ export declare function activePath(record: SessionManager, from?: string): OverrideEntryLike[];
@@ -1,7 +1,8 @@
1
1
  import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
2
  /** Which strings are levels at all — the vocabulary. What a MODEL supports is
3
3
  * `getSupportedThinkingLevels`. The `satisfies` anchor keeps this exhaustive against pi's union: a
4
- * level pi adds becomes a type error here rather than a value `set_thinking` silently rejects. */
4
+ * level pi adds becomes a type error here rather than a value `update({ thinkingLevel })` silently
5
+ * rejects. */
5
6
  const ALL_THINKING_LEVELS = {
6
7
  off: true,
7
8
  minimal: true,
@@ -73,7 +74,7 @@ export function resolveSessionSettings(entries, models, defaults) {
73
74
  }
74
75
  /**
75
76
  * The entries on the session's ACTIVE path, root→leaf — what every last-wins settings read walks.
76
- * `getBranch()` is exactly that walk: the journal can hold abandoned branches after a `navigate`,
77
+ * `getBranch()` is exactly that walk: the journal can hold abandoned branches after a leaf move,
77
78
  * and reading it flat would run the session on a setting it moved away from.
78
79
  *
79
80
  * A chain that is not intact THROWS. `getBranch()` stops where a parent is missing and answers the
@@ -81,8 +82,10 @@ export function resolveSessionSettings(entries, models, defaults) {
81
82
  * turn silently on assembly defaults. The caller decides what to do with the fault (state() reports
82
83
  * the settings as absent, dispatch answers with a code); what it must not do is guess.
83
84
  */
84
- export function activePath(record) {
85
- const path = record.getBranch();
85
+ export function activePath(record, from) {
86
+ // `from` asks a different question: the path a leaf move is ABOUT to make active, which a caller
87
+ // validating a patch needs before the move exists. Absent, it is the session's current path.
88
+ const path = record.getBranch(from);
86
89
  const root = path[0];
87
90
  if (root?.parentId != null) {
88
91
  throw new Error(`session entry "${root.parentId}" is missing from the journal (parent of "${root.id}")`);
@@ -1,13 +1,75 @@
1
1
  import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import type { SessionSummary, SessionUpdateField } from "../../session.ts";
3
+ import { type OverrideEntryLike } from "./session-settings.ts";
2
4
  import { type SessionInheritance } from "./session-inheritance.ts";
3
- /** What the AgentSession L0 needs from a session backend: open-or-create by opaque id, plus the one
4
- * creation option where a NEW session starts from (session-inheritance.ts). */
5
+ /**
6
+ * The session RECORDS, as operations rather than as pi handles.
7
+ *
8
+ * The distinction is the point. `openOrCreate`/`openIfExists` hand out a live `SessionManager` for
9
+ * the two things that genuinely need one — driving a turn, and reading a session's contents — and
10
+ * everything else is a whole-record operation implemented HERE. That line was drawn after the
11
+ * lifecycle work: when the control plane wrote properties by calling pi's append methods itself, it
12
+ * had to know pi's rules to do it (every append advances the single leaf pointer, so order decides
13
+ * where a fork's head lands; `appendSessionInfo` rewrites the name it is given; a record buffers in
14
+ * memory until its first assistant message). Every caller learning those separately is how the same
15
+ * fact ended up half-known in two modules — so they are known once, here.
16
+ */
5
17
  export interface PiSessionRecordStore {
6
18
  openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<SessionManager>;
7
19
  /** OPEN-EXISTING sibling: an unknown session answers undefined, never creates one — sessions are
8
- * the data plane's monopoly. This is what the control plane reads and writes boundary records
9
- * through, so a mutation on an unknown id is rejected rather than minted into a ghost record. */
20
+ * the data plane's monopoly. The READ path (state/entries) and the turn binding use this; the
21
+ * write path does not, because a caller holding a handle is a caller learning pi's rules. */
10
22
  openIfExists(sessionId: string): Promise<SessionManager | undefined>;
23
+ /**
24
+ * Write session properties, in the order pi's leaf pointer requires, and report what LANDED.
25
+ *
26
+ * Each property is its own journal entry — pi has no "append these together" — so a failure
27
+ * partway is a real state, and this answers it rather than pretending a rollback happened. The
28
+ * caller supplies an already-VALIDATED patch (a model resolved to provider+id, a level this model
29
+ * accepts): what belongs here is how a record is written, not what a value means.
30
+ *
31
+ * `undefined` = no such record (it vanished between the caller's check and this call).
32
+ */
33
+ applyProperties(sessionId: string, writes: PropertyWrites): Promise<AppliedProperties | undefined>;
34
+ /** Every record this store holds, in CALLER ids. Rejects when the store cannot be enumerated —
35
+ * `[]` means "no sessions", so it must not double as "could not look". Answers the CONTRACT's row
36
+ * type: the hub forwards it, and a second identical shape here would only be a thing to keep in
37
+ * sync. */
38
+ list(): Promise<SessionSummary[]>;
39
+ /** Copy `from`'s history up to entry `at` into a new record named `into`, stamped with `provenance`
40
+ * so a repeat of the SAME fork can be recognised as one ({@link forkProvenance}) instead of
41
+ * becoming a second record or an overwrite. Throws on any failure: a half-copied fork is never
42
+ * left in place, and the caller turns the throw into a coded result. */
43
+ fork(from: string, at: string, into: string, provenance: string): Promise<void>;
44
+ /** Destroy a record. `false` = there was none (the caller answers `no_such_session`). */
45
+ delete(sessionId: string): Promise<boolean>;
46
+ }
47
+ /** A validated property patch, in the shape a RECORD takes it: the model already resolved to the
48
+ * provider + id pi's append wants, so this layer never asks what a model spec means. */
49
+ export interface PropertyWrites {
50
+ name?: string;
51
+ model?: {
52
+ provider: string;
53
+ id: string;
54
+ };
55
+ thinkingLevel?: string;
56
+ leafEntryId?: string;
57
+ }
58
+ /** What a property write actually did, and what the record holds after it. */
59
+ interface AppliedProperties {
60
+ /** In write order. A field asked for but absent here did not land. */
61
+ landed: SessionUpdateField[];
62
+ /** Why the rest stopped, if anything did. The caller turns this into a coded result; the record
63
+ * already reflects `landed`. */
64
+ failure?: unknown;
65
+ /** The record AFTER the writes — what the caller reports, rather than echoing the request. pi
66
+ * rewrites a name (newlines collapse, ends trim), so the request is not what was stored. */
67
+ name?: string;
68
+ leafEntryId?: string;
69
+ /** The active path after the writes, for a caller that resolves settings against a model registry
70
+ * this layer has no business knowing. Absent when the chain cannot be walked — the caller decides
71
+ * what an unreadable chain means (design §7); it is never silently a short path. */
72
+ path?: OverrideEntryLike[];
11
73
  }
12
74
  /**
13
75
  * A Caller's session id, as a name pi will accept.
@@ -32,24 +94,31 @@ export interface PiSessionRecordStore {
32
94
  * tell which room a file belongs to.
33
95
  */
34
96
  export declare function piSessionId(sessionId: string): string;
97
+ /**
98
+ * {@link piSessionId} backwards — what `list()` needs, because a session id belongs to the CALLER and
99
+ * a record name is storage detail. The encoding is self-describing (fixed widths, `_` escapes
100
+ * itself), so this is a decode rather than a guess; a name this store did not write (no `s` head, a
101
+ * truncated escape) answers undefined and is left out of the listing rather than reported under a
102
+ * name nobody can dial.
103
+ */
104
+ export declare function callerSessionId(recordId: string): string | undefined;
35
105
  /**
36
106
  * Disk-backed store under `dir`: restart the process, conversations continue.
37
107
  *
38
- * Lookup is a directory scan (`SessionManager.list`) because pi names files `<timestamp>_<id>.jsonl`
39
- * and the timestamp is not ours to predict the same trade sessions.ts makes today.
40
- *
41
- * A record written before this store existed keeps ITS id (the older path spelled them differently),
42
- * so the scan accepts either: a conversation that predates this store is continued rather than
43
- * silently restarted as an empty one. Nothing is rewritten on disk.
108
+ * Lookup is a directory scan of THIS store's own directory, reading ids out of the filenames pi
109
+ * writes (`<timestamp>_<id>.jsonl`) not `SessionManager.list`, which filters by the cwd recorded in
110
+ * each header and would make a renamed agent directory look like an empty store (see
111
+ * {@link recordFiles}).
44
112
  *
45
113
  * NEW records live in a subdirectory of their own, because the two engines cannot share a namespace:
46
114
  * both spell ids into `[A-Za-z0-9._-]`, so neither can claim a prefix the other cannot produce, and
47
115
  * a directory holding both would have names that belong to two conversations at once — in whichever
48
116
  * direction it is read. Separate directories make each side's own injectivity sufficient.
49
117
  *
50
- * A PRE-EXISTING record is still continued in place: it is looked up by the older spelling, which is
51
- * injective on its own terms, and appended to where it lies. Both spellings are the same v3 jsonl,
52
- * so a conversation started before this store keeps going rather than restarting empty.
118
+ * A PRE-EXISTING record is continued in place: looked up by the older spelling, which is injective
119
+ * on its own terms, and appended to where it lies. Both spellings are the same v3 jsonl, so a
120
+ * conversation started before this store keeps going rather than restarting empty. Nothing on disk
121
+ * is rewritten.
53
122
  *
54
123
  * SCOPE OF "open-or-create": idempotent against a store that is serialized per session, which is what
55
124
  * the serving path provides — the single-writer lease is taken before any store call, so no two
@@ -68,3 +137,4 @@ export declare function piSessionRecordStore(options: {
68
137
  export declare function piInMemorySessionRecordStore(options?: {
69
138
  cwd?: string;
70
139
  }): PiSessionRecordStore;
140
+ export {};