@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.
- package/README.md +25 -7
- package/dist/agent.d.ts +12 -1
- package/dist/agent.js +1 -1
- package/dist/atomic-write.d.ts +19 -0
- package/dist/atomic-write.js +48 -0
- package/dist/{host/node.d.ts → channel.d.ts} +24 -28
- package/dist/channel.js +1 -0
- package/dist/channels/agentcore-service.d.ts +54 -0
- package/dist/channels/agentcore-service.js +111 -0
- package/dist/channels/agentcore.d.ts +12 -2
- package/dist/channels/agentcore.js +6 -4
- package/dist/channels/busy.d.ts +1 -1
- package/dist/channels/busy.js +1 -1
- package/dist/channels/control.d.ts +16 -6
- package/dist/channels/control.js +106 -4
- package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
- package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +1 -1
- package/dist/channels/feishu/context-buffer.js +4 -4
- package/dist/channels/feishu/feishu-api.d.ts +4 -2
- package/dist/channels/feishu/feishu-api.js +1 -1
- package/dist/channels/feishu/feishu.d.ts +1 -1
- package/dist/channels/feishu/feishu.js +45 -15
- package/dist/channels/feishu/invoke-turn.d.ts +9 -3
- package/dist/channels/feishu/invoke-turn.js +152 -34
- package/dist/channels/feishu/parse.js +6 -0
- package/dist/channels/feishu/preview.d.ts +1 -1
- package/dist/channels/feishu/preview.js +7 -3
- package/dist/channels/feishu/ws-ingress.d.ts +1 -1
- package/dist/channels/github/github.d.ts +1 -1
- package/dist/channels/http.d.ts +11 -7
- package/dist/channels/http.js +16 -111
- package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
- package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
- package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
- package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
- package/dist/channels/{seen.js → kit/seen.js} +1 -1
- package/dist/channels/kit/state.d.ts +6 -0
- package/dist/channels/{state.js → kit/state.js} +4 -24
- package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
- package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
- package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
- package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
- package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
- package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
- package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
- package/dist/channels/lark/lark.d.ts +1 -1
- package/dist/channels/serve.d.ts +73 -0
- package/dist/channels/serve.js +243 -0
- package/dist/channels/slack/bot-auth.js +3 -14
- package/dist/channels/slack/context-buffer.d.ts +2 -2
- package/dist/channels/slack/context-buffer.js +2 -2
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +9 -6
- package/dist/channels/slack/parse.js +2 -2
- package/dist/channels/slack/preview.d.ts +1 -1
- package/dist/channels/slack/preview.js +11 -3
- package/dist/channels/slack/slack-api.js +1 -1
- package/dist/channels/slack/slack.d.ts +1 -1
- package/dist/channels/slack/slack.js +8 -12
- package/dist/channels/slack/welcomed.js +1 -1
- package/dist/channels/telegram/context-buffer.d.ts +2 -2
- package/dist/channels/telegram/context-buffer.js +2 -2
- package/dist/channels/telegram/invoke-turn.d.ts +1 -1
- package/dist/channels/telegram/invoke-turn.js +2 -2
- package/dist/channels/telegram/parse.js +2 -2
- package/dist/channels/telegram/preview.d.ts +1 -1
- package/dist/channels/telegram/preview.js +6 -2
- package/dist/channels/telegram/telegram.d.ts +1 -1
- package/dist/channels/telegram/telegram.js +3 -3
- package/dist/channels/telegram/turn-store.d.ts +2 -2
- package/dist/channels/telegram/turn-store.js +2 -2
- package/dist/cli/commands/add.js +1 -1
- package/dist/cli/commands/deploy.js +13 -5
- package/dist/cli/commands/dev.js +26 -14
- package/dist/cli/commands/fire.js +2 -2
- package/dist/cli/commands/info.js +28 -5
- package/dist/cli/commands/invoke.js +1 -1
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +64 -95
- package/dist/cli/commands/tool.js +5 -7
- package/dist/cli/program.js +1 -1
- package/dist/cli/serve.d.ts +18 -57
- package/dist/cli/serve.js +57 -243
- package/dist/cli/shared.d.ts +5 -3
- package/dist/cli/shared.js +14 -7
- package/dist/collect.d.ts +30 -4
- package/dist/collect.js +39 -6
- package/dist/core.d.ts +3 -5
- package/dist/core.js +7 -5
- package/dist/deploy/preflight.d.ts +8 -2
- package/dist/deploy/preflight.js +22 -4
- package/dist/deploy/secrets.d.ts +3 -0
- package/dist/deploy/secrets.js +6 -0
- package/dist/dev-supervisor.js +16 -4
- package/dist/engines/pi/agent-session-factory.d.ts +104 -0
- package/dist/engines/pi/agent-session-factory.js +314 -0
- package/dist/engines/pi/config.d.ts +4 -4
- package/dist/engines/pi/config.js +1 -1
- package/dist/engines/pi/create.d.ts +56 -42
- package/dist/engines/pi/create.js +150 -75
- package/dist/engines/pi/definition.d.ts +41 -7
- package/dist/engines/pi/definition.js +115 -11
- package/dist/engines/pi/invoke-session.d.ts +35 -0
- package/dist/engines/pi/invoke-session.js +357 -0
- package/dist/engines/pi/login.js +7 -4
- package/dist/engines/pi/models.d.ts +59 -11
- package/dist/engines/pi/models.js +64 -9
- package/dist/engines/pi/open.d.ts +9 -6
- package/dist/engines/pi/open.js +20 -10
- package/dist/engines/pi/report.d.ts +0 -4
- package/dist/engines/pi/report.js +0 -7
- package/dist/engines/pi/service.d.ts +23 -0
- package/dist/engines/pi/service.js +19 -0
- package/dist/engines/pi/session-builder.js +88 -90
- package/dist/engines/pi/session-control.d.ts +11 -9
- package/dist/engines/pi/session-control.js +114 -77
- package/dist/engines/pi/session-inheritance.d.ts +44 -0
- package/dist/engines/pi/session-inheritance.js +242 -0
- package/dist/engines/pi/session-settings.d.ts +14 -2
- package/dist/engines/pi/session-settings.js +18 -0
- package/dist/engines/pi/session-store.d.ts +70 -0
- package/dist/engines/pi/session-store.js +313 -0
- package/dist/engines/pi/tool-context.d.ts +18 -8
- package/dist/engines/pi/tool-context.js +23 -1
- package/dist/engines/pi/tool.d.ts +5 -5
- package/dist/engines/pi/tool.js +1 -1
- package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
- package/dist/engines/pi/turn-kit.js +188 -0
- package/dist/engines/pi/wake-tool.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/log.d.ts +3 -0
- package/dist/log.js +5 -1
- package/dist/node.d.ts +16 -0
- package/dist/node.js +20 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +16 -0
- package/dist/pi.d.ts +15 -8
- package/dist/pi.js +7 -6
- package/dist/scaffold/templates/fastagent.config.mjs +2 -0
- package/dist/scaffold/templates/persona.md +2 -2
- package/dist/schedule/state.js +4 -9
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +1 -1
- package/dist/service.d.ts +158 -0
- package/dist/service.js +354 -0
- package/dist/session-remote.d.ts +1 -1
- package/dist/session-remote.js +10 -2
- package/dist/session.d.ts +4 -2
- package/dist/session.js +1 -1
- package/package.json +14 -5
- package/dist/channels/state.d.ts +0 -16
- package/dist/engines/pi/harness.d.ts +0 -112
- package/dist/engines/pi/harness.js +0 -153
- package/dist/engines/pi/invoke.js +0 -618
- package/dist/engines/pi/read-image.d.ts +0 -4
- package/dist/engines/pi/read-image.js +0 -62
- package/dist/engines/pi/sessions.d.ts +0 -44
- package/dist/engines/pi/sessions.js +0 -182
- package/dist/host/node.js +0 -52
- /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
- /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
- /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
- /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
- /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
- /package/dist/channels/{text.js → kit/text.js} +0 -0
- /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
- /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
import { log } from "../../log.js";
|
|
20
|
+
/** Inheritance window: at most this many exchanges of the parent reach the child's model context. */
|
|
21
|
+
const INHERIT_MAX_EXCHANGES = 50;
|
|
22
|
+
/** …and at most roughly this many tokens (~1/4 of a 200K context: generous, not everything). Both
|
|
23
|
+
* limits govern how far the window EXTENDS into older history — the newest exchange is a FLOOR,
|
|
24
|
+
* kept whole even when it alone exceeds the budget: the mark's boundary is entry-granular, and an
|
|
25
|
+
* inheritance that drops the exchange the thread branched off would be no inheritance at all. */
|
|
26
|
+
const INHERIT_MAX_TOKENS = 50_000;
|
|
27
|
+
/** Branch hints are IDS, not payloads: each one costs a scan over the parent's serialized path, and
|
|
28
|
+
* the wire accepts arbitrary arrays — so the engine caps them where the cost lives. */
|
|
29
|
+
const MAX_BRANCH_HINTS = 16;
|
|
30
|
+
const MAX_BRANCH_HINT_CHARS = 128;
|
|
31
|
+
/** A vision image is priced FLAT — what a provider bills for a resized image, roughly — because its
|
|
32
|
+
* base64 length (~1M chars for a photo) measures storage, not context: pricing it by chars would
|
|
33
|
+
* let one photo evict the whole text window. */
|
|
34
|
+
const INHERIT_IMAGE_TOKENS = 1_600;
|
|
35
|
+
function isUserMessage(entry) {
|
|
36
|
+
return entry?.type === "message" && entry.message?.role === "user";
|
|
37
|
+
}
|
|
38
|
+
/** Rough token estimate for windowing — text at chars/4, images flat. Precision is not the point:
|
|
39
|
+
* 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;
|
|
42
|
+
if (typeof content === "string")
|
|
43
|
+
return Math.ceil(content.length / 4);
|
|
44
|
+
if (!Array.isArray(content))
|
|
45
|
+
return 0;
|
|
46
|
+
let tokens = 0;
|
|
47
|
+
for (const block of content) {
|
|
48
|
+
if (block.type === "image")
|
|
49
|
+
tokens += INHERIT_IMAGE_TOKENS;
|
|
50
|
+
else if (typeof block.text === "string")
|
|
51
|
+
tokens += Math.ceil(block.text.length / 4);
|
|
52
|
+
else
|
|
53
|
+
tokens += Math.ceil(JSON.stringify(block).length / 4);
|
|
54
|
+
}
|
|
55
|
+
return tokens;
|
|
56
|
+
}
|
|
57
|
+
function estimateEntryTokens(entry) {
|
|
58
|
+
return entry.type === "message" && entry.message ? estimateMessageTokens(entry.message) : 0;
|
|
59
|
+
}
|
|
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")
|
|
64
|
+
return 0;
|
|
65
|
+
let tokens = Math.ceil((entry.summary ?? "").length / 4);
|
|
66
|
+
for (const message of entry.retainedTail ?? [])
|
|
67
|
+
tokens += estimateMessageTokens(message);
|
|
68
|
+
return tokens;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Find the fork target on the parent's active path: the LAST message whose content carries a hint
|
|
72
|
+
* (the most recent turn that talked about that message), extended forward to the end of its exchange
|
|
73
|
+
* — forking mid-exchange would inherit a question without its answer. Hints are tried in caller
|
|
74
|
+
* order; the first that matches anywhere wins. No match → undefined (the caller forks the present).
|
|
75
|
+
*/
|
|
76
|
+
function locateBranchPoint(path, hints) {
|
|
77
|
+
const usable = hints
|
|
78
|
+
.filter((hint) => hint.length > 0 && hint.length <= MAX_BRANCH_HINT_CHARS)
|
|
79
|
+
.slice(0, MAX_BRANCH_HINTS);
|
|
80
|
+
if (usable.length < hints.length) {
|
|
81
|
+
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`);
|
|
82
|
+
}
|
|
83
|
+
if (usable.length === 0)
|
|
84
|
+
return undefined;
|
|
85
|
+
// Serialize each message ONCE — the scan is hints × entries, and stringify must not sit in the
|
|
86
|
+
// inner loop. The whole message, not just content: shape-agnostic, and a hint is a platform id —
|
|
87
|
+
// a false positive would need the id to appear outside content, which is where ids live anyway.
|
|
88
|
+
const serialized = path.map((entry) => (entry.type === "message" ? JSON.stringify(entry.message) : ""));
|
|
89
|
+
for (const hint of usable) {
|
|
90
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
91
|
+
if (!serialized[i]?.includes(hint))
|
|
92
|
+
continue;
|
|
93
|
+
let j = i + 1;
|
|
94
|
+
while (j < path.length && !isUserMessage(path[j]))
|
|
95
|
+
j++;
|
|
96
|
+
return path[j - 1]?.id;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Bound what the child's MODEL CONTEXT starts with: keep the newest exchange unconditionally, extend
|
|
103
|
+
* older while both window limits hold, and mark the boundary with a mechanical compaction entry.
|
|
104
|
+
* Entries above the parent's own last compaction are already outside model context and need no mark;
|
|
105
|
+
* a child whose visible history fits the window gets no mark at all.
|
|
106
|
+
*/
|
|
107
|
+
function markInheritanceWindow(child) {
|
|
108
|
+
const path = child.getBranch();
|
|
109
|
+
let scanFrom = 0;
|
|
110
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
111
|
+
if (path[i]?.type === "compaction") {
|
|
112
|
+
scanFrom = i + 1;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const scanned = path.slice(scanFrom);
|
|
117
|
+
// The compaction's own summary + retained tail reach the model regardless of where the window
|
|
118
|
+
// lands, so they charge the budget as a base cost — not estimating them would over-admit.
|
|
119
|
+
const baseTokens = estimateCompactionTokens(path[scanFrom - 1]);
|
|
120
|
+
const starts = [];
|
|
121
|
+
scanned.forEach((entry, i) => {
|
|
122
|
+
if (isUserMessage(entry))
|
|
123
|
+
starts.push(i);
|
|
124
|
+
});
|
|
125
|
+
if (starts.length <= 1)
|
|
126
|
+
return; // zero or one visible exchange — nothing to cut
|
|
127
|
+
const suffixTokens = new Array(scanned.length + 1).fill(0);
|
|
128
|
+
for (let i = scanned.length - 1; i >= 0; i--) {
|
|
129
|
+
const entry = scanned[i];
|
|
130
|
+
suffixTokens[i] = (suffixTokens[i + 1] ?? 0) + (entry ? estimateEntryTokens(entry) : 0);
|
|
131
|
+
}
|
|
132
|
+
let chosen = starts.length - 1;
|
|
133
|
+
for (let k = starts.length - 2; k >= 0; k--) {
|
|
134
|
+
const exchanges = starts.length - k;
|
|
135
|
+
const startIdx = starts[k];
|
|
136
|
+
if (startIdx === undefined)
|
|
137
|
+
break;
|
|
138
|
+
if (exchanges > INHERIT_MAX_EXCHANGES || baseTokens + (suffixTokens[startIdx] ?? 0) > INHERIT_MAX_TOKENS)
|
|
139
|
+
break;
|
|
140
|
+
chosen = k;
|
|
141
|
+
}
|
|
142
|
+
if (chosen === 0)
|
|
143
|
+
return; // the whole visible history fits the window
|
|
144
|
+
const boundaryIdx = starts[chosen];
|
|
145
|
+
if (boundaryIdx === undefined)
|
|
146
|
+
return;
|
|
147
|
+
const boundary = scanned[boundaryIdx];
|
|
148
|
+
if (boundary === undefined)
|
|
149
|
+
return;
|
|
150
|
+
child.appendCompaction(`Inherited from the parent conversation; ${chosen} earlier exchange(s) are not shown.`, boundary.id, (suffixTokens[0] ?? 0) - (suffixTokens[boundaryIdx] ?? 0));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The branch point this inheritance should copy up to, and the parent's path — the decision half,
|
|
154
|
+
* shared by both backends because WHERE a thread branches is policy, not storage.
|
|
155
|
+
*/
|
|
156
|
+
export function inheritanceCut(parent, branchHints) {
|
|
157
|
+
const path = parent.getBranch();
|
|
158
|
+
const leaf = path[path.length - 1];
|
|
159
|
+
if (!leaf)
|
|
160
|
+
return undefined; // an empty parent has nothing to inherit
|
|
161
|
+
const hints = branchHints ?? [];
|
|
162
|
+
const at = locateBranchPoint(path, hints);
|
|
163
|
+
if (at === undefined && hints.length > 0) {
|
|
164
|
+
log.warn("[fastagent] no branch hint matched in the parent session — inheriting from its present");
|
|
165
|
+
}
|
|
166
|
+
return { at: at ?? leaf.id };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Copy the parent's path up to `at` into `child`, entry by entry — what a backend with no FILE to
|
|
170
|
+
* fork has to do instead. Kinds pi models as facts about an entry rather than positions (labels,
|
|
171
|
+
* the session name) are not copied: they describe the parent's record, not the thread's history.
|
|
172
|
+
*/
|
|
173
|
+
export function copyBranchInto(parent, child, at) {
|
|
174
|
+
for (const raw of parent.getBranch(at)) {
|
|
175
|
+
const entry = raw;
|
|
176
|
+
switch (entry.type) {
|
|
177
|
+
case "message":
|
|
178
|
+
if (entry.message)
|
|
179
|
+
child.appendMessage(entry.message);
|
|
180
|
+
break;
|
|
181
|
+
case "compaction":
|
|
182
|
+
child.appendCompaction(entry.summary ?? "", child.getLeafId() ?? "", entry.tokensBefore ?? 0, entry.details);
|
|
183
|
+
break;
|
|
184
|
+
case "model_change":
|
|
185
|
+
if (entry.provider && entry.modelId)
|
|
186
|
+
child.appendModelChange(entry.provider, entry.modelId);
|
|
187
|
+
break;
|
|
188
|
+
case "thinking_level_change":
|
|
189
|
+
if (entry.thinkingLevel)
|
|
190
|
+
child.appendThinkingLevelChange(entry.thinkingLevel);
|
|
191
|
+
break;
|
|
192
|
+
case "custom":
|
|
193
|
+
if (entry.customType)
|
|
194
|
+
child.appendCustomEntry(entry.customType, entry.data);
|
|
195
|
+
break;
|
|
196
|
+
default:
|
|
197
|
+
break; // label / session_info / branch_summary: the parent's facts, not the thread's history
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
markInheritanceWindow(child);
|
|
201
|
+
}
|
|
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.
|
|
215
|
+
*/
|
|
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
|
+
}
|
|
@@ -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
|
|
4
|
+
* `set_thinking` 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
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
12
12
|
import { type Models } from "@earendil-works/pi-ai";
|
|
13
|
-
import type {
|
|
13
|
+
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { AnyModel } from "./models.ts";
|
|
14
15
|
export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
|
|
15
16
|
/** The shape both override consumers walk — a session entry, structurally. */
|
|
16
17
|
export interface OverrideEntryLike {
|
|
@@ -49,3 +50,14 @@ export declare function resolveSessionSettings(entries: OverrideEntryLike[], mod
|
|
|
49
50
|
model: AnyModel;
|
|
50
51
|
thinkingLevel: ThinkingLevel;
|
|
51
52
|
}): SessionSettings;
|
|
53
|
+
/**
|
|
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`,
|
|
56
|
+
* and reading it flat would run the session on a setting it moved away from.
|
|
57
|
+
*
|
|
58
|
+
* A chain that is not intact THROWS. `getBranch()` stops where a parent is missing and answers the
|
|
59
|
+
* SHORT path, which reads exactly like a short session — every override above the gap gone, the next
|
|
60
|
+
* turn silently on assembly defaults. The caller decides what to do with the fault (state() reports
|
|
61
|
+
* the settings as absent, dispatch answers with a code); what it must not do is guess.
|
|
62
|
+
*/
|
|
63
|
+
export declare function activePath(record: SessionManager): OverrideEntryLike[];
|
|
@@ -71,3 +71,21 @@ export function resolveSessionSettings(entries, models, defaults) {
|
|
|
71
71
|
...(dropped.model || dropped.thinkingLevel ? { dropped } : {}),
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* 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
|
+
* and reading it flat would run the session on a setting it moved away from.
|
|
78
|
+
*
|
|
79
|
+
* A chain that is not intact THROWS. `getBranch()` stops where a parent is missing and answers the
|
|
80
|
+
* SHORT path, which reads exactly like a short session — every override above the gap gone, the next
|
|
81
|
+
* turn silently on assembly defaults. The caller decides what to do with the fault (state() reports
|
|
82
|
+
* the settings as absent, dispatch answers with a code); what it must not do is guess.
|
|
83
|
+
*/
|
|
84
|
+
export function activePath(record) {
|
|
85
|
+
const path = record.getBranch();
|
|
86
|
+
const root = path[0];
|
|
87
|
+
if (root?.parentId != null) {
|
|
88
|
+
throw new Error(`session entry "${root.parentId}" is missing from the journal (parent of "${root.id}")`);
|
|
89
|
+
}
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
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
|
+
export interface PiSessionRecordStore {
|
|
6
|
+
openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<SessionManager>;
|
|
7
|
+
/** 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. */
|
|
10
|
+
openIfExists(sessionId: string): Promise<SessionManager | undefined>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A Caller's session id, as a name pi will accept.
|
|
14
|
+
*
|
|
15
|
+
* `SessionManager` enforces `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$` — which no built-in
|
|
16
|
+
* channel satisfies: telegram's `-1001234567890` leads with a dash, feishu and slack keys carry
|
|
17
|
+
* `:` and `/`, and any custom `route()` may mint anything at all.
|
|
18
|
+
*
|
|
19
|
+
* The mapping is INJECTIVE, because two conversations resolving to one record is two rooms sharing a
|
|
20
|
+
* memory. `s` prefix (the pattern demands an alphanumeric head, and prefixing unconditionally is what
|
|
21
|
+
* keeps it injective — a conditional one would map `-a` and `s-a` alike), then each character
|
|
22
|
+
* outside `[A-Za-z0-9.-]` becomes `_XX` / `_uXXXX`, self-describing widths so no two inputs can
|
|
23
|
+
* produce one output. `_` escapes itself for the same reason. A trailing `.` or `-` is legal
|
|
24
|
+
* mid-name but not at the end, so it escapes too.
|
|
25
|
+
*
|
|
26
|
+
* Injective within this encoding — which is only sufficient because new records live in their own
|
|
27
|
+
* directory. The older spelling draws names from the same character set (it stored a room literally
|
|
28
|
+
* called `s42` as `s42`, which is also this encoding of `42`), so one directory would make some
|
|
29
|
+
* names ambiguous no matter how either side spells them.
|
|
30
|
+
*
|
|
31
|
+
* Readability is deliberate: `-1001234567890` becomes `s-1001234567890`, so an operator can still
|
|
32
|
+
* tell which room a file belongs to.
|
|
33
|
+
*/
|
|
34
|
+
export declare function piSessionId(sessionId: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Disk-backed store under `dir`: restart the process, conversations continue.
|
|
37
|
+
*
|
|
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.
|
|
44
|
+
*
|
|
45
|
+
* NEW records live in a subdirectory of their own, because the two engines cannot share a namespace:
|
|
46
|
+
* both spell ids into `[A-Za-z0-9._-]`, so neither can claim a prefix the other cannot produce, and
|
|
47
|
+
* a directory holding both would have names that belong to two conversations at once — in whichever
|
|
48
|
+
* direction it is read. Separate directories make each side's own injectivity sufficient.
|
|
49
|
+
*
|
|
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.
|
|
53
|
+
*
|
|
54
|
+
* SCOPE OF "open-or-create": idempotent against a store that is serialized per session, which is what
|
|
55
|
+
* the serving path provides — the single-writer lease is taken before any store call, so no two
|
|
56
|
+
* turns of one conversation reach this at once. What it does NOT do is arbitrate a FIRST open racing
|
|
57
|
+
* across processes: two instances that scan before either writes will both create, and the
|
|
58
|
+
* conversation forks into two records. sessions.ts states the same boundary for the same reason
|
|
59
|
+
* ("the serving path serializes it with the single-writer lease before reaching any store"), and a
|
|
60
|
+
* horizontally-scaled deployment that wants more owes a lease that spans its instances — an
|
|
61
|
+
* in-process one cannot arbitrate between them, and a file lock here would only look like it could.
|
|
62
|
+
*/
|
|
63
|
+
export declare function piSessionRecordStore(options: {
|
|
64
|
+
dir: string;
|
|
65
|
+
cwd?: string;
|
|
66
|
+
}): PiSessionRecordStore;
|
|
67
|
+
/** In-process store: continuity lives and dies with the instance. */
|
|
68
|
+
export declare function piInMemorySessionRecordStore(options?: {
|
|
69
|
+
cwd?: string;
|
|
70
|
+
}): PiSessionRecordStore;
|