@oh-my-pi/pi-coding-agent 15.5.10 → 15.5.11
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/CHANGELOG.md +21 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +14 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/ultrathink.d.ts +10 -0
- package/dist/types/session/redis-session-storage.d.ts +124 -0
- package/dist/types/session/sql-session-storage.d.ts +141 -0
- package/examples/sdk/12-redis-sessions.ts +54 -0
- package/examples/sdk/13-sql-sessions.ts +61 -0
- package/package.json +8 -8
- package/scripts/build-binary.ts +14 -9
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +15 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +63 -22
- package/src/index.ts +3 -0
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/memories/index.ts +8 -3
- package/src/modes/components/custom-editor.ts +3 -0
- package/src/modes/ultrathink.ts +79 -0
- package/src/prompts/system/ultrathink-notice.md +3 -0
- package/src/session/agent-session.ts +28 -0
- package/src/session/redis-session-storage.ts +481 -0
- package/src/session/sql-session-storage.ts +565 -0
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +40 -6
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import ultrathinkNotice from "../prompts/system/ultrathink-notice.md" with { type: "text" };
|
|
2
|
+
import { theme } from "./theme/theme";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "ultrathink" keyword support, mirroring Claude Code's affordance.
|
|
6
|
+
*
|
|
7
|
+
* Typing the standalone word in the input editor paints it with a rainbow
|
|
8
|
+
* gradient ({@link highlightUltrathink}); submitting a message that mentions it
|
|
9
|
+
* appends a hidden {@link ULTRATHINK_NOTICE} nudging the model toward careful
|
|
10
|
+
* multi-step reasoning. Matching is word-bounded and case-insensitive, so
|
|
11
|
+
* "ultrathinking"/"ultrathinks" never trigger either behavior.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// Cheap, stateless presence probe used to skip the boundary regex on most lines.
|
|
15
|
+
const ULTRATHINK_PROBE = /ultrathink/i;
|
|
16
|
+
// Detection: standalone keyword, any case. Non-global so `.test` stays stateless.
|
|
17
|
+
const ULTRATHINK_WORD = /\bultrathink\b/i;
|
|
18
|
+
// Highlight: global so `.replace` walks every occurrence.
|
|
19
|
+
const ULTRATHINK_HIGHLIGHT = /\bultrathink\b/gi;
|
|
20
|
+
|
|
21
|
+
/** Hidden system notice appended after a user message that mentions "ultrathink". */
|
|
22
|
+
export const ULTRATHINK_NOTICE: string = ultrathinkNotice.trim();
|
|
23
|
+
|
|
24
|
+
/** Whether `text` contains the standalone keyword "ultrathink" (any case). */
|
|
25
|
+
export function containsUltrathink(text: string): boolean {
|
|
26
|
+
return ULTRATHINK_WORD.test(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FG_RESET = "\x1b[39m";
|
|
30
|
+
// Hue stops swept across the visible spectrum. More stops than the keyword has
|
|
31
|
+
// letters so the gradient resolves smoothly regardless of casing/match length.
|
|
32
|
+
const RAINBOW_STOPS = 14;
|
|
33
|
+
|
|
34
|
+
let cachedMode: string | undefined;
|
|
35
|
+
let cachedPalette: readonly string[] | undefined;
|
|
36
|
+
|
|
37
|
+
/** Rainbow foreground escapes for the active color mode, compiled once per mode. */
|
|
38
|
+
function rainbowPalette(): readonly string[] {
|
|
39
|
+
const mode = theme.getColorMode();
|
|
40
|
+
if (cachedPalette && cachedMode === mode) return cachedPalette;
|
|
41
|
+
const format = mode === "truecolor" ? "ansi-16m" : "ansi-256";
|
|
42
|
+
const palette: string[] = [];
|
|
43
|
+
for (let i = 0; i < RAINBOW_STOPS; i++) {
|
|
44
|
+
// Sweep red→violet (0..330°), stopping short of the wrap back to red.
|
|
45
|
+
const hue = Math.round((i / RAINBOW_STOPS) * 330);
|
|
46
|
+
palette.push(Bun.color(`hsl(${hue}, 90%, 62%)`, format) ?? "");
|
|
47
|
+
}
|
|
48
|
+
cachedMode = mode;
|
|
49
|
+
cachedPalette = palette;
|
|
50
|
+
return palette;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Paint each character of `word` with the next rainbow stop, resetting fg after. */
|
|
54
|
+
function rainbow(word: string): string {
|
|
55
|
+
const palette = rainbowPalette();
|
|
56
|
+
const n = word.length;
|
|
57
|
+
let out = "";
|
|
58
|
+
let prev = "";
|
|
59
|
+
for (let i = 0; i < n; i++) {
|
|
60
|
+
const color = palette[Math.floor((i / n) * palette.length)] ?? palette[0] ?? "";
|
|
61
|
+
// Coalesce consecutive characters that resolve to the same stop.
|
|
62
|
+
if (color !== prev) {
|
|
63
|
+
out += color;
|
|
64
|
+
prev = color;
|
|
65
|
+
}
|
|
66
|
+
out += word[i];
|
|
67
|
+
}
|
|
68
|
+
return `${out}${FG_RESET}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rainbow-highlight every standalone "ultrathink" in `text` for editor display.
|
|
73
|
+
* Adds only zero-width SGR escapes — the visible width is unchanged — and returns
|
|
74
|
+
* the input untouched when the keyword is absent.
|
|
75
|
+
*/
|
|
76
|
+
export function highlightUltrathink(text: string): string {
|
|
77
|
+
if (!ULTRATHINK_PROBE.test(text)) return text;
|
|
78
|
+
return text.replace(ULTRATHINK_HIGHLIGHT, rainbow);
|
|
79
|
+
}
|
|
@@ -148,6 +148,7 @@ import type { HindsightSessionState } from "../hindsight/state";
|
|
|
148
148
|
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
149
149
|
import { resolveMemoryBackend } from "../memory-backend";
|
|
150
150
|
import { getCurrentThemeName, theme } from "../modes/theme/theme";
|
|
151
|
+
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
|
|
151
152
|
import type { PlanModeState } from "../plan-mode/state";
|
|
152
153
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
153
154
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
@@ -3997,6 +3998,21 @@ export class AgentSession {
|
|
|
3997
3998
|
// Expand file-based prompt templates if requested
|
|
3998
3999
|
const expandedText = expandPromptTemplates ? expandPromptTemplate(text, [...this.#promptTemplates]) : text;
|
|
3999
4000
|
|
|
4001
|
+
// "ultrathink" keyword: nudge the model toward careful multi-step reasoning by
|
|
4002
|
+
// appending a hidden notice after the user's message. User-authored prompts only —
|
|
4003
|
+
// synthetic/agent-initiated turns never trigger it.
|
|
4004
|
+
const ultrathinkNotice: CustomMessage | undefined =
|
|
4005
|
+
!options?.synthetic && containsUltrathink(expandedText)
|
|
4006
|
+
? {
|
|
4007
|
+
role: "custom",
|
|
4008
|
+
customType: "ultrathink-notice",
|
|
4009
|
+
content: ULTRATHINK_NOTICE,
|
|
4010
|
+
display: false,
|
|
4011
|
+
attribution: "user",
|
|
4012
|
+
timestamp: Date.now(),
|
|
4013
|
+
}
|
|
4014
|
+
: undefined;
|
|
4015
|
+
|
|
4000
4016
|
// If streaming, queue via steer() or followUp() based on option
|
|
4001
4017
|
if (this.isStreaming) {
|
|
4002
4018
|
if (!options?.streamingBehavior) {
|
|
@@ -4007,6 +4023,10 @@ export class AgentSession {
|
|
|
4007
4023
|
} else {
|
|
4008
4024
|
await this.#queueSteer(expandedText, options?.images);
|
|
4009
4025
|
}
|
|
4026
|
+
// Steer/follow-up the ultrathink notice alongside the queued user message.
|
|
4027
|
+
if (ultrathinkNotice) {
|
|
4028
|
+
await this.sendCustomMessage(ultrathinkNotice, { deliverAs: options.streamingBehavior });
|
|
4029
|
+
}
|
|
4010
4030
|
return;
|
|
4011
4031
|
}
|
|
4012
4032
|
|
|
@@ -4035,6 +4055,7 @@ export class AgentSession {
|
|
|
4035
4055
|
await this.#promptWithMessage(message, expandedText, {
|
|
4036
4056
|
...options,
|
|
4037
4057
|
prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
|
|
4058
|
+
appendMessages: ultrathinkNotice ? [ultrathinkNotice] : undefined,
|
|
4038
4059
|
});
|
|
4039
4060
|
} finally {
|
|
4040
4061
|
// Clean up residual eager-todo directive if the prompt never consumed it
|
|
@@ -4084,6 +4105,7 @@ export class AgentSession {
|
|
|
4084
4105
|
expandedText: string,
|
|
4085
4106
|
options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
|
|
4086
4107
|
prependMessages?: AgentMessage[];
|
|
4108
|
+
appendMessages?: AgentMessage[];
|
|
4087
4109
|
skipPostPromptRecoveryWait?: boolean;
|
|
4088
4110
|
},
|
|
4089
4111
|
): Promise<void> {
|
|
@@ -4147,6 +4169,12 @@ export class AgentSession {
|
|
|
4147
4169
|
|
|
4148
4170
|
messages.push(message);
|
|
4149
4171
|
|
|
4172
|
+
// Inject the ultrathink notice (and any other per-turn appends) right after the
|
|
4173
|
+
// user message so the model reads it as part of the same turn.
|
|
4174
|
+
if (options?.appendMessages) {
|
|
4175
|
+
messages.push(...options.appendMessages);
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4150
4178
|
// Early bail-out: if a newer abort/prompt cycle started during setup,
|
|
4151
4179
|
// return before mutating shared state (nextTurn messages, system prompt).
|
|
4152
4180
|
if (this.#promptGeneration !== generation) {
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { logger, toError } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from "./session-storage";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal subset of the `bun:redis` `RedisClient` surface used by
|
|
6
|
+
* {@link RedisSessionStorage}. Keeping the contract narrow (and accepting any
|
|
7
|
+
* client that conforms) lets callers swap in test doubles or shared clients
|
|
8
|
+
* without dragging the entire Bun typings into this module.
|
|
9
|
+
*/
|
|
10
|
+
export interface RedisSessionStorageClient {
|
|
11
|
+
get(key: string): Promise<string | null>;
|
|
12
|
+
set(key: string, value: string): Promise<unknown>;
|
|
13
|
+
append(key: string, value: string): Promise<number>;
|
|
14
|
+
del(...keys: string[]): Promise<number>;
|
|
15
|
+
rename(src: string, dst: string): Promise<unknown>;
|
|
16
|
+
scan(cursor: string, ...args: string[]): Promise<[string, string[]]>;
|
|
17
|
+
hset(key: string, field: string, value: string): Promise<unknown>;
|
|
18
|
+
hgetall(key: string): Promise<Record<string, string>>;
|
|
19
|
+
hdel(key: string, ...fields: string[]): Promise<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RedisSessionStorageOptions {
|
|
23
|
+
/** A connected `bun:redis` RedisClient (or any compatible adapter). */
|
|
24
|
+
client: RedisSessionStorageClient;
|
|
25
|
+
/**
|
|
26
|
+
* Key prefix applied to every Redis key this storage owns. Default `omp:sessions:`.
|
|
27
|
+
* Trailing colon is preserved verbatim — set to a project-scoped prefix to share
|
|
28
|
+
* one Redis instance between multiple agents.
|
|
29
|
+
*/
|
|
30
|
+
prefix?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Maximum number of keys returned per SCAN batch when warming the mirror.
|
|
33
|
+
* Default 500.
|
|
34
|
+
*/
|
|
35
|
+
scanCount?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface MirrorEntry {
|
|
39
|
+
content: string;
|
|
40
|
+
mtimeMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_PREFIX = "omp:sessions:";
|
|
44
|
+
const DEFAULT_SCAN_COUNT = 500;
|
|
45
|
+
|
|
46
|
+
function enoent(p: string): NodeJS.ErrnoException {
|
|
47
|
+
const err = new Error(`ENOENT: no such file, '${p}'`) as NodeJS.ErrnoException;
|
|
48
|
+
err.code = "ENOENT";
|
|
49
|
+
err.errno = -2;
|
|
50
|
+
err.path = p;
|
|
51
|
+
err.syscall = "open";
|
|
52
|
+
return err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchesGlob(name: string, pattern: string): boolean {
|
|
56
|
+
if (pattern === "*") return true;
|
|
57
|
+
if (pattern.startsWith("*.")) return name.endsWith(pattern.slice(1));
|
|
58
|
+
return name === pattern;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Redis-backed implementation of {@link SessionStorage}. Each session JSONL
|
|
63
|
+
* file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
|
|
64
|
+
* single sibling HASH. An in-memory mirror is loaded on construction so the
|
|
65
|
+
* interface's synchronous methods (`existsSync`, `statSync`, `listFilesSync`,
|
|
66
|
+
* `readTextSync`, `writeTextSync`) keep their contracts — Bun's Redis client
|
|
67
|
+
* is async only, and the persist hot path (`writer.writeLineSync`) cannot
|
|
68
|
+
* wait on a network round-trip.
|
|
69
|
+
*
|
|
70
|
+
* Trade-offs vs `FileSessionStorage`:
|
|
71
|
+
* - Mirror state is process-local. Two processes writing the same session key
|
|
72
|
+
* will diverge until one of them reloads via {@link refresh}. This matches
|
|
73
|
+
* `FileSessionStorage`'s existing single-writer assumption.
|
|
74
|
+
* - `writeLineSync` updates the mirror synchronously and queues an async
|
|
75
|
+
* `APPEND`. The promise is awaited by `flush()` / `close()` / {@link drain}.
|
|
76
|
+
* A SIGKILL landing between the sync mirror update and the network round
|
|
77
|
+
* trip loses the last line; the file-backed implementation survives that
|
|
78
|
+
* window because bytes are handed to the kernel page cache before
|
|
79
|
+
* returning.
|
|
80
|
+
* - Blobs (image data) and tool artifact files still live on disk via
|
|
81
|
+
* `BlobStore` / `ArtifactManager`. Those are out of scope for this storage.
|
|
82
|
+
*/
|
|
83
|
+
export class RedisSessionStorage implements SessionStorage {
|
|
84
|
+
readonly #client: RedisSessionStorageClient;
|
|
85
|
+
readonly #prefix: string;
|
|
86
|
+
readonly #scanCount: number;
|
|
87
|
+
readonly #mirror = new Map<string, MirrorEntry>();
|
|
88
|
+
readonly #writers = new Set<RedisSessionStorageWriter>();
|
|
89
|
+
#nextMtimeMs = 0;
|
|
90
|
+
#pendingTail: Promise<void> = Promise.resolve();
|
|
91
|
+
|
|
92
|
+
private constructor(options: RedisSessionStorageOptions) {
|
|
93
|
+
this.#client = options.client;
|
|
94
|
+
this.#prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
95
|
+
this.#scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Warm the in-memory mirror with every existing session key under the
|
|
100
|
+
* configured prefix and return the ready-to-use storage. Must be awaited
|
|
101
|
+
* before passing the storage into `SessionManager.create()` so synchronous
|
|
102
|
+
* lookups (session resume, recent sessions, EPERM-backup recovery) see
|
|
103
|
+
* the existing keyspace.
|
|
104
|
+
*/
|
|
105
|
+
static async create(options: RedisSessionStorageOptions): Promise<RedisSessionStorage> {
|
|
106
|
+
const storage = new RedisSessionStorage(options);
|
|
107
|
+
await storage.refresh();
|
|
108
|
+
return storage;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Re-scan Redis and replace the mirror's contents. Call this from a
|
|
113
|
+
* different process that took over a session keyspace, or after an
|
|
114
|
+
* out-of-band write made by another agent.
|
|
115
|
+
*/
|
|
116
|
+
async refresh(): Promise<void> {
|
|
117
|
+
this.#mirror.clear();
|
|
118
|
+
const filePrefix = this.#fileKey("");
|
|
119
|
+
const metaRaw = await this.#client.hgetall(this.#metaKey());
|
|
120
|
+
const meta: Record<string, string> = metaRaw ?? {};
|
|
121
|
+
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
let cursor = "0";
|
|
124
|
+
do {
|
|
125
|
+
const [next, batch] = await this.#client.scan(
|
|
126
|
+
cursor,
|
|
127
|
+
"MATCH",
|
|
128
|
+
`${filePrefix}*`,
|
|
129
|
+
"COUNT",
|
|
130
|
+
String(this.#scanCount),
|
|
131
|
+
);
|
|
132
|
+
cursor = next;
|
|
133
|
+
for (const key of batch) seen.add(key);
|
|
134
|
+
} while (cursor !== "0");
|
|
135
|
+
|
|
136
|
+
await Promise.all(
|
|
137
|
+
Array.from(seen, async key => {
|
|
138
|
+
const path = key.slice(filePrefix.length);
|
|
139
|
+
const content = await this.#client.get(key);
|
|
140
|
+
if (content === null) return;
|
|
141
|
+
const mtimeRaw = meta[path];
|
|
142
|
+
const mtimeMs = mtimeRaw ? Number(mtimeRaw) : Date.now();
|
|
143
|
+
this.#mirror.set(path, { content, mtimeMs });
|
|
144
|
+
if (mtimeMs > this.#nextMtimeMs) this.#nextMtimeMs = mtimeMs;
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Resolve once every pending background write (issued via `writeTextSync`
|
|
151
|
+
* or `writer.writeLineSync`) has been acknowledged by Redis. Throws if any
|
|
152
|
+
* background write failed since the last drain.
|
|
153
|
+
*
|
|
154
|
+
* Call this on graceful shutdown to avoid losing the last unflushed line.
|
|
155
|
+
* The session-manager's own `flush()` / `close()` already drain through
|
|
156
|
+
* the writer chain — this method exists for callers (test harnesses,
|
|
157
|
+
* subprocess-style consumers) that bypass the writer.
|
|
158
|
+
*/
|
|
159
|
+
async drain(): Promise<void> {
|
|
160
|
+
// Take ownership of the current tail, then reset so subsequent
|
|
161
|
+
// operations start from a clean (resolved) chain. Without the reset,
|
|
162
|
+
// any failure observed here would also be re-thrown by every later
|
|
163
|
+
// write that piggybacks on the tail via `#trackPending`.
|
|
164
|
+
const tail = this.#pendingTail;
|
|
165
|
+
this.#pendingTail = Promise.resolve();
|
|
166
|
+
await tail;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#fileKey(path: string): string {
|
|
170
|
+
return `${this.#prefix}file:${path}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#metaKey(): string {
|
|
174
|
+
return `${this.#prefix}meta`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Allocate a strictly monotonic mtime. Multiple writes within the same
|
|
179
|
+
* millisecond would otherwise yield identical `mtimeMs` values and break
|
|
180
|
+
* `getSortedSessions`' newest-first ordering.
|
|
181
|
+
*/
|
|
182
|
+
#allocMtimeMs(): number {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
const next = now > this.#nextMtimeMs ? now : this.#nextMtimeMs + 1;
|
|
185
|
+
this.#nextMtimeMs = next;
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#trackPending(promise: Promise<void>): void {
|
|
190
|
+
// `Promise.all` rejects if either input rejects, which is exactly
|
|
191
|
+
// what we want for `drain()`. The follow-up `.catch(() => {})` is
|
|
192
|
+
// attached only to silence the unhandled-rejection signal on the
|
|
193
|
+
// shared tail — `drain()` keeps its own handler chain and still
|
|
194
|
+
// observes the original error, because rejection delivery is
|
|
195
|
+
// per-handler-chain, not per-promise.
|
|
196
|
+
this.#pendingTail = Promise.all([this.#pendingTail, promise]).then(() => {});
|
|
197
|
+
this.#pendingTail.catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- sync surface ---------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
ensureDirSync(_dir: string): void {
|
|
203
|
+
// Redis is flat: directories are derived from key prefixes.
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
existsSync(path: string): boolean {
|
|
207
|
+
return this.#mirror.has(path);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
writeTextSync(path: string, content: string): void {
|
|
211
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
212
|
+
this.#mirror.set(path, { content, mtimeMs });
|
|
213
|
+
this.#trackPending(this.#writeRemote(path, content, mtimeMs));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
readTextSync(path: string): string {
|
|
217
|
+
const entry = this.#mirror.get(path);
|
|
218
|
+
if (!entry) throw enoent(path);
|
|
219
|
+
return entry.content;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
statSync(path: string): SessionStorageStat {
|
|
223
|
+
const entry = this.#mirror.get(path);
|
|
224
|
+
if (!entry) throw enoent(path);
|
|
225
|
+
return {
|
|
226
|
+
size: Buffer.byteLength(entry.content, "utf-8"),
|
|
227
|
+
mtimeMs: entry.mtimeMs,
|
|
228
|
+
mtime: new Date(entry.mtimeMs),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
listFilesSync(dir: string, pattern: string): string[] {
|
|
233
|
+
const prefix = dir.endsWith("/") ? dir : `${dir}/`;
|
|
234
|
+
const out: string[] = [];
|
|
235
|
+
for (const path of this.#mirror.keys()) {
|
|
236
|
+
if (!path.startsWith(prefix)) continue;
|
|
237
|
+
const name = path.slice(prefix.length);
|
|
238
|
+
if (name.includes("/")) continue;
|
|
239
|
+
if (!matchesGlob(name, pattern)) continue;
|
|
240
|
+
out.push(path);
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- async surface --------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
async exists(path: string): Promise<boolean> {
|
|
248
|
+
// Mirror is the source of truth; checking Redis would only diverge
|
|
249
|
+
// when a peer process mutated the key, which is outside the
|
|
250
|
+
// storage's contract (see class JSDoc).
|
|
251
|
+
return this.#mirror.has(path);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async readText(path: string): Promise<string> {
|
|
255
|
+
const entry = this.#mirror.get(path);
|
|
256
|
+
if (!entry) throw enoent(path);
|
|
257
|
+
return entry.content;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async readTextPrefix(path: string, maxBytes: number): Promise<string> {
|
|
261
|
+
const entry = this.#mirror.get(path);
|
|
262
|
+
if (!entry) throw enoent(path);
|
|
263
|
+
if (maxBytes <= 0) return "";
|
|
264
|
+
// `entry.content` is a JS string (UTF-16 code units), but the prefix
|
|
265
|
+
// contract is byte-oriented. Encode to UTF-8, slice, then decode —
|
|
266
|
+
// matching `peekFile`'s behaviour for the file-backed storage.
|
|
267
|
+
const bytes = Buffer.from(entry.content, "utf-8");
|
|
268
|
+
const slice = bytes.subarray(0, Math.min(maxBytes, bytes.byteLength));
|
|
269
|
+
return slice.toString("utf-8");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async writeText(path: string, content: string): Promise<void> {
|
|
273
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
274
|
+
this.#mirror.set(path, { content, mtimeMs });
|
|
275
|
+
await this.#writeRemote(path, content, mtimeMs);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async rename(src: string, dst: string): Promise<void> {
|
|
279
|
+
const entry = this.#mirror.get(src);
|
|
280
|
+
if (!entry) throw enoent(src);
|
|
281
|
+
// Update the mirror first so a synchronous existsSync() right after
|
|
282
|
+
// the await resolves consistently. If RENAME fails the mirror is
|
|
283
|
+
// rolled back below.
|
|
284
|
+
this.#mirror.delete(src);
|
|
285
|
+
this.#mirror.set(dst, entry);
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
this.#mirror.delete(dst);
|
|
291
|
+
this.#mirror.set(src, entry);
|
|
292
|
+
throw toError(err);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Move the mtime hash entry too. Failures here cause meta drift but
|
|
296
|
+
// the mirror cache keeps statSync accurate, so log and continue.
|
|
297
|
+
try {
|
|
298
|
+
await this.#client.hdel(this.#metaKey(), src);
|
|
299
|
+
await this.#client.hset(this.#metaKey(), dst, String(entry.mtimeMs));
|
|
300
|
+
} catch (err) {
|
|
301
|
+
logger.warn("Redis session storage meta rename failed", {
|
|
302
|
+
src,
|
|
303
|
+
dst,
|
|
304
|
+
error: toError(err).message,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async unlink(path: string): Promise<void> {
|
|
310
|
+
const existed = this.#mirror.delete(path);
|
|
311
|
+
await this.#client.del(this.#fileKey(path));
|
|
312
|
+
await this.#client.hdel(this.#metaKey(), path);
|
|
313
|
+
if (!existed) {
|
|
314
|
+
throw enoent(path);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async deleteSessionWithArtifacts(sessionPath: string): Promise<void> {
|
|
319
|
+
await this.unlink(sessionPath);
|
|
320
|
+
|
|
321
|
+
// Mirror artifacts live under `<sessionPath without .jsonl>/...`. The
|
|
322
|
+
// Redis storage doesn't actually persist tool artifact bytes — those
|
|
323
|
+
// stay on disk via `ArtifactManager` — but a draft sidecar may have
|
|
324
|
+
// been written through `writeText`. Sweep any keys under that prefix.
|
|
325
|
+
const artifactsDir = sessionPath.slice(0, -6);
|
|
326
|
+
const prefix = artifactsDir.endsWith("/") ? artifactsDir : `${artifactsDir}/`;
|
|
327
|
+
const victims: string[] = [];
|
|
328
|
+
for (const key of this.#mirror.keys()) {
|
|
329
|
+
if (key.startsWith(prefix)) victims.push(key);
|
|
330
|
+
}
|
|
331
|
+
if (victims.length === 0) return;
|
|
332
|
+
|
|
333
|
+
for (const key of victims) this.#mirror.delete(key);
|
|
334
|
+
await this.#client.del(...victims.map(v => this.#fileKey(v)));
|
|
335
|
+
await this.#client.hdel(this.#metaKey(), ...victims);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
openWriter(path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }): SessionStorageWriter {
|
|
339
|
+
const writer = new RedisSessionStorageWriter(this, path, options);
|
|
340
|
+
this.#writers.add(writer);
|
|
341
|
+
return writer;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- writer support -------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
_writerClosed(writer: RedisSessionStorageWriter): void {
|
|
347
|
+
this.#writers.delete(writer);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Mirror-only mutation, no Redis call. Used by writers to update local state synchronously. */
|
|
351
|
+
_mirrorAppend(path: string, line: string): void {
|
|
352
|
+
const existing = this.#mirror.get(path);
|
|
353
|
+
const content = existing ? existing.content + line : line;
|
|
354
|
+
this.#mirror.set(path, { content, mtimeMs: this.#allocMtimeMs() });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Mirror-only mutation, no Redis call. Used by writers opened with `flags: "w"` to truncate. */
|
|
358
|
+
_mirrorTruncate(path: string): void {
|
|
359
|
+
this.#mirror.set(path, { content: "", mtimeMs: this.#allocMtimeMs() });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async _remoteTruncate(path: string): Promise<void> {
|
|
363
|
+
const entry = this.#mirror.get(path);
|
|
364
|
+
const mtimeMs = entry?.mtimeMs ?? Date.now();
|
|
365
|
+
await this.#client.set(this.#fileKey(path), "");
|
|
366
|
+
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async _remoteAppend(path: string, line: string): Promise<void> {
|
|
370
|
+
await this.#client.append(this.#fileKey(path), line);
|
|
371
|
+
const entry = this.#mirror.get(path);
|
|
372
|
+
if (entry) {
|
|
373
|
+
await this.#client.hset(this.#metaKey(), path, String(entry.mtimeMs));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Record a writer's pending promise on the storage-level tail so `drain()` waits for it. */
|
|
378
|
+
_attachPending(promise: Promise<void>): void {
|
|
379
|
+
this.#trackPending(promise);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async #writeRemote(path: string, content: string, mtimeMs: number): Promise<void> {
|
|
383
|
+
await this.#client.set(this.#fileKey(path), content);
|
|
384
|
+
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
class RedisSessionStorageWriter implements SessionStorageWriter {
|
|
389
|
+
#storage: RedisSessionStorage;
|
|
390
|
+
#path: string;
|
|
391
|
+
#closed = false;
|
|
392
|
+
#error: Error | undefined;
|
|
393
|
+
#onError: ((err: Error) => void) | undefined;
|
|
394
|
+
#pendingChain: Promise<void> = Promise.resolve();
|
|
395
|
+
|
|
396
|
+
constructor(
|
|
397
|
+
storage: RedisSessionStorage,
|
|
398
|
+
path: string,
|
|
399
|
+
options?: { flags?: "a" | "w"; onError?: (err: Error) => void },
|
|
400
|
+
) {
|
|
401
|
+
this.#storage = storage;
|
|
402
|
+
this.#path = path;
|
|
403
|
+
this.#onError = options?.onError;
|
|
404
|
+
const flags = options?.flags ?? "a";
|
|
405
|
+
if (flags === "w") {
|
|
406
|
+
// "w" mirrors FileSessionStorageWriter passing `"w"` to
|
|
407
|
+
// `fs.openSync`: start from empty content. Materialize the
|
|
408
|
+
// truncate in the mirror synchronously so an immediate reader
|
|
409
|
+
// can't observe stale content, then queue the remote SET.
|
|
410
|
+
storage._mirrorTruncate(path);
|
|
411
|
+
this.#enqueueRaw(() => storage._remoteTruncate(path));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#recordError(err: unknown): Error {
|
|
416
|
+
const error = toError(err);
|
|
417
|
+
if (!this.#error) this.#error = error;
|
|
418
|
+
this.#onError?.(error);
|
|
419
|
+
return error;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#enqueueRaw(task: () => Promise<void>): Promise<void> {
|
|
423
|
+
const next = this.#pendingChain.then(async () => {
|
|
424
|
+
if (this.#error) throw this.#error;
|
|
425
|
+
try {
|
|
426
|
+
await task();
|
|
427
|
+
} catch (err) {
|
|
428
|
+
throw this.#recordError(err);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
this.#pendingChain = next.catch(() => {
|
|
432
|
+
// Errors are recorded on `this.#error`; subsequent enqueues
|
|
433
|
+
// throw from inside the wrapper above. The outer chain swallows
|
|
434
|
+
// to avoid surfacing as an unhandled promise rejection.
|
|
435
|
+
});
|
|
436
|
+
// Storage-level drain() waits for every writer's pending work too.
|
|
437
|
+
this.#storage._attachPending(next);
|
|
438
|
+
return next;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
writeLineSync(line: string): void {
|
|
442
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
443
|
+
if (this.#error) throw this.#error;
|
|
444
|
+
this.#storage._mirrorAppend(this.#path, line);
|
|
445
|
+
this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async writeLine(line: string): Promise<void> {
|
|
449
|
+
if (this.#closed) throw new Error("Writer closed");
|
|
450
|
+
if (this.#error) throw this.#error;
|
|
451
|
+
this.#storage._mirrorAppend(this.#path, line);
|
|
452
|
+
await this.#enqueueRaw(() => this.#storage._remoteAppend(this.#path, line));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async flush(): Promise<void> {
|
|
456
|
+
if (this.#error) throw this.#error;
|
|
457
|
+
await this.#enqueueRaw(async () => {});
|
|
458
|
+
if (this.#error) throw this.#error;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async fsync(): Promise<void> {
|
|
462
|
+
// Bun's `RedisClient` has no fsync equivalent; APPEND/SET return only
|
|
463
|
+
// after the server has acknowledged the write. `flush()` already
|
|
464
|
+
// awaits that ack, so this collapses into a drain.
|
|
465
|
+
await this.flush();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async close(): Promise<void> {
|
|
469
|
+
if (this.#closed) return;
|
|
470
|
+
this.#closed = true;
|
|
471
|
+
try {
|
|
472
|
+
await this.flush();
|
|
473
|
+
} finally {
|
|
474
|
+
this.#storage._writerClosed(this);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
getError(): Error | undefined {
|
|
479
|
+
return this.#error;
|
|
480
|
+
}
|
|
481
|
+
}
|