@fastagent-sh/fastagent 0.19.0 → 0.21.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 +5 -1
- package/dist/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session persistence for the `AgentSession` L0 — open-or-create a durable record by the Caller's
|
|
3
3
|
* opaque session id, on pi-coding-agent's `SessionManager` (the v3 jsonl every pi surface reads).
|
|
4
|
-
*
|
|
5
|
-
* Records written before this store existed (by the pi-agent-core `Session` the serving path used
|
|
6
|
-
* to run on) are the same v3 jsonl and are continued in place — see `legacySessionId`. That is a
|
|
7
|
-
* READ path for existing conversations, not a second engine.
|
|
8
4
|
*/
|
|
9
|
-
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
6
|
import { basename, dirname, join, resolve } from "node:path";
|
|
11
7
|
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
12
8
|
import { log } from "../../log.js";
|
|
13
|
-
import {
|
|
9
|
+
import { LEAF_ANCHOR, publishedLeaf, stampProvenance } from "./session-markers.js";
|
|
10
|
+
import { activePath } from "./session-settings.js";
|
|
11
|
+
import { copyBranchForInheritance, copyBranchInto, inheritanceCut, } from "./session-inheritance.js";
|
|
14
12
|
/**
|
|
15
13
|
* A Caller's session id, as a name pi will accept.
|
|
16
14
|
*
|
|
@@ -25,10 +23,10 @@ import { copyBranchInto, forkForInheritance, inheritanceCut } from "./session-in
|
|
|
25
23
|
* produce one output. `_` escapes itself for the same reason. A trailing `.` or `-` is legal
|
|
26
24
|
* mid-name but not at the end, so it escapes too.
|
|
27
25
|
*
|
|
28
|
-
* Injective within this encoding — which is only sufficient because
|
|
29
|
-
* directory.
|
|
30
|
-
* called `s42`
|
|
31
|
-
* names ambiguous no matter how
|
|
26
|
+
* Injective within this encoding — which is only sufficient because this store's records live in
|
|
27
|
+
* their own directory. A name this store did not write draws from the same character set and can
|
|
28
|
+
* still decode: a file called `s42` is also this encoding of `42`, so one directory would make some
|
|
29
|
+
* names ambiguous no matter how this side spells them.
|
|
32
30
|
*
|
|
33
31
|
* Readability is deliberate: `-1001234567890` becomes `s-1001234567890`, so an operator can still
|
|
34
32
|
* tell which room a file belongs to.
|
|
@@ -43,33 +41,55 @@ export function piSessionId(sessionId) {
|
|
|
43
41
|
const body = sessionId.replace(/[^A-Za-z0-9.-]/g, hex).replace(/[.-]$/, hex);
|
|
44
42
|
return `s${body}`;
|
|
45
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* {@link piSessionId} backwards — what `list()` needs, because a session id belongs to the CALLER and
|
|
46
|
+
* a record name is storage detail. The encoding is self-describing (fixed widths, `_` escapes
|
|
47
|
+
* itself), so this is a decode rather than a guess; a name this store did not write (no `s` head, a
|
|
48
|
+
* truncated escape) answers undefined and is left out of the listing rather than reported under a
|
|
49
|
+
* name nobody can dial.
|
|
50
|
+
*/
|
|
51
|
+
export function callerSessionId(recordId) {
|
|
52
|
+
if (!recordId.startsWith("s"))
|
|
53
|
+
return undefined;
|
|
54
|
+
let out = "";
|
|
55
|
+
for (let i = 1; i < recordId.length; i++) {
|
|
56
|
+
const c = recordId[i];
|
|
57
|
+
if (c !== "_") {
|
|
58
|
+
out += c;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const wide = recordId[i + 1] === "u";
|
|
62
|
+
const start = i + (wide ? 2 : 1);
|
|
63
|
+
const width = wide ? 4 : 2;
|
|
64
|
+
const hex = recordId.slice(start, start + width);
|
|
65
|
+
if (hex.length !== width || !/^[0-9A-F]+$/.test(hex))
|
|
66
|
+
return undefined;
|
|
67
|
+
out += String.fromCharCode(Number.parseInt(hex, 16));
|
|
68
|
+
i = start + width - 1;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
46
72
|
/**
|
|
47
73
|
* Disk-backed store under `dir`: restart the process, conversations continue.
|
|
48
74
|
*
|
|
49
|
-
* Lookup is a directory scan
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* so the scan accepts either: a conversation that predates this store is continued rather than
|
|
54
|
-
* silently restarted as an empty one. Nothing is rewritten on disk.
|
|
55
|
-
*
|
|
56
|
-
* NEW records live in a subdirectory of their own, because the two engines cannot share a namespace:
|
|
57
|
-
* both spell ids into `[A-Za-z0-9._-]`, so neither can claim a prefix the other cannot produce, and
|
|
58
|
-
* a directory holding both would have names that belong to two conversations at once — in whichever
|
|
59
|
-
* direction it is read. Separate directories make each side's own injectivity sufficient.
|
|
75
|
+
* Lookup is a directory scan of THIS store's own directory, reading ids out of the filenames pi
|
|
76
|
+
* writes (`<timestamp>_<id>.jsonl`) — not `SessionManager.list`, which filters by the cwd recorded in
|
|
77
|
+
* each header and would make a renamed agent directory look like an empty store (see
|
|
78
|
+
* {@link recordFiles}).
|
|
60
79
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
80
|
+
* Records live in a subdirectory of their own, because a name this store did not write can still
|
|
81
|
+
* decode to a Caller id: a file called `s42` beside them is also this encoding of `42`, so a shared
|
|
82
|
+
* directory would answer `42` with a record it does not own. Only this directory is scanned, which
|
|
83
|
+
* makes this encoding's own injectivity sufficient — a record written before this store existed
|
|
84
|
+
* lies outside it and is never read.
|
|
64
85
|
*
|
|
65
86
|
* SCOPE OF "open-or-create": idempotent against a store that is serialized per session, which is what
|
|
66
87
|
* the serving path provides — the single-writer lease is taken before any store call, so no two
|
|
67
88
|
* turns of one conversation reach this at once. What it does NOT do is arbitrate a FIRST open racing
|
|
68
89
|
* across processes: two instances that scan before either writes will both create, and the
|
|
69
|
-
* conversation forks into two records.
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* in-process one cannot arbitrate between them, and a file lock here would only look like it could.
|
|
90
|
+
* conversation forks into two records. A horizontally-scaled deployment that wants more owes a lease
|
|
91
|
+
* that spans its instances — an in-process one cannot arbitrate between them, and a file lock here
|
|
92
|
+
* would only look like it could.
|
|
73
93
|
*/
|
|
74
94
|
export function piSessionRecordStore(options) {
|
|
75
95
|
const cwd = options.cwd ?? process.cwd();
|
|
@@ -80,74 +100,359 @@ export function piSessionRecordStore(options) {
|
|
|
80
100
|
/** Where a forked record is finished before it becomes discoverable. A SUBDIRECTORY of the store,
|
|
81
101
|
* so `list()` (one level, `*.jsonl`) never sees a record that is still being prepared. */
|
|
82
102
|
const staging = join(own, ".staging");
|
|
103
|
+
/** An empty record under `id`, ON DISK (see {@link materialize}) and not yet discoverable. Copying
|
|
104
|
+
* into it appends immediately, so a crash leaves a partial record in staging rather than a
|
|
105
|
+
* complete-looking one under the id. */
|
|
106
|
+
const stage = (id) => {
|
|
107
|
+
mkdirSync(staging, { recursive: true });
|
|
108
|
+
return materialize(SessionManager.create(cwd, staging, { id }), staging);
|
|
109
|
+
};
|
|
110
|
+
/** Fill a staged record and move it in — or leave nothing behind. The partial file is deleted on
|
|
111
|
+
* the way out and the failure is rethrown untouched: `fork` turns it into a coded result and
|
|
112
|
+
* inheritance falls back to an empty session, but neither can see `.staging`, which would
|
|
113
|
+
* otherwise accumulate a file per failed attempt (ENOSPC being the realistic repeat offender) in
|
|
114
|
+
* a directory nothing ever reads. */
|
|
115
|
+
const fillStaged = (id, fill) => {
|
|
116
|
+
const staged = stage(id);
|
|
117
|
+
// WHERE the record is, so cleanup deletes the file that exists rather than the path it used to
|
|
118
|
+
// have: after the rename, removing the staging path succeeds against nothing while the record it
|
|
119
|
+
// was supposed to undo sits in the store.
|
|
120
|
+
let published;
|
|
121
|
+
try {
|
|
122
|
+
fill(staged);
|
|
123
|
+
// Publishing is a mkdir + rename, and both can fail (EACCES, a store root on another
|
|
124
|
+
// filesystem) — a finished record stranded in staging is the same accumulation as a partial
|
|
125
|
+
// one. NOT covered by a test: the tests reach a fill failure by making a copy throw, and there
|
|
126
|
+
// is no equivalent injection point for rename without mocking node:fs for the whole file.
|
|
127
|
+
published = publishStaged(staged);
|
|
128
|
+
return SessionManager.open(published, own);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
rmSync(published ?? staged.getSessionFile() ?? "", { force: true });
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
/** Move a finished record into the store: one same-filesystem rename, so a reader sees the whole
|
|
136
|
+
* thing or nothing at all. Answers WHERE it landed — the caller opens it, and can undo it. */
|
|
137
|
+
const publishStaged = (staged) => {
|
|
138
|
+
const file = staged.getSessionFile();
|
|
139
|
+
if (!file)
|
|
140
|
+
throw new Error(`staged record ${staged.getSessionId()} has no file to publish`);
|
|
141
|
+
mkdirSync(own, { recursive: true });
|
|
142
|
+
const target = join(own, basename(file));
|
|
143
|
+
renameSync(file, target);
|
|
144
|
+
return target;
|
|
145
|
+
};
|
|
83
146
|
/** Fork the named parent into `id`, or answer undefined so the caller starts empty. Every failure
|
|
84
147
|
* is a warn: a thread must not lose its first turn to an inheritance edge. */
|
|
85
|
-
const inheritInto = async (
|
|
86
|
-
const
|
|
87
|
-
const found = (await SessionManager.list(cwd, own)).find((r) => r.id === parentId) ??
|
|
88
|
-
(await SessionManager.list(cwd, root)).find((r) => r.id === legacySessionId(inherit.parentSession));
|
|
148
|
+
const inheritInto = async (sessionId, inherit) => {
|
|
149
|
+
const found = locate(inherit.parentSession);
|
|
89
150
|
if (!found) {
|
|
90
|
-
log.warn(`[fastagent] session "${
|
|
151
|
+
log.warn(`[fastagent] session "${sessionId}" names parent "${inherit.parentSession}", which has no record — starting empty`);
|
|
91
152
|
return undefined;
|
|
92
153
|
}
|
|
93
154
|
try {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
parent: reconcileInterruptedToolCalls(SessionManager.open(found.path, parentDir)),
|
|
100
|
-
id,
|
|
101
|
-
cwd,
|
|
102
|
-
stagingDir: staging,
|
|
103
|
-
branchHints: inherit.branchHints,
|
|
104
|
-
});
|
|
105
|
-
if (!staged)
|
|
155
|
+
// A parent that crashed mid tool-execution would otherwise pass its dangling tool_use down
|
|
156
|
+
// to the child, whose very first request the provider then rejects.
|
|
157
|
+
const parent = reconcileInterruptedToolCalls(SessionManager.open(found.path, found.dir));
|
|
158
|
+
const cut = inheritanceCut(parent, inherit.branchHints);
|
|
159
|
+
if (!cut)
|
|
106
160
|
return undefined;
|
|
107
|
-
|
|
108
|
-
// thing or nothing at all.
|
|
109
|
-
const stagedFile = staged.getSessionFile();
|
|
110
|
-
if (!stagedFile)
|
|
111
|
-
return staged; // non-persisting backend: nothing to publish
|
|
112
|
-
const target = join(own, basename(stagedFile));
|
|
113
|
-
renameSync(stagedFile, target);
|
|
114
|
-
return SessionManager.open(target, own);
|
|
161
|
+
return fillStaged(piSessionId(sessionId), (staged) => copyBranchForInheritance(parent, staged, cut.at));
|
|
115
162
|
}
|
|
116
163
|
catch (error) {
|
|
117
164
|
// Unattributed on purpose: this spans reading the parent AND writing the child.
|
|
118
|
-
log.warn(`[fastagent] could not inherit from "${inherit.parentSession}" into "${
|
|
165
|
+
log.warn(`[fastagent] could not inherit from "${inherit.parentSession}" into "${sessionId}" (${String(error)}) — starting empty`);
|
|
119
166
|
return undefined;
|
|
120
167
|
}
|
|
121
168
|
};
|
|
169
|
+
/** The unreadable records the last listing reported, so a polled endpoint states the condition
|
|
170
|
+
* once rather than once a second. */
|
|
171
|
+
let lastUnreadable = "";
|
|
172
|
+
/** WHERE a session's record is — the one lookup every caller shares, so a fix to it (this store's
|
|
173
|
+
* own directory rather than pi's cwd-filtered listing) cannot reach three of the four. */
|
|
174
|
+
const locate = (sessionId) => {
|
|
175
|
+
const mine = recordFiles(own).find((f) => f.id === piSessionId(sessionId));
|
|
176
|
+
return mine ? { path: mine.path, dir: own } : undefined;
|
|
177
|
+
};
|
|
178
|
+
/** Open an existing record, or undefined. A closure rather than a method call, so `fork` cannot be
|
|
179
|
+
* broken by a caller that spreads this object into another one. */
|
|
180
|
+
const openExisting = async (sessionId) => {
|
|
181
|
+
const found = locate(sessionId);
|
|
182
|
+
return found ? SessionManager.open(found.path, found.dir) : undefined;
|
|
183
|
+
};
|
|
122
184
|
return {
|
|
123
185
|
async openOrCreate(sessionId, inherit) {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return reconcileInterruptedToolCalls(SessionManager.open(mine.path, own));
|
|
128
|
-
const legacy = (await SessionManager.list(cwd, root)).find((r) => r.id === legacySessionId(sessionId));
|
|
129
|
-
if (legacy)
|
|
130
|
-
return reconcileInterruptedToolCalls(SessionManager.open(legacy.path, root));
|
|
186
|
+
const found = locate(sessionId);
|
|
187
|
+
if (found)
|
|
188
|
+
return reconcileInterruptedToolCalls(SessionManager.open(found.path, found.dir));
|
|
131
189
|
mkdirSync(own, { recursive: true });
|
|
132
190
|
// Inheritance is a CREATE-path decision: an existing session above ignores it entirely, which
|
|
133
191
|
// is what makes it one-time by construction.
|
|
134
192
|
if (inherit) {
|
|
135
|
-
const inherited = await inheritInto(
|
|
193
|
+
const inherited = await inheritInto(sessionId, inherit);
|
|
136
194
|
if (inherited)
|
|
137
195
|
return inherited;
|
|
138
196
|
}
|
|
139
|
-
|
|
197
|
+
// The CALLER's id in every message above; pi's spelling only where pi names the file.
|
|
198
|
+
return materialize(SessionManager.create(cwd, own, { id: piSessionId(sessionId) }), own);
|
|
140
199
|
},
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
200
|
+
openIfExists: openExisting,
|
|
201
|
+
applyProperties: (sessionId, writes) => applyProperties(() => openExisting(sessionId), writes),
|
|
202
|
+
async list() {
|
|
203
|
+
const files = recordFiles(own);
|
|
204
|
+
const rows = [];
|
|
205
|
+
const unreadable = [];
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
// A name this store did not write cannot be decoded back to a Caller id, and a row nobody
|
|
208
|
+
// can dial is worse than a row that is missing.
|
|
209
|
+
const session = callerSessionId(file.id);
|
|
210
|
+
if (!session)
|
|
211
|
+
continue;
|
|
212
|
+
try {
|
|
213
|
+
rows.push(summarize(session, SessionManager.open(file.path, own)));
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
// ONE unreadable record must not take the listing down — the other conversations are fine,
|
|
217
|
+
// and a GUI that shows nothing is worse than one missing a row. But it must not vanish
|
|
218
|
+
// silently either: that is the same conflation `sessions_unavailable` prevents one level
|
|
219
|
+
// up, just per record. Named, with its reason, every time it is polled: a record that
|
|
220
|
+
// cannot be read is a condition someone has to act on.
|
|
221
|
+
unreadable.push(`${basename(file.path)} (${String(error)})`);
|
|
222
|
+
}
|
|
223
|
+
// A TURN for everything else. Reading a record is synchronous (pi parses the whole file and
|
|
224
|
+
// builds its index), and this endpoint is POLLED — so without a yield the process stops for
|
|
225
|
+
// the length of the whole listing, once a second, while SSE heartbeats and in-flight turns
|
|
226
|
+
// wait. pi's own listing streamed and interleaved; this reads faster and must not take that
|
|
227
|
+
// away.
|
|
228
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
229
|
+
}
|
|
230
|
+
// NEWEST ACTIVITY first. The filenames sort by CREATION, so an old conversation that just
|
|
231
|
+
// received a message would otherwise sit below a newer idle one — the wrong answer for the
|
|
232
|
+
// column a conversation list is read by.
|
|
233
|
+
rows.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
234
|
+
// Said when the SET changes, not on every poll: this endpoint is polled once a second or so,
|
|
235
|
+
// and an unreadable record stays unreadable until someone acts on it.
|
|
236
|
+
const reported = unreadable.join("; ");
|
|
237
|
+
if (reported !== lastUnreadable) {
|
|
238
|
+
lastUnreadable = reported;
|
|
239
|
+
if (unreadable.length > 0) {
|
|
240
|
+
log.warn(`[fastagent] ${unreadable.length} of ${files.length} session records in ${own} could not be read and are missing from this listing: ${reported}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return rows;
|
|
244
|
+
},
|
|
245
|
+
async fork(from, at, into, provenance) {
|
|
246
|
+
const parent = await openExisting(from);
|
|
247
|
+
if (!parent)
|
|
248
|
+
throw new Error(`session "${from}" has no record`);
|
|
249
|
+
// The port promises a NEW record, so the guarantee belongs here rather than in the one caller
|
|
250
|
+
// that happens to check: two records under one id makes which one a lookup finds a matter of
|
|
251
|
+
// directory order.
|
|
252
|
+
if (await openExisting(into))
|
|
253
|
+
throw new Error(`session "${into}" already exists`);
|
|
254
|
+
fillStaged(piSessionId(into), (staged) => {
|
|
255
|
+
// METADATA FIRST, history last. pi has one leaf pointer and every append advances it, so
|
|
256
|
+
// whatever is written last becomes the fork's `leafEntryId` — and a client opening a fresh
|
|
257
|
+
// fork would find its head on an empty `custom` record instead of on the exchange it forked
|
|
258
|
+
// at. The name and the provenance describe the record; the history is what the session IS.
|
|
259
|
+
const name = parent.getSessionName();
|
|
260
|
+
// The name travels: a fork of "Deploy notes" that lists as untitled is a row a user cannot
|
|
261
|
+
// place. A client that wants "(copy)" calls `update({ name })`.
|
|
262
|
+
if (name)
|
|
263
|
+
staged.appendSessionInfo(name);
|
|
264
|
+
stampProvenance(staged, provenance);
|
|
265
|
+
// NOT reconciled: the repair appends at the parent's LEAF, which a copy stopping at `at` can
|
|
266
|
+
// never reach — it would only write to the record being copied FROM. The child is reconciled
|
|
267
|
+
// on its own first open, like every other record.
|
|
268
|
+
copyBranchInto(parent, staged, at);
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
async delete(sessionId) {
|
|
272
|
+
const found = locate(sessionId);
|
|
273
|
+
if (!found)
|
|
274
|
+
return false;
|
|
275
|
+
// A record that cannot be deleted must not report success — the caller turns the throw into a
|
|
276
|
+
// coded failure, and the session is still there for the next attempt.
|
|
277
|
+
rmSync(found.path);
|
|
278
|
+
return true;
|
|
148
279
|
},
|
|
149
280
|
};
|
|
150
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* The property write, for both backends: the ONE place that knows how pi records a property.
|
|
284
|
+
*
|
|
285
|
+
* ORDER IS THE POINT. pi has a single leaf pointer and every append advances it, so the leaf ends up
|
|
286
|
+
* wherever the last write landed — which is why the move goes FIRST (the properties below it must
|
|
287
|
+
* hang off the branch the caller asked for, not the one it left) and why a fork writes its metadata
|
|
288
|
+
* before its history (session-store's `fork`). The handle is opened HERE, inside the caller's lease:
|
|
289
|
+
* one taken earlier could be a snapshot of a run that finished in the window, and appending to it
|
|
290
|
+
* would hang the property off a stale leaf.
|
|
291
|
+
*/
|
|
292
|
+
async function applyProperties(open, writes) {
|
|
293
|
+
const record = await open();
|
|
294
|
+
if (!record)
|
|
295
|
+
return undefined;
|
|
296
|
+
const landed = [];
|
|
297
|
+
let moved = false;
|
|
298
|
+
let failure;
|
|
299
|
+
try {
|
|
300
|
+
if (writes.leafEntryId !== undefined) {
|
|
301
|
+
// A move to where the head ALREADY is asks for nothing: a client retry, or a UI firing on
|
|
302
|
+
// every selection, must not grow the session. Compared against the PUBLISHED head, because
|
|
303
|
+
// pi's leaf may be the anchor a previous move left there.
|
|
304
|
+
if (publishedLeaf(record) !== writes.leafEntryId) {
|
|
305
|
+
record.branch(writes.leafEntryId);
|
|
306
|
+
moved = true;
|
|
307
|
+
}
|
|
308
|
+
landed.push("leafEntryId");
|
|
309
|
+
}
|
|
310
|
+
if (writes.model) {
|
|
311
|
+
record.appendModelChange(writes.model.provider, writes.model.id);
|
|
312
|
+
landed.push("model");
|
|
313
|
+
}
|
|
314
|
+
if (writes.thinkingLevel !== undefined) {
|
|
315
|
+
record.appendThinkingLevelChange(writes.thinkingLevel);
|
|
316
|
+
landed.push("thinkingLevel");
|
|
317
|
+
}
|
|
318
|
+
if (writes.name !== undefined) {
|
|
319
|
+
record.appendSessionInfo(writes.name);
|
|
320
|
+
landed.push("name");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
// Held, not rethrown: what already landed still has to be reported, and the report is the only
|
|
325
|
+
// way a caller learns the record moved.
|
|
326
|
+
failure = error;
|
|
327
|
+
}
|
|
328
|
+
// THE ANCHOR, outside the try so a failed property write cannot skip it — that is precisely the
|
|
329
|
+
// case reporting a MOVE as landed. pi's `branch()` writes nothing (the leaf is runtime state, and
|
|
330
|
+
// `open()` puts it back on the file's last entry), so a move nothing followed is forgotten the
|
|
331
|
+
// moment anything reopens the record. Appending anything pins it; this appends only when nothing
|
|
332
|
+
// else in the patch already did.
|
|
333
|
+
if (moved && record.getLeafId() === writes.leafEntryId) {
|
|
334
|
+
try {
|
|
335
|
+
record.appendCustomEntry(LEAF_ANCHOR, {});
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
// The move is runtime-only and will not survive the next open, so it did NOT land: saying it
|
|
339
|
+
// did is the one thing a partial report cannot afford. A first failure keeps its place — it is
|
|
340
|
+
// what stopped the patch — and this one is reported when nothing else went wrong.
|
|
341
|
+
landed.splice(landed.indexOf("leafEntryId"), 1);
|
|
342
|
+
failure ??= error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// READ BACK, never echo: pi rewrites a name (newlines collapse, ends trim), and the settings a
|
|
346
|
+
// path resolves to can change under a moved leaf. The caller reports THIS, so `state()` and
|
|
347
|
+
// `list()` cannot disagree with what it said.
|
|
348
|
+
const name = record.getSessionName();
|
|
349
|
+
// The PUBLISHED head, not pi's: the anchor above is ours, and a client must be told the position
|
|
350
|
+
// it asked for — the one it can find in `entries()`.
|
|
351
|
+
const leafEntryId = publishedLeaf(record);
|
|
352
|
+
let path;
|
|
353
|
+
try {
|
|
354
|
+
path = activePath(record);
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
// An unreadable chain is the caller's decision (design §7), not a silent short path. The writes
|
|
358
|
+
// are already durable either way.
|
|
359
|
+
log.warn(`[fastagent] session ${record.getSessionId()}: written, active path unreadable: ${String(error)}`);
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
landed,
|
|
363
|
+
...(failure !== undefined ? { failure } : {}),
|
|
364
|
+
...(name ? { name } : {}),
|
|
365
|
+
...(leafEntryId ? { leafEntryId } : {}),
|
|
366
|
+
...(path ? { path } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The record files in a directory, newest first — pi names them `<ISO timestamp>_<id>.jsonl`, so the
|
|
371
|
+
* name sorts by time and carries the id without opening anything.
|
|
372
|
+
*
|
|
373
|
+
* OUR readdir, not pi's `SessionManager.list`, and the difference is the reason this exists: that one
|
|
374
|
+
* filters by the cwd recorded in each header — right for a TUI showing "this project's sessions",
|
|
375
|
+
* wrong for a repository, where renaming the agent directory made every conversation vanish from the
|
|
376
|
+
* listing AND from lookup, so the next turn started an empty session on top of the old one. It also
|
|
377
|
+
* swallows per-file faults, which a listing needs to see.
|
|
378
|
+
*
|
|
379
|
+
* ENOENT is the one condition that is not a fault: a store nobody has written to holds no records.
|
|
380
|
+
* Anything else — an unreadable ancestor, a file where the directory should be — travels with its
|
|
381
|
+
* code, because "this deployment has no conversations" is not an answer for a store we cannot read.
|
|
382
|
+
*/
|
|
383
|
+
function recordFiles(dir) {
|
|
384
|
+
let names;
|
|
385
|
+
try {
|
|
386
|
+
names = readdirSync(dir);
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
if (error.code !== "ENOENT")
|
|
390
|
+
throw error;
|
|
391
|
+
return [];
|
|
392
|
+
}
|
|
393
|
+
// UNORDERED. Lookup does not care, and `list()` sorts its rows by activity — a filename order kept
|
|
394
|
+
// here would be one nobody consumes and the next reader has to prove is unused.
|
|
395
|
+
return names
|
|
396
|
+
.filter((name) => name.endsWith(RECORD_SUFFIX))
|
|
397
|
+
.flatMap((name) => {
|
|
398
|
+
// The FIRST underscore: the timestamp holds none, and an encoded id may hold several
|
|
399
|
+
// (`piSessionId` escapes with `_`).
|
|
400
|
+
const cut = name.indexOf("_");
|
|
401
|
+
return cut < 0 ? [] : [{ path: join(dir, name), id: name.slice(cut + 1, -RECORD_SUFFIX.length) }];
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/** One record as a conversation-list row. Read out of the record itself rather than from pi's
|
|
405
|
+
* listing, which is not asked for one — so the fields mean exactly what they say: no sentinel
|
|
406
|
+
* standing in for "no first message", and `updatedAt` floored at the record's own creation (a
|
|
407
|
+
* fork's entries carry the SOURCE's timestamps, so a branch made today would otherwise sort into
|
|
408
|
+
* whenever the original was written — the one column a conversation list orders by). */
|
|
409
|
+
function summarize(session, record) {
|
|
410
|
+
const entries = record.getEntries();
|
|
411
|
+
const createdAt = Date.parse(record.getHeader()?.timestamp ?? "") || 0;
|
|
412
|
+
// `|| 0` on both: an unparseable timestamp is NaN, and NaN in `updatedAt` serializes to `null` —
|
|
413
|
+
// which the contract types as a number and a client sorts by.
|
|
414
|
+
const lastAt = Date.parse(entries.at(-1)?.timestamp ?? "") || 0;
|
|
415
|
+
const messages = entries.filter((e) => e.type === "message");
|
|
416
|
+
const name = record.getSessionName();
|
|
417
|
+
const preview = firstUserText(messages);
|
|
418
|
+
return {
|
|
419
|
+
session,
|
|
420
|
+
...(name ? { name } : {}),
|
|
421
|
+
createdAt,
|
|
422
|
+
updatedAt: Math.max(lastAt, createdAt),
|
|
423
|
+
messageCount: messages.length,
|
|
424
|
+
...(preview ? { preview } : {}),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** The first user message with text in it, truncated by CODE POINT (a cut through a surrogate pair
|
|
428
|
+
* would put a lone half in the row, which renders as U+FFFD). A session opened with a caption-less
|
|
429
|
+
* photo has none, and answers undefined — a row without a preview, not a row claiming one. */
|
|
430
|
+
function firstUserText(messages) {
|
|
431
|
+
for (const entry of messages) {
|
|
432
|
+
const message = entry.message;
|
|
433
|
+
if (message?.role !== "user")
|
|
434
|
+
continue;
|
|
435
|
+
const content = message.content;
|
|
436
|
+
const text = typeof content === "string"
|
|
437
|
+
? content
|
|
438
|
+
: Array.isArray(content)
|
|
439
|
+
? content
|
|
440
|
+
.filter((block) => block.type === "text")
|
|
441
|
+
.map((block) => block.text ?? "")
|
|
442
|
+
.join(" ")
|
|
443
|
+
: "";
|
|
444
|
+
// Cut to UTF-16 units FIRST: a pasted megabyte would otherwise become a million-element array
|
|
445
|
+
// on the way to keeping 200 of them. Two units per code point is the ceiling, so this cannot
|
|
446
|
+
// take fewer characters than the slice below wants.
|
|
447
|
+
if (text.trim())
|
|
448
|
+
return [...text.slice(0, PREVIEW_CHARS * 2)].slice(0, PREVIEW_CHARS).join("");
|
|
449
|
+
}
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
/** What pi names a record file. */
|
|
453
|
+
const RECORD_SUFFIX = ".jsonl";
|
|
454
|
+
/** How much of the first message a list row carries. A row, not a transcript. */
|
|
455
|
+
const PREVIEW_CHARS = 200;
|
|
151
456
|
/**
|
|
152
457
|
* Crash-safety reconciliation, run on every OPEN of an existing record.
|
|
153
458
|
*
|
|
@@ -226,7 +531,7 @@ function reconcileInterruptedToolCalls(record) {
|
|
|
226
531
|
}
|
|
227
532
|
return record;
|
|
228
533
|
}
|
|
229
|
-
/** Where
|
|
534
|
+
/** Where the records live, under the sessions directory the store is pointed at. */
|
|
230
535
|
const OWN_RECORDS_DIR = "agent-session";
|
|
231
536
|
/**
|
|
232
537
|
* Make a NEW record exist on disk before anyone can act on it.
|
|
@@ -243,7 +548,7 @@ const OWN_RECORDS_DIR = "agent-session";
|
|
|
243
548
|
* Writing pi's OWN header (`getHeader()`, not a hand-built literal) and reopening puts the manager
|
|
244
549
|
* on its normal "file exists" path, where every append lands immediately.
|
|
245
550
|
*/
|
|
246
|
-
function
|
|
551
|
+
function materialize(session, dir) {
|
|
247
552
|
const file = session.getSessionFile();
|
|
248
553
|
if (!file || existsSync(file))
|
|
249
554
|
return session; // in-memory, or already on disk
|
|
@@ -283,7 +588,7 @@ export function piInMemorySessionRecordStore(options = {}) {
|
|
|
283
588
|
const staged = fresh();
|
|
284
589
|
const cut = inheritanceCut(reconcileInterruptedToolCalls(parent), inherit.branchHints);
|
|
285
590
|
if (cut)
|
|
286
|
-
|
|
591
|
+
copyBranchForInheritance(parent, staged, cut.at);
|
|
287
592
|
created = staged;
|
|
288
593
|
}
|
|
289
594
|
catch (error) {
|
|
@@ -300,14 +605,40 @@ export function piInMemorySessionRecordStore(options = {}) {
|
|
|
300
605
|
async openIfExists(sessionId) {
|
|
301
606
|
return live.get(sessionId);
|
|
302
607
|
},
|
|
608
|
+
applyProperties: (sessionId, writes) => applyProperties(async () => live.get(sessionId), writes),
|
|
609
|
+
async list() {
|
|
610
|
+
// The SAME row builder the disk store uses, ordered the same way: a backend difference here is
|
|
611
|
+
// one an embedder discovers as a missing field (this one had no `preview` for exactly that
|
|
612
|
+
// reason). Nothing to read from disk, so nothing can be unreadable.
|
|
613
|
+
return [...live].map(([session, record]) => summarize(session, record)).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
614
|
+
},
|
|
615
|
+
async fork(from, at, into, provenance) {
|
|
616
|
+
const parent = live.get(from);
|
|
617
|
+
if (!parent)
|
|
618
|
+
throw new Error(`session "${from}" has no record`);
|
|
619
|
+
// The port's promise, not the caller's: registering over a live session would replace its
|
|
620
|
+
// history outright.
|
|
621
|
+
if (live.has(into))
|
|
622
|
+
throw new Error(`session "${into}" already exists`);
|
|
623
|
+
// Entry-by-entry, like the in-memory inheritance path: there is no file to fork. Registered
|
|
624
|
+
// only once complete, so a failure leaves no half-copied session behind. NO inheritance
|
|
625
|
+
// window: this is the same user keeping their own history, not a new thread bounded from a
|
|
626
|
+
// parent's.
|
|
627
|
+
const staged = SessionManager.inMemory(cwd, { id: piSessionId(into) });
|
|
628
|
+
// METADATA FIRST, history last — the same order the disk store writes in, and for the same
|
|
629
|
+
// reason: pi has one leaf pointer, so whatever is appended last is where a client opening this
|
|
630
|
+
// fork finds its head. That should be the exchange it was forked at, not a metadata record.
|
|
631
|
+
// The name travels so a fork of "Deploy notes" is not an untitled row a user cannot place; a
|
|
632
|
+
// client that wants "(copy)" calls `update({ name })`.
|
|
633
|
+
const name = parent.getSessionName();
|
|
634
|
+
if (name)
|
|
635
|
+
staged.appendSessionInfo(name);
|
|
636
|
+
stampProvenance(staged, provenance);
|
|
637
|
+
copyBranchInto(parent, staged, at);
|
|
638
|
+
live.set(into, staged);
|
|
639
|
+
},
|
|
640
|
+
async delete(sessionId) {
|
|
641
|
+
return live.delete(sessionId);
|
|
642
|
+
},
|
|
303
643
|
};
|
|
304
644
|
}
|
|
305
|
-
/** The spelling used before this store existed — read-only, so older records still resolve. */
|
|
306
|
-
function legacySessionId(sessionId) {
|
|
307
|
-
return sessionId.replace(/[^A-Za-z0-9._-]/g, (c) => {
|
|
308
|
-
const code = c.charCodeAt(0);
|
|
309
|
-
return code < 0x100
|
|
310
|
-
? `%${code.toString(16).toUpperCase().padStart(2, "0")}`
|
|
311
|
-
: `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
312
|
-
});
|
|
313
|
-
}
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* closures. Deploy-time ambients a tool closes over at build time do NOT belong here.
|
|
5
5
|
*/
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
-
import type { SessionEntry as PiSessionEntry } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { SessionEntry as PiSessionEntry, AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
/** FastAgent's read-only port over the current conversation manager. Serving and chat adapt their
|
|
10
9
|
* different concrete session implementations to this one tool-runtime contract. */
|
|
11
10
|
export interface ReadonlySessionManager {
|
|
@@ -42,21 +41,35 @@ export interface ToolActivation {
|
|
|
42
41
|
description: string;
|
|
43
42
|
}>;
|
|
44
43
|
/** ADDITIVE activation. Unknown names are filtered out before reaching pi (whose `setActiveTools`
|
|
45
|
-
* THROWS on them);
|
|
46
|
-
|
|
44
|
+
* THROWS on them); answers the names actually newly activated (already-active names don't repeat).
|
|
45
|
+
*
|
|
46
|
+
* SYNCHRONOUS, and that is the contract, not an implementation detail: read-modify-write against
|
|
47
|
+
* pi's active set cannot be interleaved as long as no caller can await inside it. An async
|
|
48
|
+
* signature would need a lock to say the same thing, and the lock is what a previous version had
|
|
49
|
+
* — one rebuilt per tool call, so the parallel batch it existed for never met on it. If pi's
|
|
50
|
+
* setters ever become async, this signature is where that breaks, loudly. */
|
|
51
|
+
activate(names: string[]): string[];
|
|
47
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The activation bridge over a live pi session — the ONE implementation, for both consumers.
|
|
55
|
+
*
|
|
56
|
+
* Serving (`agent-session-factory.ts`) and chat (`session-builder.ts`) had a copy each, identical
|
|
57
|
+
* but for the persistence line; the neighbouring `definitionResourceLoaderOptions` exists because
|
|
58
|
+
* that exact duplication drifted once before. The difference is a PARAMETER now: `onActivated` is
|
|
59
|
+
* what a served session uses to record the delta that carries the discovery into its next turn,
|
|
60
|
+
* and chat has nowhere to put one (pi's SessionContext has no active-tool set).
|
|
61
|
+
*
|
|
62
|
+
* Bind it to the SESSION, never to a tool call: the next call has to see what this one activated.
|
|
63
|
+
*/
|
|
64
|
+
export declare function sessionToolActivation(session: AgentSession, onActivated?: (added: string[]) => void): ToolActivation;
|
|
48
65
|
export interface TurnContext {
|
|
49
66
|
/** Working directory for this execution. Falls back to process.cwd() only for an unbound direct call. */
|
|
50
67
|
cwd?: string;
|
|
51
68
|
/** Current conversation manager. Absent outside a FastAgent-managed agent turn. */
|
|
52
69
|
sessionManager?: ReadonlySessionManager;
|
|
53
|
-
/** Tool activation for the current turn. Two producers, one consumer surface:
|
|
54
|
-
* served session;
|
|
55
|
-
* semantics). Absent only outside any turn (a bare `fastagent tool` run). */
|
|
70
|
+
/** Tool activation for the current turn. Two producers, one consumer surface: agent-session-factory.ts
|
|
71
|
+
* bridges the served session; session-builder.ts bridges the resident one (chat emulates deferral —
|
|
72
|
+
* same loader, same semantics). Absent only outside any turn (a bare `fastagent tool` run). */
|
|
56
73
|
tools?: ToolActivation;
|
|
57
74
|
}
|
|
58
75
|
export declare const turnContext: AsyncLocalStorage<TurnContext>;
|
|
59
|
-
/** The additive-activation contract, in ONE place for both bridges (the served session,
|
|
60
|
-
* chat.ts over pi's AgentSession): dedupe → keep registered names only (pi's setters THROW on
|
|
61
|
-
* unknown) → exclude already-active → the names to actually add (empty = nothing to set). */
|
|
62
|
-
export declare function additiveActivation(registered: string[], current: string[], names: string[]): string[];
|