-
Notifications
You must be signed in to change notification settings - Fork 111
Expand file tree
/
Copy pathcomposite.ts
More file actions
268 lines (236 loc) · 8.1 KB
/
composite.ts
File metadata and controls
268 lines (236 loc) · 8.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
/**
* CompositeBackend: Route operations to different backends based on path prefix.
*/
import type {
BackendProtocol,
EditResult,
FileData,
FileInfo,
GrepMatch,
WriteResult,
} from "./protocol.js";
/**
* Backend that routes file operations to different backends based on path prefix.
*
* This enables hybrid storage strategies like:
* - `/memories/` → StoreBackend (persistent, cross-thread)
* - Everything else → StateBackend (ephemeral, per-thread)
*
* The CompositeBackend handles path prefix stripping/re-adding transparently.
*/
export class CompositeBackend implements BackendProtocol {
private default: BackendProtocol;
private routes: Record<string, BackendProtocol>;
private sortedRoutes: Array<[string, BackendProtocol]>;
constructor(
defaultBackend: BackendProtocol,
routes: Record<string, BackendProtocol>,
) {
this.default = defaultBackend;
this.routes = routes;
// Sort routes by length (longest first) for correct prefix matching
this.sortedRoutes = Object.entries(routes).sort(
(a, b) => b[0].length - a[0].length,
);
}
/**
* Determine which backend handles this key and strip prefix.
*
* @param key - Original file path
* @returns Tuple of [backend, stripped_key] where stripped_key has the route
* prefix removed (but keeps leading slash).
*/
private getBackendAndKey(key: string): [BackendProtocol, string] {
// Check routes in order of length (longest first)
for (const [prefix, backend] of this.sortedRoutes) {
if (key.startsWith(prefix)) {
// Strip full prefix and ensure a leading slash remains
// e.g., "/memories/notes.txt" → "/notes.txt"; "/memories/" → "/"
const suffix = key.substring(prefix.length);
const strippedKey = suffix ? "/" + suffix : "/";
return [backend, strippedKey];
}
}
return [this.default, key];
}
/**
* List files and directories in the specified directory (non-recursive).
*
* @param path - Absolute path to directory
* @returns List of FileInfo objects with route prefixes added, for files and directories
* directly in the directory. Directories have a trailing / in their path and is_dir=true.
*/
async lsInfo(path: string): Promise<FileInfo[]> {
// Check if path matches a specific route
for (const [routePrefix, backend] of this.sortedRoutes) {
if (path.startsWith(routePrefix.replace(/\/$/, ""))) {
// Query only the matching routed backend
const suffix = path.substring(routePrefix.length);
const searchPath = suffix ? "/" + suffix : "/";
const infos = await backend.lsInfo(searchPath);
// Add route prefix back to paths
const prefixed: FileInfo[] = [];
for (const fi of infos) {
prefixed.push({
...fi,
path: routePrefix.slice(0, -1) + fi.path,
});
}
return prefixed;
}
}
// At root, aggregate default and all routed backends
if (path === "/") {
const results: FileInfo[] = [];
const defaultInfos = await this.default.lsInfo(path);
results.push(...defaultInfos);
// Add the route itself as a directory (e.g., /memories/)
for (const [routePrefix] of this.sortedRoutes) {
results.push({
path: routePrefix,
is_dir: true,
size: 0,
modified_at: "",
});
}
results.sort((a, b) => a.path.localeCompare(b.path));
return results;
}
// Path doesn't match a route: query only default backend
return await this.default.lsInfo(path);
}
/**
* Read file content, routing to appropriate backend.
*
* @param filePath - Absolute file path
* @param offset - Line offset to start reading from (0-indexed)
* @param limit - Maximum number of lines to read
* @returns Formatted file content with line numbers, or error message
*/
async read(
filePath: string,
offset: number = 0,
limit: number = 2000,
): Promise<string> {
const [backend, strippedKey] = this.getBackendAndKey(filePath);
return await backend.read(strippedKey, offset, limit);
}
/**
* Read file content as raw FileData.
*
* @param filePath - Absolute file path
* @returns Raw file content as FileData
*/
async readRaw(filePath: string): Promise<FileData> {
const [backend, strippedKey] = this.getBackendAndKey(filePath);
return await backend.readRaw(strippedKey);
}
/**
* Structured search results or error string for invalid input.
*/
async grepRaw(
pattern: string,
path: string = "/",
glob: string | null = null,
): Promise<GrepMatch[] | string> {
// If path targets a specific route, search only that backend
for (const [routePrefix, backend] of this.sortedRoutes) {
if (path.startsWith(routePrefix.replace(/\/$/, ""))) {
const searchPath = path.substring(routePrefix.length - 1);
const raw = await backend.grepRaw(pattern, searchPath || "/", glob);
if (typeof raw === "string") {
return raw;
}
// Add route prefix back
return raw.map((m) => ({
...m,
path: routePrefix.slice(0, -1) + m.path,
}));
}
}
// Otherwise, search default and all routed backends and merge
const allMatches: GrepMatch[] = [];
const rawDefault = await this.default.grepRaw(pattern, path, glob);
if (typeof rawDefault === "string") {
return rawDefault;
}
allMatches.push(...rawDefault);
// Search all routes
for (const [routePrefix, backend] of Object.entries(this.routes)) {
const raw = await backend.grepRaw(pattern, "/", glob);
if (typeof raw === "string") {
return raw;
}
// Add route prefix back
allMatches.push(
...raw.map((m) => ({
...m,
path: routePrefix.slice(0, -1) + m.path,
})),
);
}
return allMatches;
}
/**
* Structured glob matching returning FileInfo objects.
*/
async globInfo(pattern: string, path: string = "/"): Promise<FileInfo[]> {
const results: FileInfo[] = [];
// Route based on path, not pattern
for (const [routePrefix, backend] of this.sortedRoutes) {
if (path.startsWith(routePrefix.replace(/\/$/, ""))) {
const searchPath = path.substring(routePrefix.length - 1);
const infos = await backend.globInfo(pattern, searchPath || "/");
// Add route prefix back
return infos.map((fi) => ({
...fi,
path: routePrefix.slice(0, -1) + fi.path,
}));
}
}
// Path doesn't match any specific route - search default backend AND all routed backends
const defaultInfos = await this.default.globInfo(pattern, path);
results.push(...defaultInfos);
for (const [routePrefix, backend] of Object.entries(this.routes)) {
const infos = await backend.globInfo(pattern, "/");
results.push(
...infos.map((fi) => ({
...fi,
path: routePrefix.slice(0, -1) + fi.path,
})),
);
}
// Deterministic ordering
results.sort((a, b) => a.path.localeCompare(b.path));
return results;
}
/**
* Create a new file, routing to appropriate backend.
*
* @param filePath - Absolute file path
* @param content - File content as string
* @returns WriteResult with path or error
*/
async write(filePath: string, content: string): Promise<WriteResult> {
const [backend, strippedKey] = this.getBackendAndKey(filePath);
return await backend.write(strippedKey, content);
}
/**
* Edit a file, routing to appropriate backend.
*
* @param filePath - Absolute file path
* @param oldString - String to find and replace
* @param newString - Replacement string
* @param replaceAll - If true, replace all occurrences
* @returns EditResult with path, occurrences, or error
*/
async edit(
filePath: string,
oldString: string,
newString: string,
replaceAll: boolean = false,
): Promise<EditResult> {
const [backend, strippedKey] = this.getBackendAndKey(filePath);
return await backend.edit(strippedKey, oldString, newString, replaceAll);
}
}