@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.1
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 +113 -31
- package/dist/types/capability/skill.d.ts +7 -0
- package/dist/types/cli/update-cli.d.ts +15 -1
- package/dist/types/config/append-only-context-mode.d.ts +8 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/models-config-schema.d.ts +15 -0
- package/dist/types/config/settings-schema.d.ts +38 -24
- package/dist/types/debug/protocol-probe.d.ts +38 -0
- package/dist/types/debug/terminal-info.d.ts +34 -0
- package/dist/types/exa/mcp-client.d.ts +2 -1
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mcp/json-rpc.d.ts +6 -1
- package/dist/types/mcp/tool-bridge.d.ts +4 -0
- package/dist/types/mcp/transports/stdio.d.ts +16 -7
- package/dist/types/mnemopi/state.d.ts +2 -2
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/assistant-message.d.ts +3 -2
- package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +11 -0
- package/dist/types/modes/components/plugin-settings.d.ts +40 -8
- package/dist/types/modes/components/session-selector.d.ts +8 -3
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- package/dist/types/modes/components/todo-reminder.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +36 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +20 -1
- package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
- package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
- package/dist/types/session/agent-session.d.ts +5 -2
- package/dist/types/session/history-storage.d.ts +3 -4
- package/dist/types/session/indexed-session-storage.d.ts +59 -0
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/redis-session-storage.d.ts +12 -85
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/session-storage.d.ts +5 -7
- package/dist/types/session/sql-session-storage.d.ts +16 -85
- package/dist/types/slash-commands/types.d.ts +17 -4
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/telemetry-export.d.ts +19 -0
- package/dist/types/tiny/compiled-runtime.d.ts +35 -0
- package/dist/types/tiny/text.d.ts +17 -0
- package/dist/types/tools/ask.d.ts +1 -0
- package/dist/types/tools/index.d.ts +4 -2
- package/dist/types/tools/path-utils.d.ts +1 -1
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
- package/dist/types/utils/session-color.d.ts +7 -2
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +2 -2
- package/dist/types/web/search/providers/base.d.ts +14 -0
- package/dist/types/web/search/providers/exa.d.ts +9 -0
- package/dist/types/web/search/providers/perplexity.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +66 -1
- package/package.json +15 -9
- package/scripts/build-binary.ts +12 -0
- package/src/capability/skill.ts +7 -0
- package/src/cli/args.ts +1 -1
- package/src/cli/session-picker.ts +1 -0
- package/src/cli/update-cli.ts +54 -2
- package/src/commands/completions.ts +1 -1
- package/src/config/append-only-context-mode.ts +37 -0
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +24 -57
- package/src/debug/index.ts +67 -1
- package/src/debug/protocol-probe.ts +267 -0
- package/src/debug/terminal-info.ts +127 -0
- package/src/exa/mcp-client.ts +11 -5
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +3 -3
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/types.ts +11 -0
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +3 -3
- package/src/index.ts +1 -0
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/main.ts +16 -2
- package/src/mcp/json-rpc.ts +8 -0
- package/src/mcp/render.ts +3 -0
- package/src/mcp/tool-bridge.ts +10 -2
- package/src/mcp/transports/http.ts +33 -16
- package/src/mcp/transports/stdio.ts +37 -12
- package/src/mnemopi/state.ts +4 -4
- package/src/modes/acp/acp-agent.ts +168 -3
- package/src/modes/acp/acp-event-mapper.ts +7 -7
- package/src/modes/components/agent-dashboard.ts +103 -31
- package/src/modes/components/assistant-message.ts +3 -2
- package/src/modes/components/extensions/extension-dashboard.ts +56 -10
- package/src/modes/components/history-search.ts +128 -14
- package/src/modes/components/hook-selector.ts +149 -14
- package/src/modes/components/plugin-settings.ts +270 -36
- package/src/modes/components/session-selector.ts +81 -19
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/status-line/segments.ts +2 -1
- package/src/modes/components/status-line.ts +1 -1
- package/src/modes/components/tips.txt +6 -2
- package/src/modes/components/todo-reminder.ts +1 -1
- package/src/modes/components/tool-execution.ts +9 -4
- package/src/modes/components/transcript-container.ts +109 -0
- package/src/modes/controllers/command-controller.ts +4 -3
- package/src/modes/controllers/event-controller.ts +12 -7
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/input-controller.ts +10 -5
- package/src/modes/controllers/selector-controller.ts +30 -19
- package/src/modes/controllers/todo-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +56 -9
- package/src/modes/print-mode.ts +5 -0
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/modes/theme/theme.ts +48 -8
- package/src/modes/utils/keybinding-matchers.ts +10 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/plan-mode/plan-handoff.ts +37 -0
- package/src/priority.json +4 -0
- package/src/prompts/goals/goal-continuation.md +1 -1
- package/src/prompts/steering/user-interjection.md +10 -0
- package/src/prompts/system/agent-creation-architect.md +1 -26
- package/src/prompts/system/eager-todo.md +3 -3
- package/src/prompts/system/orchestrate-notice.md +5 -5
- package/src/prompts/system/plan-mode-approved.md +14 -17
- package/src/prompts/system/plan-mode-reference.md +3 -6
- package/src/prompts/system/subagent-system-prompt.md +11 -0
- package/src/prompts/system/system-prompt.md +143 -145
- package/src/prompts/system/title-system.md +3 -2
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/browser.md +33 -30
- package/src/prompts/tools/render-mermaid.md +2 -2
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/task.md +3 -1
- package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
- package/src/sdk.ts +6 -21
- package/src/session/agent-session.ts +44 -21
- package/src/session/history-storage.ts +11 -18
- package/src/session/indexed-session-storage.ts +430 -0
- package/src/session/messages.ts +80 -0
- package/src/session/redis-session-storage.ts +66 -377
- package/src/session/session-manager.ts +109 -23
- package/src/session/session-storage.ts +148 -68
- package/src/session/sql-session-storage.ts +131 -382
- package/src/slash-commands/helpers/todo.ts +2 -2
- package/src/slash-commands/types.ts +27 -10
- package/src/task/executor.ts +9 -1
- package/src/task/index.ts +51 -1
- package/src/telemetry-export.ts +126 -0
- package/src/tiny/compiled-runtime.ts +179 -0
- package/src/tiny/text.ts +112 -1
- package/src/tiny/worker.ts +24 -2
- package/src/tools/ask.ts +133 -87
- package/src/tools/fetch.ts +17 -4
- package/src/tools/find.ts +2 -2
- package/src/tools/index.ts +6 -4
- package/src/tools/memory-recall.ts +1 -1
- package/src/tools/memory-reflect.ts +1 -1
- package/src/tools/path-utils.ts +16 -7
- package/src/tools/renderers.ts +2 -2
- package/src/tools/search.ts +6 -3
- package/src/tools/ssh.ts +26 -10
- package/src/tools/{todo-write.ts → todo.ts} +32 -35
- package/src/tools/write.ts +14 -2
- package/src/tui/status-line.ts +15 -4
- package/src/utils/session-color.ts +39 -14
- package/src/utils/title-generator.ts +9 -2
- package/src/web/search/index.ts +4 -2
- package/src/web/search/provider.ts +19 -35
- package/src/web/search/providers/base.ts +17 -0
- package/src/web/search/providers/exa.ts +111 -7
- package/src/web/search/providers/perplexity.ts +8 -4
- package/src/web/search/types.ts +74 -32
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { logger, toError } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
IndexedSessionStorage,
|
|
4
|
+
type SessionStorageBackend,
|
|
5
|
+
type SessionStorageIndexEntry,
|
|
6
|
+
} from "./indexed-session-storage";
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Minimal subset of the `bun:redis` `RedisClient` surface used by
|
|
@@ -9,6 +13,8 @@ import type { SessionStorage, SessionStorageStat, SessionStorageWriter } from ".
|
|
|
9
13
|
*/
|
|
10
14
|
export interface RedisSessionStorageClient {
|
|
11
15
|
get(key: string): Promise<string | null>;
|
|
16
|
+
getrange(key: string, start: number, end: number): Promise<string>;
|
|
17
|
+
strlen(key: string): Promise<number>;
|
|
12
18
|
set(key: string, value: string): Promise<unknown>;
|
|
13
19
|
append(key: string, value: string): Promise<number>;
|
|
14
20
|
del(...keys: string[]): Promise<number>;
|
|
@@ -29,92 +35,52 @@ export interface RedisSessionStorageOptions {
|
|
|
29
35
|
*/
|
|
30
36
|
prefix?: string;
|
|
31
37
|
/**
|
|
32
|
-
* Maximum number of keys returned per SCAN batch when warming the
|
|
38
|
+
* Maximum number of keys returned per SCAN batch when warming the metadata index.
|
|
33
39
|
* Default 500.
|
|
34
40
|
*/
|
|
35
41
|
scanCount?: number;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
interface MirrorEntry {
|
|
39
|
-
content: string;
|
|
40
|
-
mtimeMs: number;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
44
|
const DEFAULT_PREFIX = "omp:sessions:";
|
|
44
45
|
const DEFAULT_SCAN_COUNT = 500;
|
|
45
46
|
|
|
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
47
|
/**
|
|
62
48
|
* Redis-backed implementation of {@link SessionStorage}. Each session JSONL
|
|
63
49
|
* file maps to a Redis STRING key, with per-key metadata (mtime) tracked in a
|
|
64
|
-
* single sibling HASH.
|
|
65
|
-
*
|
|
66
|
-
* `
|
|
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.
|
|
50
|
+
* single sibling HASH. This process keeps only a metadata index (`size`,
|
|
51
|
+
* `mtimeMs`) in memory so synchronous `existsSync` / `statSync` /
|
|
52
|
+
* `listFilesSync` calls remain available without mirroring full content.
|
|
82
53
|
*/
|
|
83
|
-
export class RedisSessionStorage
|
|
54
|
+
export class RedisSessionStorage extends IndexedSessionStorage {
|
|
55
|
+
/**
|
|
56
|
+
* Warm the metadata index with every existing session key under the configured
|
|
57
|
+
* prefix and return the ready-to-use storage. Must be awaited before passing
|
|
58
|
+
* the storage into `SessionManager.create()` so synchronous lookups (session
|
|
59
|
+
* resume, recent sessions, EPERM-backup recovery) see the existing keyspace.
|
|
60
|
+
*/
|
|
61
|
+
static async create(options: RedisSessionStorageOptions): Promise<RedisSessionStorage> {
|
|
62
|
+
const storage = new RedisSessionStorage(new RedisSessionStorageBackend(options));
|
|
63
|
+
await storage.initialize();
|
|
64
|
+
return storage;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class RedisSessionStorageBackend implements SessionStorageBackend {
|
|
84
69
|
readonly #client: RedisSessionStorageClient;
|
|
85
70
|
readonly #prefix: string;
|
|
86
71
|
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
72
|
|
|
92
|
-
|
|
73
|
+
constructor(options: RedisSessionStorageOptions) {
|
|
93
74
|
this.#client = options.client;
|
|
94
75
|
this.#prefix = options.prefix ?? DEFAULT_PREFIX;
|
|
95
76
|
this.#scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT;
|
|
96
77
|
}
|
|
97
78
|
|
|
98
|
-
|
|
99
|
-
|
|
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;
|
|
79
|
+
init(): Promise<void> {
|
|
80
|
+
return Promise.resolve();
|
|
109
81
|
}
|
|
110
82
|
|
|
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();
|
|
83
|
+
async loadIndex(): Promise<SessionStorageIndexEntry[]> {
|
|
118
84
|
const filePrefix = this.#fileKey("");
|
|
119
85
|
const metaRaw = await this.#client.hgetall(this.#metaKey());
|
|
120
86
|
const meta: Record<string, string> = metaRaw ?? {};
|
|
@@ -133,170 +99,58 @@ export class RedisSessionStorage implements SessionStorage {
|
|
|
133
99
|
for (const key of batch) seen.add(key);
|
|
134
100
|
} while (cursor !== "0");
|
|
135
101
|
|
|
136
|
-
|
|
102
|
+
const fallbackMtimeMs = Date.now();
|
|
103
|
+
return Promise.all(
|
|
137
104
|
Array.from(seen, async key => {
|
|
138
105
|
const path = key.slice(filePrefix.length);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
106
|
+
const size = await this.#client.strlen(key);
|
|
107
|
+
const rawMtime = meta[path];
|
|
108
|
+
const parsedMtime = rawMtime === undefined ? Number.NaN : Number(rawMtime);
|
|
109
|
+
return {
|
|
110
|
+
path,
|
|
111
|
+
size,
|
|
112
|
+
mtimeMs: Number.isFinite(parsedMtime) ? parsedMtime : fallbackMtimeMs,
|
|
113
|
+
};
|
|
145
114
|
}),
|
|
146
115
|
);
|
|
147
116
|
}
|
|
148
117
|
|
|
149
|
-
|
|
150
|
-
|
|
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}`;
|
|
118
|
+
readFull(path: string): Promise<string | null> {
|
|
119
|
+
return this.#client.get(this.#fileKey(path));
|
|
171
120
|
}
|
|
172
121
|
|
|
173
|
-
|
|
174
|
-
|
|
122
|
+
async readSlices(path: string, prefixBytes: number, suffixBytes: number): Promise<[string, string]> {
|
|
123
|
+
const key = this.#fileKey(path);
|
|
124
|
+
const head = prefixBytes > 0 ? this.#client.getrange(key, 0, prefixBytes - 1) : Promise.resolve("");
|
|
125
|
+
const tail = suffixBytes > 0 ? this.#client.getrange(key, -suffixBytes, -1) : Promise.resolve("");
|
|
126
|
+
return Promise.all([head, tail]);
|
|
175
127
|
}
|
|
176
128
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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);
|
|
129
|
+
async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
|
|
130
|
+
await this.#client.set(this.#fileKey(path), content);
|
|
131
|
+
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
252
132
|
}
|
|
253
133
|
|
|
254
|
-
async
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return entry.content;
|
|
134
|
+
async append(path: string, line: string, mtimeMs: number): Promise<void> {
|
|
135
|
+
await this.#client.append(this.#fileKey(path), line);
|
|
136
|
+
await this.#client.hset(this.#metaKey(), path, String(mtimeMs));
|
|
258
137
|
}
|
|
259
138
|
|
|
260
|
-
async
|
|
261
|
-
|
|
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");
|
|
139
|
+
async truncate(path: string, mtimeMs: number): Promise<void> {
|
|
140
|
+
await this.writeFull(path, "", mtimeMs);
|
|
270
141
|
}
|
|
271
142
|
|
|
272
|
-
async
|
|
273
|
-
|
|
274
|
-
this.#
|
|
275
|
-
await this.#
|
|
143
|
+
async remove(paths: string[]): Promise<void> {
|
|
144
|
+
if (paths.length === 0) return;
|
|
145
|
+
await this.#client.del(...paths.map(path => this.#fileKey(path)));
|
|
146
|
+
await this.#client.hdel(this.#metaKey(), ...paths);
|
|
276
147
|
}
|
|
277
148
|
|
|
278
|
-
async
|
|
279
|
-
|
|
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.
|
|
149
|
+
async move(src: string, dst: string, mtimeMs: number): Promise<void> {
|
|
150
|
+
await this.#client.rename(this.#fileKey(src), this.#fileKey(dst));
|
|
297
151
|
try {
|
|
298
152
|
await this.#client.hdel(this.#metaKey(), src);
|
|
299
|
-
await this.#client.hset(this.#metaKey(), dst, String(
|
|
153
|
+
await this.#client.hset(this.#metaKey(), dst, String(mtimeMs));
|
|
300
154
|
} catch (err) {
|
|
301
155
|
logger.warn("Redis session storage meta rename failed", {
|
|
302
156
|
src,
|
|
@@ -306,176 +160,11 @@ export class RedisSessionStorage implements SessionStorage {
|
|
|
306
160
|
}
|
|
307
161
|
}
|
|
308
162
|
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
}
|
|
163
|
+
#fileKey(path: string): string {
|
|
164
|
+
return `${this.#prefix}file:${path}`;
|
|
476
165
|
}
|
|
477
166
|
|
|
478
|
-
|
|
479
|
-
return this.#
|
|
167
|
+
#metaKey(): string {
|
|
168
|
+
return `${this.#prefix}meta`;
|
|
480
169
|
}
|
|
481
170
|
}
|