@hraness/dawg 0.2.0 → 0.4.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/CHANGELOG.md +201 -0
- package/DAWG.md +762 -11
- package/README.md +79 -30
- package/core/chords.ts +1724 -0
- package/core/diff.ts +261 -0
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/key.ts +43 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/pitch.ts +60 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +1054 -14
- package/core/sdk/eval-child.ts +125 -0
- package/core/sdk/eval.ts +260 -0
- package/core/sdk/print.ts +675 -0
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +4159 -0
- package/core/slug.ts +19 -0
- package/core/synth.ts +1001 -0
- package/package.json +8 -5
- package/src/agent/agent.ts +317 -16
- package/src/agent/brief.ts +50 -5
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/gateway.ts +215 -41
- package/src/agent/models.ts +633 -0
- package/src/agent/ops.ts +3 -18
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +55 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/provider.ts +234 -106
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/sse.ts +31 -7
- package/src/agent/tools.ts +705 -14
- package/src/agent/usage.ts +296 -0
- package/src/agent/workspace.ts +683 -0
- package/src/agent/xcb-agent.ts +35 -14
- package/src/agent/xcb.ts +233 -21
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/click.ts +125 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +430 -33
- package/src/audio/kits.ts +200 -0
- package/src/audio/live.ts +152 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/player.ts +19 -4
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/render-worker.ts +68 -0
- package/src/audio/renderer.ts +174 -0
- package/src/audio/sampler.ts +420 -0
- package/src/audio/samples.ts +1025 -0
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +550 -334
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/auth/cli.ts +146 -27
- package/src/auth/credentials.ts +167 -41
- package/src/auth/discover.ts +481 -0
- package/src/auth/login.ts +885 -128
- package/src/auth/openrouter.ts +206 -0
- package/src/auth/picker.ts +282 -0
- package/src/auth/runner.ts +25 -2
- package/src/auth/tui.ts +60 -43
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +192 -0
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +535 -0
- package/src/commands/history.ts +32 -19
- package/src/commands/music.ts +34 -15
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +462 -0
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +2136 -170
- package/src/media/analyze.ts +364 -0
- package/src/media/backend.ts +253 -0
- package/src/media/cli.ts +186 -0
- package/src/media/download.ts +281 -0
- package/src/media/dsp.ts +281 -0
- package/src/media/import.ts +130 -0
- package/src/media/lyrics.ts +201 -0
- package/src/media/notes.ts +363 -0
- package/src/media/paths.ts +168 -0
- package/src/media/process.ts +226 -0
- package/src/media/registry.ts +9 -0
- package/src/media/sidecar.ts +72 -0
- package/src/media/stemdeck.ts +254 -0
- package/src/media/stems.ts +173 -0
- package/src/media/tools.ts +397 -0
- package/src/media/types.ts +92 -0
- package/src/media/vendor/basic-pitch.ts +261 -0
- package/src/media/vendor/drums.ts +817 -0
- package/src/media/vendor/grid.ts +203 -0
- package/src/media/vendor/util.ts +139 -0
- package/src/media/vendor/wav.ts +233 -0
- package/src/media/wavetable.ts +202 -0
- package/src/project/check.ts +80 -0
- package/src/project/init.ts +253 -0
- package/src/project/sync.ts +432 -0
- package/src/project/typecheck.ts +149 -0
- package/src/render.ts +49 -6
- package/src/session/attach.ts +3 -4
- package/src/session/daemon.ts +25 -8
- package/src/session/delta.ts +249 -0
- package/src/session/naming.ts +6 -38
- package/src/session/port.ts +26 -6
- package/src/session/rebase.ts +38 -8
- package/src/session/store.ts +116 -21
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +2125 -0
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-mode.ts +442 -0
- package/src/tui/play-session.ts +984 -0
- package/src/tui/sketch.ts +108 -0
- package/src/web/fetch.ts +340 -0
- package/src/web/http.ts +137 -0
- package/src/web/search.ts +684 -0
- package/tui/activity.ts +42 -3
- package/tui/app.ts +398 -33
- package/tui/drums.ts +44 -0
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +24 -2
- package/tui/layers.ts +14 -2
- package/tui/play-strip.ts +188 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace file access for the agent. The project root is the directory
|
|
3
|
+
* `dawg` runs in. Every path the model supplies is resolved lexically, then
|
|
4
|
+
* through `realpath`, and must stay under the root; `.dawg/` is dawg's own
|
|
5
|
+
* state and is never reachable. Reads cover the whole project; writes are
|
|
6
|
+
* limited to `song.ts` and the focused track's `tracks/<slug>/` directory.
|
|
7
|
+
* Every operation is bounded in entries and bytes.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
lstat,
|
|
11
|
+
mkdir,
|
|
12
|
+
open,
|
|
13
|
+
readdir,
|
|
14
|
+
readFile as fsReadFile,
|
|
15
|
+
realpath,
|
|
16
|
+
rename,
|
|
17
|
+
stat,
|
|
18
|
+
unlink,
|
|
19
|
+
writeFile as fsWriteFile,
|
|
20
|
+
} from "node:fs/promises";
|
|
21
|
+
import {
|
|
22
|
+
basename,
|
|
23
|
+
dirname,
|
|
24
|
+
isAbsolute,
|
|
25
|
+
join,
|
|
26
|
+
relative,
|
|
27
|
+
resolve,
|
|
28
|
+
sep,
|
|
29
|
+
} from "node:path";
|
|
30
|
+
|
|
31
|
+
export const WORKSPACE_LIMITS = Object.freeze({
|
|
32
|
+
maxPathChars: 512,
|
|
33
|
+
maxPathSegments: 32,
|
|
34
|
+
maxListEntries: 500,
|
|
35
|
+
maxReadBytes: 256 * 1024,
|
|
36
|
+
/** Bytes scanned to honour a line offset; files beyond this are reported as partial. */
|
|
37
|
+
maxScanBytes: 4 * 1024 * 1024,
|
|
38
|
+
maxWriteBytes: 1024 * 1024,
|
|
39
|
+
maxReadLines: 100_000,
|
|
40
|
+
maxOutlineLines: 30,
|
|
41
|
+
maxOutlineLineChars: 120,
|
|
42
|
+
maxNotesBytes: 1024,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** The directory dawg owns; invisible to the file tools. */
|
|
46
|
+
export const RUNTIME_DIR = ".dawg";
|
|
47
|
+
const OUTLINE_HIDDEN = new Set([RUNTIME_DIR, "node_modules", ".git"]);
|
|
48
|
+
|
|
49
|
+
export type WorkspaceScope = Readonly<{
|
|
50
|
+
/** Absolute project root (the session workspace). */
|
|
51
|
+
root: string;
|
|
52
|
+
/** Slug of the focused track; its `tracks/<slug>/` directory is writable. */
|
|
53
|
+
trackSlug: string;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
/** A path or content the tools refused; nothing was touched. */
|
|
57
|
+
export class WorkspaceError extends Error {
|
|
58
|
+
constructor(message: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "WorkspaceError";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type ResolvedPath = Readonly<{
|
|
65
|
+
/** Absolute path under the (unresolved) root. */
|
|
66
|
+
abs: string;
|
|
67
|
+
/** Project-relative path with `/` separators; `""` is the root. */
|
|
68
|
+
rel: string;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
/** Project-relative roots this scope may write under. */
|
|
72
|
+
export function writeRoots(scope: WorkspaceScope): readonly string[] {
|
|
73
|
+
return ["song.ts", `tracks/${scope.trackSlug}/`];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function inWriteScope(scope: WorkspaceScope, rel: string): boolean {
|
|
77
|
+
return rel === "song.ts" || rel.startsWith(`tracks/${scope.trackSlug}/`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function outsideWriteScope(scope: WorkspaceScope, rel: string): WorkspaceError {
|
|
81
|
+
return new WorkspaceError(
|
|
82
|
+
`${rel || "."} is outside this window's writable scope; it may write ${writeRoots(scope).join(" and ")} (reads work anywhere except ${RUNTIME_DIR}/)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function escapes(rel: string): boolean {
|
|
87
|
+
return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Lexical resolution: a string, bounded, under the root and not in `.dawg/`. */
|
|
91
|
+
export function lexicalPath(
|
|
92
|
+
scope: WorkspaceScope,
|
|
93
|
+
input: unknown,
|
|
94
|
+
label = "path",
|
|
95
|
+
): ResolvedPath {
|
|
96
|
+
if (typeof input !== "string")
|
|
97
|
+
throw new WorkspaceError(`${label} must be a string`);
|
|
98
|
+
if (input.length > WORKSPACE_LIMITS.maxPathChars)
|
|
99
|
+
throw new WorkspaceError(
|
|
100
|
+
`${label} is longer than ${WORKSPACE_LIMITS.maxPathChars} characters`,
|
|
101
|
+
);
|
|
102
|
+
if (input.includes("\0"))
|
|
103
|
+
throw new WorkspaceError(`${label} contains a NUL byte`);
|
|
104
|
+
const abs = resolve(scope.root, input);
|
|
105
|
+
const rel = relative(scope.root, abs);
|
|
106
|
+
if (escapes(rel))
|
|
107
|
+
throw new WorkspaceError(
|
|
108
|
+
`${label} ${input.slice(0, 80)} leaves the project directory`,
|
|
109
|
+
);
|
|
110
|
+
const segments = rel === "" ? [] : rel.split(sep);
|
|
111
|
+
if (segments.length > WORKSPACE_LIMITS.maxPathSegments)
|
|
112
|
+
throw new WorkspaceError(
|
|
113
|
+
`${label} has more than ${WORKSPACE_LIMITS.maxPathSegments} segments`,
|
|
114
|
+
);
|
|
115
|
+
if (segments[0] === RUNTIME_DIR)
|
|
116
|
+
throw new WorkspaceError(
|
|
117
|
+
`${segments.join("/")} is inside ${RUNTIME_DIR}/, which dawg owns; tools cannot read or write there`,
|
|
118
|
+
);
|
|
119
|
+
return { abs, rel: segments.join("/") };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function realRoot(scope: WorkspaceScope): Promise<string> {
|
|
123
|
+
try {
|
|
124
|
+
return await realpath(scope.root);
|
|
125
|
+
} catch {
|
|
126
|
+
throw new WorkspaceError("project directory is unavailable");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Relative path of `real` under the real root, or a diagnostic for `rel`. */
|
|
131
|
+
function realRelative(root: string, real: string, rel: string): string {
|
|
132
|
+
const realRel = relative(root, real);
|
|
133
|
+
if (escapes(realRel))
|
|
134
|
+
throw new WorkspaceError(
|
|
135
|
+
`${rel || "."} resolves through a symlink that leaves the project`,
|
|
136
|
+
);
|
|
137
|
+
const segments = realRel === "" ? [] : realRel.split(sep);
|
|
138
|
+
if (segments[0] === RUNTIME_DIR)
|
|
139
|
+
throw new WorkspaceError(
|
|
140
|
+
`${rel || "."} resolves into ${RUNTIME_DIR}/, which dawg owns`,
|
|
141
|
+
);
|
|
142
|
+
return segments.join("/");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Resolve a path for reading: it must exist and stay under the root after `realpath`. */
|
|
146
|
+
export async function resolveReadPath(
|
|
147
|
+
scope: WorkspaceScope,
|
|
148
|
+
input: unknown,
|
|
149
|
+
label = "path",
|
|
150
|
+
): Promise<ResolvedPath & { real: string }> {
|
|
151
|
+
const lexical = lexicalPath(scope, input, label);
|
|
152
|
+
const root = await realRoot(scope);
|
|
153
|
+
let real: string;
|
|
154
|
+
try {
|
|
155
|
+
real = await realpath(lexical.abs);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (isNotFound(error))
|
|
158
|
+
throw new WorkspaceError(`${lexical.rel || "."}: no such file`);
|
|
159
|
+
throw new WorkspaceError(`${lexical.rel || "."}: ${fsMessage(error)}`);
|
|
160
|
+
}
|
|
161
|
+
realRelative(root, real, lexical.rel);
|
|
162
|
+
return { ...lexical, real };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolve a path for writing: inside the write scope lexically and after
|
|
167
|
+
* resolving its nearest existing ancestor, never through a symlink, never a
|
|
168
|
+
* directory. The file itself may not exist yet.
|
|
169
|
+
*/
|
|
170
|
+
export async function resolveWritePath(
|
|
171
|
+
scope: WorkspaceScope,
|
|
172
|
+
input: unknown,
|
|
173
|
+
): Promise<ResolvedPath> {
|
|
174
|
+
const lexical = lexicalPath(scope, input);
|
|
175
|
+
if (!inWriteScope(scope, lexical.rel))
|
|
176
|
+
throw outsideWriteScope(scope, lexical.rel);
|
|
177
|
+
const root = await realRoot(scope);
|
|
178
|
+
let ancestor = lexical.abs;
|
|
179
|
+
const tail: string[] = [];
|
|
180
|
+
for (;;) {
|
|
181
|
+
try {
|
|
182
|
+
const info = await lstat(ancestor);
|
|
183
|
+
if (tail.length === 0) {
|
|
184
|
+
if (info.isSymbolicLink())
|
|
185
|
+
throw new WorkspaceError(
|
|
186
|
+
`${lexical.rel} is a symlink; tools do not write through links`,
|
|
187
|
+
);
|
|
188
|
+
if (info.isDirectory())
|
|
189
|
+
throw new WorkspaceError(`${lexical.rel} is a directory`);
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error instanceof WorkspaceError) throw error;
|
|
194
|
+
if (!isNotFound(error))
|
|
195
|
+
throw new WorkspaceError(`${lexical.rel}: ${fsMessage(error)}`);
|
|
196
|
+
}
|
|
197
|
+
const parent = dirname(ancestor);
|
|
198
|
+
if (parent === ancestor)
|
|
199
|
+
throw new WorkspaceError("project directory is unavailable");
|
|
200
|
+
tail.unshift(basename(ancestor));
|
|
201
|
+
ancestor = parent;
|
|
202
|
+
}
|
|
203
|
+
const realAncestor = await realpath(ancestor).catch(() => {
|
|
204
|
+
throw new WorkspaceError(`${lexical.rel}: parent is unavailable`);
|
|
205
|
+
});
|
|
206
|
+
const realRel = realRelative(root, join(realAncestor, ...tail), lexical.rel);
|
|
207
|
+
if (!inWriteScope(scope, realRel)) throw outsideWriteScope(scope, realRel);
|
|
208
|
+
return lexical;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export type ListedEntry = Readonly<{
|
|
212
|
+
name: string;
|
|
213
|
+
kind: "dir" | "file" | "link" | "other";
|
|
214
|
+
size: number;
|
|
215
|
+
mtime: string;
|
|
216
|
+
}>;
|
|
217
|
+
|
|
218
|
+
/** Directory listing: directories first, bounded to `maxListEntries`. */
|
|
219
|
+
export async function listFiles(
|
|
220
|
+
scope: WorkspaceScope,
|
|
221
|
+
input: unknown = "",
|
|
222
|
+
): Promise<{ text: string; summary: string }> {
|
|
223
|
+
const target = await resolveReadPath(scope, input ?? "");
|
|
224
|
+
let names: import("node:fs").Dirent[];
|
|
225
|
+
try {
|
|
226
|
+
names = await readdir(target.real, { withFileTypes: true });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (isNotDirectory(error))
|
|
229
|
+
throw new WorkspaceError(
|
|
230
|
+
`${target.rel} is a file, not a directory; use read_file`,
|
|
231
|
+
);
|
|
232
|
+
throw new WorkspaceError(`${target.rel || "."}: ${fsMessage(error)}`);
|
|
233
|
+
}
|
|
234
|
+
const visible = names
|
|
235
|
+
.filter((entry) => !(target.rel === "" && entry.name === RUNTIME_DIR))
|
|
236
|
+
.sort(
|
|
237
|
+
(left, right) =>
|
|
238
|
+
Number(right.isDirectory()) - Number(left.isDirectory()) ||
|
|
239
|
+
left.name.localeCompare(right.name),
|
|
240
|
+
);
|
|
241
|
+
const shown = visible.slice(0, WORKSPACE_LIMITS.maxListEntries);
|
|
242
|
+
const entries: ListedEntry[] = [];
|
|
243
|
+
for (const entry of shown) {
|
|
244
|
+
try {
|
|
245
|
+
const info = await lstat(join(target.real, entry.name));
|
|
246
|
+
entries.push({
|
|
247
|
+
name: entry.name,
|
|
248
|
+
kind: info.isDirectory()
|
|
249
|
+
? "dir"
|
|
250
|
+
: info.isSymbolicLink()
|
|
251
|
+
? "link"
|
|
252
|
+
: info.isFile()
|
|
253
|
+
? "file"
|
|
254
|
+
: "other",
|
|
255
|
+
size: info.size,
|
|
256
|
+
mtime: info.mtime.toISOString().slice(0, 19) + "Z",
|
|
257
|
+
});
|
|
258
|
+
} catch {
|
|
259
|
+
entries.push({ name: entry.name, kind: "other", size: 0, mtime: "" });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const lines = entries.map((entry) =>
|
|
263
|
+
entry.kind === "dir"
|
|
264
|
+
? `${entry.name}/`
|
|
265
|
+
: `${entry.name}${entry.kind === "link" ? "@" : ""} ${formatBytes(entry.size)} ${entry.mtime}`,
|
|
266
|
+
);
|
|
267
|
+
const omitted = visible.length - shown.length;
|
|
268
|
+
if (omitted > 0) lines.push(`… ${omitted} more entries not shown`);
|
|
269
|
+
const where = target.rel || ".";
|
|
270
|
+
return {
|
|
271
|
+
text: `${where}/ · ${visible.length} entr${visible.length === 1 ? "y" : "ies"}${lines.length ? "\n" : ""}${lines.join("\n")}`,
|
|
272
|
+
summary: `listed ${where} (${visible.length})`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const MAGIC: ReadonlyArray<readonly [string, (head: Uint8Array) => boolean]> = [
|
|
277
|
+
["wav", (h) => ascii(h, 0, "RIFF") && ascii(h, 8, "WAVE")],
|
|
278
|
+
["aiff", (h) => ascii(h, 0, "FORM") && ascii(h, 8, "AIFF")],
|
|
279
|
+
["flac", (h) => ascii(h, 0, "fLaC")],
|
|
280
|
+
["ogg", (h) => ascii(h, 0, "OggS")],
|
|
281
|
+
[
|
|
282
|
+
"mp3",
|
|
283
|
+
(h) => ascii(h, 0, "ID3") || (h[0] === 0xff && (h[1]! & 0xe0) === 0xe0),
|
|
284
|
+
],
|
|
285
|
+
["mp4", (h) => ascii(h, 4, "ftyp")],
|
|
286
|
+
["midi", (h) => ascii(h, 0, "MThd")],
|
|
287
|
+
["png", (h) => h[0] === 0x89 && ascii(h, 1, "PNG")],
|
|
288
|
+
["jpeg", (h) => h[0] === 0xff && h[1] === 0xd8],
|
|
289
|
+
["gif", (h) => ascii(h, 0, "GIF8")],
|
|
290
|
+
["pdf", (h) => ascii(h, 0, "%PDF")],
|
|
291
|
+
["zip", (h) => ascii(h, 0, "PK\u0003\u0004")],
|
|
292
|
+
["gzip", (h) => h[0] === 0x1f && h[1] === 0x8b],
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
function ascii(head: Uint8Array, at: number, text: string): boolean {
|
|
296
|
+
for (let i = 0; i < text.length; i += 1)
|
|
297
|
+
if (head[at + i] !== text.charCodeAt(i)) return false;
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** `undefined` for text; otherwise a short type name. */
|
|
302
|
+
export function detectBinary(head: Uint8Array): string | undefined {
|
|
303
|
+
for (const [type, test] of MAGIC)
|
|
304
|
+
if (head.length >= 12 && test(head)) return type;
|
|
305
|
+
const window = head.subarray(0, 8192);
|
|
306
|
+
return window.includes(0) ? "binary" : undefined;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export type ReadRange = Readonly<{ offset?: number; limit?: number }>;
|
|
310
|
+
|
|
311
|
+
/** Read a text file by lines, bounded to `maxReadBytes` of output. */
|
|
312
|
+
export async function readFile(
|
|
313
|
+
scope: WorkspaceScope,
|
|
314
|
+
input: unknown,
|
|
315
|
+
range: ReadRange = {},
|
|
316
|
+
): Promise<{ text: string; summary: string }> {
|
|
317
|
+
const offset = range.offset ?? 1;
|
|
318
|
+
const limit = range.limit ?? WORKSPACE_LIMITS.maxReadLines;
|
|
319
|
+
const target = await resolveReadPath(scope, input);
|
|
320
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
321
|
+
try {
|
|
322
|
+
info = await stat(target.real);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
throw new WorkspaceError(`${target.rel}: ${fsMessage(error)}`);
|
|
325
|
+
}
|
|
326
|
+
if (info.isDirectory())
|
|
327
|
+
throw new WorkspaceError(`${target.rel} is a directory; use list_files`);
|
|
328
|
+
if (!info.isFile())
|
|
329
|
+
throw new WorkspaceError(`${target.rel} is not a regular file`);
|
|
330
|
+
const scan = Math.min(info.size, WORKSPACE_LIMITS.maxScanBytes);
|
|
331
|
+
const buffer = new Uint8Array(scan);
|
|
332
|
+
const handle = await open(target.real, "r");
|
|
333
|
+
let read = 0;
|
|
334
|
+
try {
|
|
335
|
+
while (read < scan) {
|
|
336
|
+
const { bytesRead } = await handle.read(buffer, read, scan - read, read);
|
|
337
|
+
if (bytesRead === 0) break;
|
|
338
|
+
read += bytesRead;
|
|
339
|
+
}
|
|
340
|
+
} finally {
|
|
341
|
+
await handle.close();
|
|
342
|
+
}
|
|
343
|
+
const head = buffer.subarray(0, read);
|
|
344
|
+
const type = detectBinary(head);
|
|
345
|
+
if (type)
|
|
346
|
+
return {
|
|
347
|
+
text: `${target.rel}: ${type} file, ${formatBytes(info.size)} (${info.size} bytes); not readable as text`,
|
|
348
|
+
summary: `read ${target.rel} (${type})`,
|
|
349
|
+
};
|
|
350
|
+
const decoded = new TextDecoder("utf-8").decode(head);
|
|
351
|
+
const lines = decoded.split("\n");
|
|
352
|
+
if (lines.length > 1 && lines[lines.length - 1] === "" && read === info.size)
|
|
353
|
+
lines.pop();
|
|
354
|
+
const partialScan = read < info.size;
|
|
355
|
+
const total = partialScan ? lines.length - 1 : lines.length;
|
|
356
|
+
if (offset > total && total > 0)
|
|
357
|
+
throw new WorkspaceError(
|
|
358
|
+
`${target.rel} has ${total}${partialScan ? "+" : ""} lines; offset ${offset} is past the end`,
|
|
359
|
+
);
|
|
360
|
+
const selected = lines.slice(offset - 1, offset - 1 + limit);
|
|
361
|
+
const encoder = new TextEncoder();
|
|
362
|
+
const out: string[] = [];
|
|
363
|
+
let bytes = 0;
|
|
364
|
+
for (const line of selected) {
|
|
365
|
+
const size = encoder.encode(line).byteLength + 1;
|
|
366
|
+
if (bytes + size > WORKSPACE_LIMITS.maxReadBytes) break;
|
|
367
|
+
bytes += size;
|
|
368
|
+
out.push(line);
|
|
369
|
+
}
|
|
370
|
+
const first = total === 0 ? 0 : offset;
|
|
371
|
+
const last = total === 0 ? 0 : offset + out.length - 1;
|
|
372
|
+
const notes: string[] = [];
|
|
373
|
+
if (out.length < selected.length)
|
|
374
|
+
notes.push(
|
|
375
|
+
`truncated at ${formatBytes(WORKSPACE_LIMITS.maxReadBytes)}; continue with offset ${last + 1}`,
|
|
376
|
+
);
|
|
377
|
+
else if (last < total) notes.push(`continue with offset ${last + 1}`);
|
|
378
|
+
if (partialScan)
|
|
379
|
+
notes.push(
|
|
380
|
+
`only the first ${formatBytes(WORKSPACE_LIMITS.maxScanBytes)} were scanned`,
|
|
381
|
+
);
|
|
382
|
+
const header = `${target.rel} · lines ${first}-${last} of ${total}${partialScan ? "+" : ""}${notes.length ? ` · ${notes.join("; ")}` : ""}`;
|
|
383
|
+
return {
|
|
384
|
+
text: out.length ? `${header}\n${out.join("\n")}` : header,
|
|
385
|
+
summary: `read ${target.rel} (${out.length} line${out.length === 1 ? "" : "s"})`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export type WriteResult = Readonly<{
|
|
390
|
+
rel: string;
|
|
391
|
+
bytes: number;
|
|
392
|
+
created: boolean;
|
|
393
|
+
text: string;
|
|
394
|
+
summary: string;
|
|
395
|
+
}>;
|
|
396
|
+
|
|
397
|
+
/** Replace or create a file atomically (temp file + rename in the same directory). */
|
|
398
|
+
export async function writeFile(
|
|
399
|
+
scope: WorkspaceScope,
|
|
400
|
+
input: unknown,
|
|
401
|
+
content: unknown,
|
|
402
|
+
): Promise<WriteResult> {
|
|
403
|
+
if (typeof content !== "string")
|
|
404
|
+
throw new WorkspaceError("content must be a string");
|
|
405
|
+
const bytes = new TextEncoder().encode(content);
|
|
406
|
+
if (bytes.byteLength > WORKSPACE_LIMITS.maxWriteBytes)
|
|
407
|
+
throw new WorkspaceError(
|
|
408
|
+
`content is ${formatBytes(bytes.byteLength)}; the write limit is ${formatBytes(WORKSPACE_LIMITS.maxWriteBytes)}`,
|
|
409
|
+
);
|
|
410
|
+
const target = await resolveWritePath(scope, input);
|
|
411
|
+
const created = !(await exists(target.abs));
|
|
412
|
+
await mkdir(dirname(target.abs), { recursive: true }).catch((error) => {
|
|
413
|
+
throw new WorkspaceError(`${target.rel}: ${fsMessage(error)}`);
|
|
414
|
+
});
|
|
415
|
+
await atomicWrite(target, bytes);
|
|
416
|
+
return {
|
|
417
|
+
rel: target.rel,
|
|
418
|
+
bytes: bytes.byteLength,
|
|
419
|
+
created,
|
|
420
|
+
text: `wrote ${target.rel} (${bytes.byteLength} bytes${created ? ", new file" : ""})`,
|
|
421
|
+
summary: `wrote ${target.rel} (${formatBytes(bytes.byteLength)})`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Replace exactly one occurrence of `oldText`; otherwise report the count. */
|
|
426
|
+
export async function editFile(
|
|
427
|
+
scope: WorkspaceScope,
|
|
428
|
+
input: unknown,
|
|
429
|
+
oldText: unknown,
|
|
430
|
+
newText: unknown,
|
|
431
|
+
): Promise<WriteResult> {
|
|
432
|
+
if (typeof oldText !== "string" || oldText.length === 0)
|
|
433
|
+
throw new WorkspaceError("old must be a non-empty string");
|
|
434
|
+
if (typeof newText !== "string")
|
|
435
|
+
throw new WorkspaceError("new must be a string");
|
|
436
|
+
if (oldText === newText)
|
|
437
|
+
throw new WorkspaceError("old and new are identical; nothing to change");
|
|
438
|
+
if (
|
|
439
|
+
oldText.length > WORKSPACE_LIMITS.maxWriteBytes ||
|
|
440
|
+
newText.length > WORKSPACE_LIMITS.maxWriteBytes
|
|
441
|
+
)
|
|
442
|
+
throw new WorkspaceError(
|
|
443
|
+
`old and new must each be under ${formatBytes(WORKSPACE_LIMITS.maxWriteBytes)}`,
|
|
444
|
+
);
|
|
445
|
+
const target = await resolveWritePath(scope, input);
|
|
446
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
447
|
+
try {
|
|
448
|
+
info = await stat(target.abs);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (isNotFound(error))
|
|
451
|
+
throw new WorkspaceError(
|
|
452
|
+
`${target.rel}: no such file; use write_file to create it`,
|
|
453
|
+
);
|
|
454
|
+
throw new WorkspaceError(`${target.rel}: ${fsMessage(error)}`);
|
|
455
|
+
}
|
|
456
|
+
if (info.size > WORKSPACE_LIMITS.maxWriteBytes)
|
|
457
|
+
throw new WorkspaceError(
|
|
458
|
+
`${target.rel} is ${formatBytes(info.size)}, above the ${formatBytes(WORKSPACE_LIMITS.maxWriteBytes)} edit limit`,
|
|
459
|
+
);
|
|
460
|
+
const raw = await fsReadFile(target.abs);
|
|
461
|
+
if (detectBinary(raw))
|
|
462
|
+
throw new WorkspaceError(`${target.rel} is not a text file`);
|
|
463
|
+
const current = raw.toString("utf8");
|
|
464
|
+
let count = 0;
|
|
465
|
+
let at = current.indexOf(oldText);
|
|
466
|
+
const first = at;
|
|
467
|
+
while (at !== -1 && count < 1000) {
|
|
468
|
+
count += 1;
|
|
469
|
+
at = current.indexOf(oldText, at + oldText.length);
|
|
470
|
+
}
|
|
471
|
+
if (count === 0)
|
|
472
|
+
throw new WorkspaceError(
|
|
473
|
+
`old text was not found in ${target.rel}; read the file and copy the exact text`,
|
|
474
|
+
);
|
|
475
|
+
if (count > 1)
|
|
476
|
+
throw new WorkspaceError(
|
|
477
|
+
`old text occurs ${count} times in ${target.rel}; include more surrounding lines so it matches once`,
|
|
478
|
+
);
|
|
479
|
+
const next =
|
|
480
|
+
current.slice(0, first) + newText + current.slice(first + oldText.length);
|
|
481
|
+
const bytes = new TextEncoder().encode(next);
|
|
482
|
+
if (bytes.byteLength > WORKSPACE_LIMITS.maxWriteBytes)
|
|
483
|
+
throw new WorkspaceError(
|
|
484
|
+
`the edited file would exceed ${formatBytes(WORKSPACE_LIMITS.maxWriteBytes)}`,
|
|
485
|
+
);
|
|
486
|
+
await atomicWrite(target, bytes);
|
|
487
|
+
const oldLines = oldText.split("\n").length;
|
|
488
|
+
const newLines = newText.split("\n").length;
|
|
489
|
+
return {
|
|
490
|
+
rel: target.rel,
|
|
491
|
+
bytes: bytes.byteLength,
|
|
492
|
+
created: false,
|
|
493
|
+
text: `edited ${target.rel}: replaced 1 occurrence (${oldLines} → ${newLines} line${newLines === 1 ? "" : "s"})`,
|
|
494
|
+
summary: `edited ${target.rel}`,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function atomicWrite(target: ResolvedPath, bytes: Uint8Array) {
|
|
499
|
+
const nonce = Math.random().toString(36).slice(2, 8);
|
|
500
|
+
const temp = join(
|
|
501
|
+
dirname(target.abs),
|
|
502
|
+
`.${basename(target.abs)}.${process.pid}.${nonce}.tmp`,
|
|
503
|
+
);
|
|
504
|
+
try {
|
|
505
|
+
await fsWriteFile(temp, bytes, { flag: "wx" });
|
|
506
|
+
await rename(temp, target.abs);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
await unlink(temp).catch(() => undefined);
|
|
509
|
+
throw new WorkspaceError(`${target.rel}: ${fsMessage(error)}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export type ProjectOutline = Readonly<{
|
|
514
|
+
/** At most `maxOutlineLines` lines naming top-level entries and track directories. */
|
|
515
|
+
tree: readonly string[];
|
|
516
|
+
/** First `maxNotesBytes` of the focused track's `notes.md`, when present. */
|
|
517
|
+
notes?: string;
|
|
518
|
+
}>;
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* A bounded view of the project for the brief. Never throws: an unreadable
|
|
522
|
+
* project yields an empty tree.
|
|
523
|
+
*/
|
|
524
|
+
export async function projectOutline(
|
|
525
|
+
scope: WorkspaceScope,
|
|
526
|
+
): Promise<ProjectOutline> {
|
|
527
|
+
const lines: string[] = [];
|
|
528
|
+
const push = (line: string) =>
|
|
529
|
+
lines.push(
|
|
530
|
+
line.length > WORKSPACE_LIMITS.maxOutlineLineChars
|
|
531
|
+
? `${line.slice(0, WORKSPACE_LIMITS.maxOutlineLineChars - 1)}…`
|
|
532
|
+
: line,
|
|
533
|
+
);
|
|
534
|
+
const top = await safeReaddir(scope.root);
|
|
535
|
+
const topVisible = top
|
|
536
|
+
.filter((entry) => !OUTLINE_HIDDEN.has(entry.name))
|
|
537
|
+
.sort(
|
|
538
|
+
(left, right) =>
|
|
539
|
+
Number(right.isDirectory()) - Number(left.isDirectory()) ||
|
|
540
|
+
left.name.localeCompare(right.name),
|
|
541
|
+
);
|
|
542
|
+
for (const entry of topVisible.slice(0, WORKSPACE_LIMITS.maxOutlineLines)) {
|
|
543
|
+
if (entry.name === "tracks" && entry.isDirectory()) continue;
|
|
544
|
+
if (entry.isDirectory()) push(`${entry.name}/`);
|
|
545
|
+
else
|
|
546
|
+
push(
|
|
547
|
+
`${entry.name} ${formatBytes(await sizeOf(join(scope.root, entry.name)))}`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const tracksDir = join(scope.root, "tracks");
|
|
551
|
+
const trackDirs = (await safeReaddir(tracksDir))
|
|
552
|
+
.filter((entry) => entry.isDirectory())
|
|
553
|
+
.map((entry) => entry.name)
|
|
554
|
+
.sort((left, right) =>
|
|
555
|
+
left === scope.trackSlug
|
|
556
|
+
? -1
|
|
557
|
+
: right === scope.trackSlug
|
|
558
|
+
? 1
|
|
559
|
+
: left.localeCompare(right),
|
|
560
|
+
);
|
|
561
|
+
for (const slug of trackDirs.slice(0, WORKSPACE_LIMITS.maxOutlineLines)) {
|
|
562
|
+
const dir = join(tracksDir, slug);
|
|
563
|
+
const files = (await safeReaddir(dir)).filter((entry) => entry.isFile());
|
|
564
|
+
let bytes = 0;
|
|
565
|
+
for (const file of files) bytes += await sizeOf(join(dir, file.name));
|
|
566
|
+
const samples = (await safeReaddir(join(dir, "samples"))).filter((e) =>
|
|
567
|
+
e.isFile(),
|
|
568
|
+
);
|
|
569
|
+
const downloads = (await safeReaddir(join(dir, "downloads"))).filter((e) =>
|
|
570
|
+
e.isFile(),
|
|
571
|
+
);
|
|
572
|
+
const focus = slug === scope.trackSlug ? " (focused)" : "";
|
|
573
|
+
const parts = [
|
|
574
|
+
`${files.length} file${files.length === 1 ? "" : "s"} ${formatBytes(bytes)}`,
|
|
575
|
+
...(samples.length ? [`${samples.length} samples`] : []),
|
|
576
|
+
];
|
|
577
|
+
push(`tracks/${slug}/${focus} ${parts.join(", ")}`);
|
|
578
|
+
if (downloads.length > 0) {
|
|
579
|
+
const names = downloads
|
|
580
|
+
.map((e) => e.name)
|
|
581
|
+
.sort()
|
|
582
|
+
.slice(0, 8);
|
|
583
|
+
const more = downloads.length - names.length;
|
|
584
|
+
push(
|
|
585
|
+
`tracks/${slug}/downloads/: ${names.join(", ")}${more > 0 ? ` +${more}` : ""}`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const tree =
|
|
590
|
+
lines.length > WORKSPACE_LIMITS.maxOutlineLines
|
|
591
|
+
? [
|
|
592
|
+
...lines.slice(0, WORKSPACE_LIMITS.maxOutlineLines - 1),
|
|
593
|
+
`… ${lines.length - WORKSPACE_LIMITS.maxOutlineLines + 1} more`,
|
|
594
|
+
]
|
|
595
|
+
: lines;
|
|
596
|
+
const notes = await notesHead(join(tracksDir, scope.trackSlug, "notes.md"));
|
|
597
|
+
return notes === undefined ? { tree } : { tree, notes };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function notesHead(path: string): Promise<string | undefined> {
|
|
601
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
602
|
+
try {
|
|
603
|
+
const info = await lstat(path);
|
|
604
|
+
if (!info.isFile()) return undefined;
|
|
605
|
+
handle = await open(path, "r");
|
|
606
|
+
} catch {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
const buffer = new Uint8Array(WORKSPACE_LIMITS.maxNotesBytes);
|
|
611
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
612
|
+
const head = buffer.subarray(0, bytesRead);
|
|
613
|
+
if (detectBinary(head)) return undefined;
|
|
614
|
+
const text = new TextDecoder("utf-8").decode(head).replace(/�$/, "");
|
|
615
|
+
return bytesRead === buffer.length ? `${text}…` : text;
|
|
616
|
+
} catch {
|
|
617
|
+
return undefined;
|
|
618
|
+
} finally {
|
|
619
|
+
await handle.close();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function safeReaddir(path: string): Promise<import("node:fs").Dirent[]> {
|
|
624
|
+
try {
|
|
625
|
+
return await readdir(path, { withFileTypes: true });
|
|
626
|
+
} catch {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function sizeOf(path: string): Promise<number> {
|
|
632
|
+
try {
|
|
633
|
+
return (await stat(path)).size;
|
|
634
|
+
} catch {
|
|
635
|
+
return 0;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function exists(path: string): Promise<boolean> {
|
|
640
|
+
try {
|
|
641
|
+
await lstat(path);
|
|
642
|
+
return true;
|
|
643
|
+
} catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function formatBytes(bytes: number): string {
|
|
649
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
650
|
+
if (bytes < 1024 * 1024) return `${trim(bytes / 1024)} KiB`;
|
|
651
|
+
return `${trim(bytes / (1024 * 1024))} MiB`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function trim(value: number): string {
|
|
655
|
+
return (Math.round(value * 10) / 10).toString();
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function isNotFound(error: unknown): boolean {
|
|
659
|
+
return (error as { code?: string } | null)?.code === "ENOENT";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function isNotDirectory(error: unknown): boolean {
|
|
663
|
+
return (error as { code?: string } | null)?.code === "ENOTDIR";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function fsMessage(error: unknown): string {
|
|
667
|
+
const code = (error as { code?: string } | null)?.code;
|
|
668
|
+
switch (code) {
|
|
669
|
+
case "ENOENT":
|
|
670
|
+
return "no such file";
|
|
671
|
+
case "EACCES":
|
|
672
|
+
case "EPERM":
|
|
673
|
+
return "permission denied";
|
|
674
|
+
case "EISDIR":
|
|
675
|
+
return "is a directory";
|
|
676
|
+
case "ENOTDIR":
|
|
677
|
+
return "a path segment is not a directory";
|
|
678
|
+
case "ELOOP":
|
|
679
|
+
return "too many symlinks";
|
|
680
|
+
default:
|
|
681
|
+
return code ? `filesystem error ${code}` : "filesystem error";
|
|
682
|
+
}
|
|
683
|
+
}
|