@fastagent-sh/fastagent 0.14.0 → 0.15.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 +3 -3
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/context-buffer.d.ts +30 -0
- package/dist/channels/context-buffer.js +98 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +8 -11
- package/dist/channels/feishu/context-buffer.js +6 -67
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +20 -2
- package/dist/channels/feishu/feishu.d.ts +28 -16
- package/dist/channels/feishu/feishu.js +210 -188
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +10 -62
- package/dist/channels/feishu/model.d.ts +2 -1
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/owned-threads.d.ts +2 -6
- package/dist/channels/feishu/parse.d.ts +1 -1
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +24 -140
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/setup-mode.d.ts +8 -0
- package/dist/channels/feishu/setup-mode.js +2 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +8 -6
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +35 -2
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/preview-kit.d.ts +109 -0
- package/dist/channels/preview-kit.js +183 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/owned-threads.d.ts +6 -0
- package/dist/channels/slack/owned-threads.js +43 -0
- package/dist/channels/slack/parse.d.ts +23 -0
- package/dist/channels/slack/parse.js +81 -0
- package/dist/channels/slack/preview.d.ts +24 -0
- package/dist/channels/slack/preview.js +359 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +77 -0
- package/dist/channels/slack/slack-api.js +415 -0
- package/dist/channels/slack/slack.d.ts +58 -0
- package/dist/channels/slack/slack.js +451 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.js +3 -3
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +13 -0
- package/dist/channels/tasks.js +10 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/telegram.js +23 -9
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
- package/dist/channels/{feishu/text.js → text.js} +1 -1
- package/dist/channels/turn-queue.js +1 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +27 -0
- package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
- package/dist/cli/add-slack.d.ts +10 -0
- package/dist/cli/add-slack.js +204 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +152 -20
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +559 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +6 -1
- package/dist/cli/commands/deploy.d.ts +2 -0
- package/dist/cli/commands/deploy.js +78 -22
- package/dist/cli/commands/dev.js +11 -5
- package/dist/cli/commands/fire.js +3 -3
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/invoke.js +2 -3
- package/dist/cli/commands/login.js +14 -25
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/start.js +15 -8
- package/dist/cli/commands/tool.js +2 -1
- package/dist/cli/fail.d.ts +0 -2
- package/dist/cli/fail.js +1 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +79 -21
- package/dist/cli/serve.d.ts +38 -18
- package/dist/cli/serve.js +197 -39
- package/dist/cli/shared.d.ts +26 -6
- package/dist/cli/shared.js +163 -33
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/docker/plan.d.ts +2 -2
- package/dist/deploy/docker/plan.js +8 -6
- package/dist/deploy/fly/plan.d.ts +3 -1
- package/dist/deploy/fly/plan.js +12 -7
- package/dist/deploy/fly/run.d.ts +5 -4
- package/dist/deploy/fly/run.js +16 -5
- package/dist/deploy/preflight.d.ts +10 -4
- package/dist/deploy/preflight.js +53 -9
- package/dist/deploy/railway/plan.d.ts +3 -1
- package/dist/deploy/railway/plan.js +12 -4
- package/dist/deploy/railway/run.d.ts +5 -4
- package/dist/deploy/railway/run.js +15 -5
- package/dist/deploy/runner.d.ts +2 -1
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +0 -2
- package/dist/dev-supervisor.js +7 -3
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +90 -60
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +14 -9
- package/dist/engines/pi/config.js +33 -18
- package/dist/engines/pi/create.d.ts +22 -2
- package/dist/engines/pi/create.js +20 -14
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +11 -0
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +56 -10
- package/dist/engines/pi/models.js +61 -23
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +6 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/engines/pi/workspace.d.ts +52 -0
- package/dist/engines/pi/workspace.js +72 -15
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +23 -6
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/pi.d.ts +5 -4
- package/dist/pi.js +2 -1
- package/dist/scaffold/add-channel.d.ts +19 -6
- package/dist/scaffold/add-channel.js +126 -17
- package/dist/scaffold/templates/fastagent.config.mjs +4 -3
- package/dist/schedule/wakeups.d.ts +0 -3
- package/dist/schedule/wakeups.js +1 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +21 -10
- package/package.json +19 -7
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pi implementation of the session control plane: observation (`state`/`entries`/`events`,
|
|
3
|
+
* design Phase 1) and run modulation (`dispatch`: steer/follow_up/abort, Phase 2a) over
|
|
4
|
+
* invoke-driven runs. `createPiSessionControl` returns the neutral `SessionControl` plus the
|
|
5
|
+
* {@link SessionObserver} to plug into the invoke pipeline (`createPiAgentFromHarness({ observer })`)
|
|
6
|
+
* — the hub derives everything from the rich event stream (plus the {@link RunControls} the
|
|
7
|
+
* run_started event carries), holds no durable state of its own, and never writes: durable truth
|
|
8
|
+
* stays in the session repository (read via {@link PiSessionReader}), live truth in the events the
|
|
9
|
+
* data plane emits, modulation in the controls the data plane registers.
|
|
10
|
+
*
|
|
11
|
+
* Boundary mutations (Phase 2b: compact/set_model/set_thinking) take the same lease as runs;
|
|
12
|
+
* without boundary wiring they are rejected before acceptance with `unsupported_capability` — a
|
|
13
|
+
* client gating on `capabilities()` never sends them.
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_COMPACTION_SETTINGS, compact, prepareCompaction } from "@earendil-works/pi-agent-core";
|
|
16
|
+
import { SESSION_BUSY_CODE } from "../../agent.js";
|
|
17
|
+
import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
|
|
18
|
+
import { listModels } from "./config.js";
|
|
19
|
+
import { SUMMARIZATION_RETRY_POLICY, THINKING_LEVELS, harnessSession, lastOverrideEntries, } from "./harness.js";
|
|
20
|
+
import { log } from "../../log.js";
|
|
21
|
+
// ── Entry normalization (durable plane) ──────────────────────────────────────
|
|
22
|
+
/** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
|
|
23
|
+
* AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
|
|
24
|
+
function textOf(content) {
|
|
25
|
+
if (typeof content === "string")
|
|
26
|
+
return content;
|
|
27
|
+
if (!Array.isArray(content))
|
|
28
|
+
return "";
|
|
29
|
+
return content
|
|
30
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
31
|
+
.map((b) => b.text)
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* pi `SessionTreeEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
|
|
36
|
+
* kind vocabulary (user/assistant/tool) with a minimal render payload; every other engine record
|
|
37
|
+
* keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
|
|
38
|
+
* cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
|
|
39
|
+
*/
|
|
40
|
+
function toSessionEntry(entry) {
|
|
41
|
+
const base = {
|
|
42
|
+
id: entry.id,
|
|
43
|
+
parentId: entry.parentId ?? undefined,
|
|
44
|
+
timestamp: Date.parse(entry.timestamp),
|
|
45
|
+
};
|
|
46
|
+
if (entry.type === "message") {
|
|
47
|
+
const m = entry.message;
|
|
48
|
+
if (m.role === "user")
|
|
49
|
+
return { ...base, kind: "user", data: { text: textOf(m.content) } };
|
|
50
|
+
if (m.role === "assistant") {
|
|
51
|
+
const toolCalls = m.content
|
|
52
|
+
.filter((b) => b.type === "toolCall")
|
|
53
|
+
.map((b) => ({ id: b.id ?? "", name: b.name ?? "" }));
|
|
54
|
+
const data = { text: textOf(m.content) };
|
|
55
|
+
if (toolCalls.length > 0)
|
|
56
|
+
data.toolCalls = toolCalls;
|
|
57
|
+
return { ...base, kind: "assistant", data };
|
|
58
|
+
}
|
|
59
|
+
if (m.role === "toolResult") {
|
|
60
|
+
return {
|
|
61
|
+
...base,
|
|
62
|
+
kind: "tool",
|
|
63
|
+
data: {
|
|
64
|
+
toolCallId: m.toolCallId,
|
|
65
|
+
toolName: m.toolName,
|
|
66
|
+
isError: m.isError ?? false,
|
|
67
|
+
text: textOf(m.content),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// A custom AgentMessage role (channel/extension-defined): open-set kind, skippable.
|
|
72
|
+
return { ...base, kind: `message:${m.role}`, data: {} };
|
|
73
|
+
}
|
|
74
|
+
return { ...base, kind: entry.type, data: {} };
|
|
75
|
+
}
|
|
76
|
+
// ── Live fan-out (events plane) ──────────────────────────────────────────────
|
|
77
|
+
/** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
|
|
78
|
+
* connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
|
|
79
|
+
* has its buffer FROZEN at the cap (memory bounded — the actual goal: ≈10k small events ≈ a few
|
|
80
|
+
* MB worst case per stuck connection) and its subscription marked closed. The close is observed
|
|
81
|
+
* via pulls — which a stalled connection by definition does not make — so a consumer that RESUMES
|
|
82
|
+
* pulling first drains the frozen backlog, then gets done (no buffered event dropped), while a
|
|
83
|
+
* permanently stalled one holds the frozen buffer until its TCP connection dies. Recovery either
|
|
84
|
+
* way is the standard reconnect+backfill, semantically lossless. */
|
|
85
|
+
export const SUBSCRIBER_BUFFER_CAP = 10_000;
|
|
86
|
+
/** One subscriber's push→pull queue, capped at {@link SUBSCRIBER_BUFFER_CAP}. `close()` settles a
|
|
87
|
+
* pending pull — an async generator suspended on a quiet stream cannot be ended by `return()`
|
|
88
|
+
* alone (it queues behind the never-settling await), so teardown needs this explicit door. */
|
|
89
|
+
class Subscriber {
|
|
90
|
+
/** For the overflow diagnostic only — a warn without the session is not actionable on a
|
|
91
|
+
* multi-session serve. (Explicit assignment: TS parameter properties break Node's strip-only
|
|
92
|
+
* type erasure, which the CLI runs under.) */
|
|
93
|
+
session;
|
|
94
|
+
constructor(session) {
|
|
95
|
+
this.session = session;
|
|
96
|
+
}
|
|
97
|
+
buffer = [];
|
|
98
|
+
// A QUEUE of waiters, not a single slot: concurrent next() calls are contract-legal (any wrapper
|
|
99
|
+
// may poll twice), and a single `wake` field would let the second await overwrite the first's
|
|
100
|
+
// resolver — hanging the first next() forever. Every wake flushes all waiters; each re-checks the
|
|
101
|
+
// buffer and re-queues if another consumer won the event.
|
|
102
|
+
wakes = [];
|
|
103
|
+
closed = false;
|
|
104
|
+
flush() {
|
|
105
|
+
const wakes = this.wakes;
|
|
106
|
+
this.wakes = [];
|
|
107
|
+
for (const wake of wakes)
|
|
108
|
+
wake();
|
|
109
|
+
}
|
|
110
|
+
push(event) {
|
|
111
|
+
if (this.closed)
|
|
112
|
+
return;
|
|
113
|
+
if (this.buffer.length >= SUBSCRIBER_BUFFER_CAP) {
|
|
114
|
+
log.warn(`[fastagent] session-control subscriber for session "${this.session}" is ${SUBSCRIBER_BUFFER_CAP} events behind — no further events buffered; its stream ends after draining the backlog (or at connection death), then the client resyncs via entries()`);
|
|
115
|
+
this.close();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.buffer.push(event);
|
|
119
|
+
this.flush();
|
|
120
|
+
}
|
|
121
|
+
close() {
|
|
122
|
+
this.closed = true;
|
|
123
|
+
this.flush();
|
|
124
|
+
}
|
|
125
|
+
async next() {
|
|
126
|
+
while (true) {
|
|
127
|
+
if (this.buffer.length > 0)
|
|
128
|
+
return { done: false, value: this.buffer.shift() };
|
|
129
|
+
if (this.closed)
|
|
130
|
+
return { done: true, value: undefined };
|
|
131
|
+
await new Promise((resolve) => {
|
|
132
|
+
this.wakes.push(resolve);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Build the observation hub. Wire `observer` into the SAME agent assembly that serves the sessions:
|
|
139
|
+
*
|
|
140
|
+
* ```ts
|
|
141
|
+
* const { control, observer } = createPiSessionControl({ sessions });
|
|
142
|
+
* const agent = createPiAgent({ ..., sessions, observer });
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export function createPiSessionControl(options) {
|
|
146
|
+
const { sessions, boundary } = options;
|
|
147
|
+
/** Live run state per session — derived purely from run_started/run_settled and the controls
|
|
148
|
+
* registered with run_started. */
|
|
149
|
+
const active = new Map();
|
|
150
|
+
const subscribers = new Map();
|
|
151
|
+
/** Sessions with a manual compaction in flight — reported as `status: "compacting"`, keyed to
|
|
152
|
+
* the summarization's AbortController so `abort` has a door into it (run/compaction symmetry:
|
|
153
|
+
* both are model calls a client must be able to stop). Set at ADMISSION, cleared by the
|
|
154
|
+
* detached task before `compaction_finished`. */
|
|
155
|
+
const compacting = new Map();
|
|
156
|
+
/** Fan an event out to this session's subscribers — shared by the observer (run events) and the
|
|
157
|
+
* boundary mutations (session-level events, no runId). */
|
|
158
|
+
const fanOut = (session, event) => {
|
|
159
|
+
const subs = subscribers.get(session);
|
|
160
|
+
if (subs)
|
|
161
|
+
for (const sub of [...subs])
|
|
162
|
+
sub.push(event);
|
|
163
|
+
};
|
|
164
|
+
/** Emit a HUB-generated event: subscribers first, then the external tap — the boundary-event
|
|
165
|
+
* half of a full-vocabulary tap (run events reach it through the observer composition). */
|
|
166
|
+
const emitOwn = (session, event) => {
|
|
167
|
+
fanOut(session, event);
|
|
168
|
+
try {
|
|
169
|
+
options.tap?.(session, event);
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// Same discipline as the data plane's observer guard: a broken tap is its own problem.
|
|
173
|
+
log.warn(`[fastagent] session-control tap threw (event ${event.type}): ${String(error)}`);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const observer = (session, event, run) => {
|
|
177
|
+
if (event.type === "run_started" && event.runId) {
|
|
178
|
+
active.set(session, { runId: event.runId, controls: run, pending: { steering: 0, followUp: 0 } });
|
|
179
|
+
}
|
|
180
|
+
else if (event.type === "run_settled") {
|
|
181
|
+
active.delete(session);
|
|
182
|
+
}
|
|
183
|
+
else if (event.type === "queue_changed") {
|
|
184
|
+
const entry = active.get(session);
|
|
185
|
+
if (entry)
|
|
186
|
+
entry.pending = event.data;
|
|
187
|
+
}
|
|
188
|
+
fanOut(session, event);
|
|
189
|
+
};
|
|
190
|
+
const control = {
|
|
191
|
+
capabilities: () => {
|
|
192
|
+
const b = boundary?.();
|
|
193
|
+
return {
|
|
194
|
+
steering: true,
|
|
195
|
+
followUp: true,
|
|
196
|
+
manualCompaction: !!b,
|
|
197
|
+
modelSelection: b ? { allowedModels: listModels(b.models) } : false,
|
|
198
|
+
thinkingLevel: b ? { allowedLevels: [...THINKING_LEVELS] } : false,
|
|
199
|
+
toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
|
|
200
|
+
usage: false,
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
async state(session) {
|
|
204
|
+
const run = active.get(session);
|
|
205
|
+
const opened = await sessions.openIfExists(session);
|
|
206
|
+
const leafEntryId = opened ? ((await opened.getLeafId()) ?? undefined) : undefined;
|
|
207
|
+
// The durable overrides (set_model / set_thinking), via the SAME walk the harness resolve
|
|
208
|
+
// uses (lastOverrideEntries) — one physical implementation, so the reporting surface and the
|
|
209
|
+
// execution surface can never disagree on which record is "the" override. Reported as
|
|
210
|
+
// recorded, even if the current registry lacks the model (state reports session truth; the
|
|
211
|
+
// harness resolve owns the execution fallback).
|
|
212
|
+
let model;
|
|
213
|
+
let thinkingLevel;
|
|
214
|
+
if (opened) {
|
|
215
|
+
const recorded = lastOverrideEntries((await opened.getEntries()));
|
|
216
|
+
if (recorded.model)
|
|
217
|
+
model = `${recorded.model.provider}/${recorded.model.modelId}`;
|
|
218
|
+
thinkingLevel = recorded.thinkingLevel;
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
|
|
222
|
+
...(run ? { activeRunId: run.runId } : {}),
|
|
223
|
+
...(model !== undefined ? { model } : {}),
|
|
224
|
+
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
|
225
|
+
pending: run ? { ...run.pending } : { steering: 0, followUp: 0 },
|
|
226
|
+
...(leafEntryId ? { leafEntryId } : {}),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
async entries(session, opts) {
|
|
230
|
+
const opened = await sessions.openIfExists(session);
|
|
231
|
+
if (!opened)
|
|
232
|
+
return { entries: [] };
|
|
233
|
+
const all = (await opened.getEntries()).map(toSessionEntry);
|
|
234
|
+
const leafEntryId = (await opened.getLeafId()) ?? undefined;
|
|
235
|
+
let entries = all;
|
|
236
|
+
if (opts?.since !== undefined) {
|
|
237
|
+
const idx = all.findIndex((e) => e.id === opts.since);
|
|
238
|
+
// Unknown cursor → full backfill (correct, merely larger): the client's cursor may predate
|
|
239
|
+
// a repository the session was rebuilt into; silently skipping records would lose history.
|
|
240
|
+
if (idx >= 0)
|
|
241
|
+
entries = all.slice(idx + 1);
|
|
242
|
+
}
|
|
243
|
+
return { entries, ...(leafEntryId ? { leafEntryId } : {}) };
|
|
244
|
+
},
|
|
245
|
+
events(session) {
|
|
246
|
+
// EVERY ITERATION IS A FRESH SUBSCRIPTION — the per-subscription state lives inside
|
|
247
|
+
// asyncIterator(), matching the remote client (one connection per iteration): two concurrent
|
|
248
|
+
// iterations each get the full stream, and one iteration's end does not poison the next.
|
|
249
|
+
// Registration happens on the FIRST next(), not at iterator creation: subscription semantics
|
|
250
|
+
// = you are subscribed while you iterate; an iterator obtained but never driven must not
|
|
251
|
+
// buffer. Teardown goes through Subscriber.close() so a `return()` on a QUIET stream
|
|
252
|
+
// resolves promptly instead of queueing behind a never-settling pull — without it every
|
|
253
|
+
// attach/detach against an idle session would leak a permanently registered subscriber.
|
|
254
|
+
return {
|
|
255
|
+
[Symbol.asyncIterator]() {
|
|
256
|
+
let sub;
|
|
257
|
+
// `finished` is its own state: `sub === undefined` alone would conflate "not yet
|
|
258
|
+
// registered" with "terminated", and a post-done next() would silently REGISTER A FRESH
|
|
259
|
+
// subscription — the exact ghost-subscriber leak this class exists to prevent, reachable
|
|
260
|
+
// by any wrapper that polls one extra time. done is terminal, per the iterator protocol.
|
|
261
|
+
let finished = false;
|
|
262
|
+
const cleanup = () => {
|
|
263
|
+
finished = true;
|
|
264
|
+
if (!sub)
|
|
265
|
+
return;
|
|
266
|
+
sub.close();
|
|
267
|
+
const set = subscribers.get(session);
|
|
268
|
+
if (set) {
|
|
269
|
+
set.delete(sub);
|
|
270
|
+
if (set.size === 0)
|
|
271
|
+
subscribers.delete(session);
|
|
272
|
+
}
|
|
273
|
+
sub = undefined;
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
async next() {
|
|
277
|
+
if (finished)
|
|
278
|
+
return { done: true, value: undefined };
|
|
279
|
+
if (!sub) {
|
|
280
|
+
sub = new Subscriber(session);
|
|
281
|
+
let set = subscribers.get(session);
|
|
282
|
+
if (!set) {
|
|
283
|
+
set = new Set();
|
|
284
|
+
subscribers.set(session, set);
|
|
285
|
+
}
|
|
286
|
+
set.add(sub);
|
|
287
|
+
}
|
|
288
|
+
const result = await sub.next();
|
|
289
|
+
if (result.done)
|
|
290
|
+
cleanup();
|
|
291
|
+
return result;
|
|
292
|
+
},
|
|
293
|
+
async return(value) {
|
|
294
|
+
cleanup();
|
|
295
|
+
return { done: true, value: value };
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
async dispatch(session, command) {
|
|
302
|
+
switch (command.type) {
|
|
303
|
+
case "steer":
|
|
304
|
+
case "follow_up":
|
|
305
|
+
case "abort": {
|
|
306
|
+
const run = active.get(session);
|
|
307
|
+
if (!run) {
|
|
308
|
+
// Run/compaction symmetry: an in-flight manual compaction is a model call too, and
|
|
309
|
+
// `abort` is its only door — interrupting the harness converges through the detached
|
|
310
|
+
// task's catch into `compaction_finished{aborted}` with the lease released; answering
|
|
311
|
+
// no_active_run against a state() that says "compacting" would be a lie.
|
|
312
|
+
const comp = command.type === "abort" ? compacting.get(session) : undefined;
|
|
313
|
+
if (comp) {
|
|
314
|
+
comp.abort();
|
|
315
|
+
return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
|
|
316
|
+
}
|
|
317
|
+
// Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false —
|
|
318
|
+
// as-is retry fails again; re-dispatch after state() shows an active run.
|
|
319
|
+
return {
|
|
320
|
+
ok: false,
|
|
321
|
+
error: {
|
|
322
|
+
code: NO_ACTIVE_RUN_CODE,
|
|
323
|
+
message: `no active run for this session — ${command.type} modulates a run an invoke is driving`,
|
|
324
|
+
retryable: false,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (!run.controls) {
|
|
329
|
+
// A run EXISTS (state() rightly reports running) but was registered observation-only
|
|
330
|
+
// (the observer seam allows run_started without controls). That is a CAPABILITY
|
|
331
|
+
// problem, not a run problem — permanent for this wiring, so neither no_active_run
|
|
332
|
+
// (would poll forever) nor run_command_failed (transient) fits.
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
error: {
|
|
336
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
337
|
+
message: `the active run registered without modulation controls (observation-only) — ${command.type} cannot reach it`,
|
|
338
|
+
retryable: false,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
if (command.type === "steer")
|
|
344
|
+
await run.controls.steer(command.prompt);
|
|
345
|
+
else if (command.type === "follow_up")
|
|
346
|
+
await run.controls.followUp(command.prompt);
|
|
347
|
+
else
|
|
348
|
+
await run.controls.abort();
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
// The run raced us to settlement, failed setup, or the engine refused: still
|
|
352
|
+
// pre-acceptance (nothing was queued), distinct from "no run existed". retryable:
|
|
353
|
+
// false for the same reason — the run is gone; consult state() before re-dispatching.
|
|
354
|
+
return {
|
|
355
|
+
ok: false,
|
|
356
|
+
error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false },
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
// Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
|
|
360
|
+
return { ok: true, runId: run.runId };
|
|
361
|
+
}
|
|
362
|
+
case "compact":
|
|
363
|
+
case "set_model":
|
|
364
|
+
case "set_thinking": {
|
|
365
|
+
const b = boundary?.();
|
|
366
|
+
if (!b) {
|
|
367
|
+
// No boundary wiring: rejected before acceptance; a capability-gating client never
|
|
368
|
+
// lands here.
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: {
|
|
372
|
+
code: UNSUPPORTED_CAPABILITY_CODE,
|
|
373
|
+
message: `command "${command.type}" is not supported by this runtime (no boundary wiring)`,
|
|
374
|
+
retryable: false,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// Payload validation BEFORE the lease — an invalid value must not briefly block a run.
|
|
379
|
+
/** The entry-append for set_model/set_thinking — undefined for compact (harness path). */
|
|
380
|
+
let apply;
|
|
381
|
+
if (command.type === "set_model") {
|
|
382
|
+
const slash = command.model.indexOf("/");
|
|
383
|
+
const model = slash > 0 ? b.models.getModel(command.model.slice(0, slash), command.model.slice(slash + 1)) : undefined;
|
|
384
|
+
if (!model) {
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
error: {
|
|
388
|
+
code: INVALID_COMMAND_CODE,
|
|
389
|
+
message: `unknown model "${command.model}" — capabilities().modelSelection lists the allowed specs`,
|
|
390
|
+
retryable: false,
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
apply = async (s) => {
|
|
395
|
+
await s.appendModelChange(model.provider, model.id);
|
|
396
|
+
// The CANONICAL spec, same string the durable entry and state() report — the event
|
|
397
|
+
// must not echo a client alias the other two surfaces would disagree with.
|
|
398
|
+
return { type: "state_changed", timestamp: Date.now(), data: { model: `${model.provider}/${model.id}` } };
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
else if (command.type === "set_thinking") {
|
|
402
|
+
if (!THINKING_LEVELS.has(command.level)) {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
error: {
|
|
406
|
+
code: INVALID_COMMAND_CODE,
|
|
407
|
+
message: `unknown thinking level "${command.level}" — capabilities().thinkingLevel lists the allowed values`,
|
|
408
|
+
retryable: false,
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
apply = async (s) => {
|
|
413
|
+
await s.appendThinkingLevelChange(command.level);
|
|
414
|
+
return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
// Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
|
|
418
|
+
// not minted into a ghost record. (Existence check before the lease — read-only; the
|
|
419
|
+
// WRITE handle is re-opened under the lease below, this one is discarded.)
|
|
420
|
+
if (!(await sessions.openIfExists(session))) {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
error: {
|
|
424
|
+
code: NO_SUCH_SESSION_CODE,
|
|
425
|
+
message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
|
|
426
|
+
retryable: false,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// Boundary mutations are the control plane's only writers: same lease as every run — a
|
|
431
|
+
// mutation must never race one (design §9).
|
|
432
|
+
const release = b.lease.tryAcquire(session);
|
|
433
|
+
if (!release) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
error: {
|
|
437
|
+
code: SESSION_BUSY_CODE,
|
|
438
|
+
message: "session busy: a run (or another boundary mutation) is in flight — retry at idle",
|
|
439
|
+
retryable: true,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (command.type === "compact") {
|
|
444
|
+
// ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
|
|
445
|
+
// the dispatch open until it finishes made acceptance = outcome, the one exception to
|
|
446
|
+
// §5.2, and broke remote clients whose request timeouts are sized for control calls.
|
|
447
|
+
// The dispatch answers once the work is ADMITTED (lease held, harness built); the
|
|
448
|
+
// outcome travels as compaction_finished{summary|error|aborted}, the bounds contract watchers
|
|
449
|
+
// already rely on. Pre-acceptance failures (the harness build) still reject here.
|
|
450
|
+
// The admission step is EVERYTHING cheap and local: the harness build (the ONE
|
|
451
|
+
// canonical resolution of session overrides + auth) plus the compaction PREPARATION
|
|
452
|
+
// (a pure branch-read computation) — the boundary between "reject the dispatch" and
|
|
453
|
+
// "outcome travels as an event" sits where the work becomes asynchronous and
|
|
454
|
+
// expensive: the model call. "Nothing to compact" is thus a pre-acceptance answer,
|
|
455
|
+
// never a finished{error} dressed as a failure. The summarization runs through pi's
|
|
456
|
+
// compaction primitives instead of harness.compact() for exactly one reason: the
|
|
457
|
+
// harness surface passes no signal to its model call, so an in-flight compaction
|
|
458
|
+
// would be uncancellable — and `abort` needs a real door (run/compaction symmetry).
|
|
459
|
+
let harness;
|
|
460
|
+
try {
|
|
461
|
+
harness = await b.harnessFactory(session);
|
|
462
|
+
}
|
|
463
|
+
catch (error) {
|
|
464
|
+
release();
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const teardown = async () => {
|
|
471
|
+
try {
|
|
472
|
+
await harness.abort(); // fresh-harness discipline
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
log.warn(`[fastagent] compaction harness teardown failed: ${String(error)}`);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
let record;
|
|
479
|
+
let preparation;
|
|
480
|
+
try {
|
|
481
|
+
const bound = harnessSession(harness);
|
|
482
|
+
if (!bound)
|
|
483
|
+
throw new Error("harness has no bound session (factory invariant broken)");
|
|
484
|
+
record = bound;
|
|
485
|
+
const prep = prepareCompaction(await record.getBranch(), DEFAULT_COMPACTION_SETTINGS);
|
|
486
|
+
if (!prep.ok)
|
|
487
|
+
throw prep.error;
|
|
488
|
+
if (!prep.value) {
|
|
489
|
+
await teardown();
|
|
490
|
+
release();
|
|
491
|
+
// A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
|
|
492
|
+
// machine-distinguish "give up" from "re-dispatch once the session grows", and
|
|
493
|
+
// branching on message prose is forbidden by contract.
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
error: {
|
|
497
|
+
code: NOTHING_TO_COMPACT_CODE,
|
|
498
|
+
message: "nothing to compact — the session has no compactable history yet; retry after more turns",
|
|
499
|
+
retryable: false,
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
preparation = prep.value;
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
await teardown();
|
|
507
|
+
release();
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const door = new AbortController();
|
|
514
|
+
compacting.set(session, door); // admission complete: from here `abort` reaches the model call
|
|
515
|
+
emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
|
|
516
|
+
void (async () => {
|
|
517
|
+
let outcome;
|
|
518
|
+
try {
|
|
519
|
+
const done = await compact(preparation, b.models, harness.getModel(), command.instructions, door.signal, harness.getThinkingLevel(), SUMMARIZATION_RETRY_POLICY, {
|
|
520
|
+
// Retries are otherwise invisible between compaction_started and _finished —
|
|
521
|
+
// surface each backoff so a long gap is diagnosable (not confusable with a
|
|
522
|
+
// hang): as a session event for attached observers, as a warn for server logs.
|
|
523
|
+
onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
|
|
524
|
+
log.warn(`[fastagent] compaction retry ${attempt}/${maxAttempts} in ${delayMs}ms (session ${session}): ${errorMessage}`);
|
|
525
|
+
emitOwn(session, {
|
|
526
|
+
type: "retry_scheduled",
|
|
527
|
+
timestamp: Date.now(),
|
|
528
|
+
data: { operation: "compaction", attempt, maxAttempts, delayMs, error: errorMessage },
|
|
529
|
+
});
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
if (!done.ok)
|
|
533
|
+
throw done.error;
|
|
534
|
+
await record.appendCompaction(done.value.summary, done.value.firstKeptEntryId, done.value.tokensBefore, done.value.details);
|
|
535
|
+
outcome = { summary: done.value.summary };
|
|
536
|
+
}
|
|
537
|
+
catch (error) {
|
|
538
|
+
// A deliberate stop is not a failure — run/compaction symmetry with
|
|
539
|
+
// run_settled{aborted}: the door's signal is the classification, same discipline
|
|
540
|
+
// as run abort attribution (a racing real failure still reads as aborted — the
|
|
541
|
+
// intent was live while the work resolved).
|
|
542
|
+
outcome = door.signal.aborted ? { aborted: true } : { error: String(error) };
|
|
543
|
+
}
|
|
544
|
+
await teardown();
|
|
545
|
+
// Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
|
|
546
|
+
// "finished ⇒ the lease is free and status is no longer compacting" must hold.
|
|
547
|
+
compacting.delete(session);
|
|
548
|
+
release();
|
|
549
|
+
emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
|
|
550
|
+
})();
|
|
551
|
+
return { ok: true };
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
// The WRITE handle is opened UNDER the lease: a handle from before tryAcquire could
|
|
555
|
+
// be a stale snapshot of a run that completed in the window — appending to it would
|
|
556
|
+
// hang the override off an outdated leaf.
|
|
557
|
+
const fresh = await sessions.openIfExists(session);
|
|
558
|
+
if (!fresh) {
|
|
559
|
+
// Same real condition as the pre-lease check (the session vanished in the window):
|
|
560
|
+
// same code, same disposition — not a retryable internal error.
|
|
561
|
+
return {
|
|
562
|
+
ok: false,
|
|
563
|
+
error: {
|
|
564
|
+
code: NO_SUCH_SESSION_CODE,
|
|
565
|
+
message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
|
|
566
|
+
retryable: false,
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
// Unreachable by construction: only set_model/set_thinking reach this branch, and
|
|
571
|
+
// both assign `apply` in validation. Throw rather than silently skip (fail visibly).
|
|
572
|
+
if (!apply)
|
|
573
|
+
throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
|
|
574
|
+
emitOwn(session, await apply(fresh));
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
// The append failed before anything durable landed — "nothing took effect"; the same
|
|
578
|
+
// command may succeed on retry.
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
finally {
|
|
585
|
+
release();
|
|
586
|
+
}
|
|
587
|
+
return { ok: true };
|
|
588
|
+
}
|
|
589
|
+
default:
|
|
590
|
+
// Wire input bypasses the TS union (a remote client can send any `type`): a protocol-
|
|
591
|
+
// level answer, never an undefined body — the transport promises `ok: false` shapes.
|
|
592
|
+
return {
|
|
593
|
+
ok: false,
|
|
594
|
+
error: {
|
|
595
|
+
code: INVALID_COMMAND_CODE,
|
|
596
|
+
message: `unknown command type "${String(command.type)}"`,
|
|
597
|
+
retryable: false,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
return { control, observer };
|
|
604
|
+
}
|
|
@@ -3,8 +3,23 @@ import type { Session } from "@earendil-works/pi-agent-core";
|
|
|
3
3
|
export interface PiSessionStore {
|
|
4
4
|
openOrCreate(sessionId: string): Promise<Session>;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
|
|
8
|
+
* `undefined`, never creates one — sessions are the data plane's monopoly. Two consumers:
|
|
9
|
+
* - the OBSERVATION plane (`state()`/`entries()`), strictly read-only (design §16 invariant 4);
|
|
10
|
+
* - the control plane's BOUNDARY writers (`set_model`/`set_thinking`), which append override
|
|
11
|
+
* records to the returned handle after an existence check, under the run lease.
|
|
12
|
+
* `openIfExists` skips the open-time crash reconciliation (that appends repair entries — a write
|
|
13
|
+
* the observation plane must not perform). The boundary writers are safe WITHOUT it only because
|
|
14
|
+
* override records are not messages — they cannot create or interact with a dangling tool_use
|
|
15
|
+
* pair. Writing MESSAGE-class records through this handle would bypass that repair: use
|
|
16
|
+
* `openOrCreate` for anything that enters the transcript.
|
|
17
|
+
*/
|
|
18
|
+
export interface PiSessionReader {
|
|
19
|
+
openIfExists(sessionId: string): Promise<Session | undefined>;
|
|
20
|
+
}
|
|
6
21
|
/** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
|
|
7
|
-
export declare function inMemorySessionStore(): PiSessionStore;
|
|
22
|
+
export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader;
|
|
8
23
|
/**
|
|
9
24
|
* Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
|
|
10
25
|
* `cwd` is recorded in session metadata; defaults to process.cwd().
|
|
@@ -12,4 +27,4 @@ export declare function inMemorySessionStore(): PiSessionStore;
|
|
|
12
27
|
export declare function jsonlSessionStore(options: {
|
|
13
28
|
dir: string;
|
|
14
29
|
cwd?: string;
|
|
15
|
-
}): PiSessionStore;
|
|
30
|
+
}): PiSessionStore & PiSessionReader;
|
|
@@ -100,6 +100,10 @@ export function inMemorySessionStore() {
|
|
|
100
100
|
await reconcileInterruptedToolCalls(session);
|
|
101
101
|
return session;
|
|
102
102
|
},
|
|
103
|
+
async openIfExists(sessionId) {
|
|
104
|
+
const existing = (await repo.list()).find((m) => m.id === sessionId);
|
|
105
|
+
return existing ? repo.open(existing) : undefined;
|
|
106
|
+
},
|
|
103
107
|
};
|
|
104
108
|
}
|
|
105
109
|
/**
|
|
@@ -122,6 +126,11 @@ export function jsonlSessionStore(options) {
|
|
|
122
126
|
await reconcileInterruptedToolCalls(session);
|
|
123
127
|
return session;
|
|
124
128
|
},
|
|
129
|
+
async openIfExists(sessionId) {
|
|
130
|
+
const id = encodeSessionId(sessionId);
|
|
131
|
+
const existing = (await repo.list({ cwd })).find((m) => m.id === id);
|
|
132
|
+
return existing ? repo.open(existing) : undefined;
|
|
133
|
+
},
|
|
125
134
|
};
|
|
126
135
|
}
|
|
127
136
|
/** Injective filename-safe encoding: [A-Za-z0-9._-] verbatim, the rest %-escaped. */
|