topchester-ai 0.1.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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2986 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +51 -0
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,2986 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cwd, stderr, stdout } from "node:process";
|
|
3
|
+
import { basename, delimiter, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
6
|
+
import { generateText, streamText } from "ai";
|
|
7
|
+
import { constants, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { parse } from "yaml";
|
|
10
|
+
import { ZodError, z } from "zod";
|
|
11
|
+
import pino from "pino";
|
|
12
|
+
import { access, mkdir, open, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { Input, ProcessTerminal, TUI, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
//#region src/model/index.ts
|
|
17
|
+
var ModelGateway = class {
|
|
18
|
+
#config;
|
|
19
|
+
constructor(config) {
|
|
20
|
+
this.#config = config;
|
|
21
|
+
}
|
|
22
|
+
resolveModel(purpose = this.#config.defaultPurpose) {
|
|
23
|
+
const modelConfig = this.#config.models[purpose] ?? this.#config.models.fallback;
|
|
24
|
+
if (!modelConfig) throw new Error(`No model configured for purpose "${purpose}".`);
|
|
25
|
+
const providerId = modelConfig.provider ?? this.#config.defaultProvider;
|
|
26
|
+
if (!providerId) throw new Error(`No provider configured for model "${modelConfig.name}".`);
|
|
27
|
+
const modelId = modelConfig.name;
|
|
28
|
+
const providerConfig = this.#config.providers[providerId];
|
|
29
|
+
if (!providerConfig) throw new Error(`No provider configured for model provider "${providerId}".`);
|
|
30
|
+
return {
|
|
31
|
+
model: createOpenAICompatible({
|
|
32
|
+
name: providerId,
|
|
33
|
+
baseURL: providerConfig.baseURL,
|
|
34
|
+
apiKey: resolveApiKey(providerConfig),
|
|
35
|
+
headers: providerConfig.headers,
|
|
36
|
+
supportsStructuredOutputs: providerConfig.supportsStructuredOutputs
|
|
37
|
+
}).chatModel(modelId),
|
|
38
|
+
providerId,
|
|
39
|
+
modelId,
|
|
40
|
+
purpose
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async generateText(request) {
|
|
44
|
+
const resolved = this.resolveModel(request.purpose);
|
|
45
|
+
return {
|
|
46
|
+
text: (await generateText({
|
|
47
|
+
model: resolved.model,
|
|
48
|
+
system: request.system,
|
|
49
|
+
prompt: request.prompt,
|
|
50
|
+
abortSignal: request.abortSignal
|
|
51
|
+
})).text,
|
|
52
|
+
providerId: resolved.providerId,
|
|
53
|
+
modelId: resolved.modelId,
|
|
54
|
+
purpose: resolved.purpose
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async *streamText(request) {
|
|
58
|
+
yield* streamText({
|
|
59
|
+
model: this.resolveModel(request.purpose).model,
|
|
60
|
+
system: request.system,
|
|
61
|
+
prompt: request.prompt,
|
|
62
|
+
abortSignal: request.abortSignal
|
|
63
|
+
}).textStream;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
function resolveApiKey(config) {
|
|
67
|
+
if (config.apiKey !== void 0) return config.apiKey;
|
|
68
|
+
if (config.apiKeyEnv === void 0) return;
|
|
69
|
+
return process.env[config.apiKeyEnv];
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/config/index.ts
|
|
73
|
+
const modelPurposeSchema = z.enum([
|
|
74
|
+
"agent.primary",
|
|
75
|
+
"agent.fast",
|
|
76
|
+
"kb.scan",
|
|
77
|
+
"kb.summarize",
|
|
78
|
+
"kb.extract",
|
|
79
|
+
"kb.embed",
|
|
80
|
+
"fallback"
|
|
81
|
+
]);
|
|
82
|
+
const providerSchema = z.object({
|
|
83
|
+
type: z.literal("openai-compatible"),
|
|
84
|
+
baseURL: z.string().url(),
|
|
85
|
+
apiKeyEnv: z.string().optional(),
|
|
86
|
+
apiKey: z.string().optional(),
|
|
87
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
88
|
+
supportsStructuredOutputs: z.boolean().optional()
|
|
89
|
+
});
|
|
90
|
+
const modelAssignmentSchema = z.object({
|
|
91
|
+
name: z.string(),
|
|
92
|
+
provider: z.string().optional()
|
|
93
|
+
});
|
|
94
|
+
const providersSchema = z.object({ default: z.string().optional() }).catchall(providerSchema.or(z.string()));
|
|
95
|
+
const topchesterConfigSchema = z.object({ models: z.object({
|
|
96
|
+
defaultPurpose: modelPurposeSchema.optional(),
|
|
97
|
+
assignments: z.partialRecord(modelPurposeSchema, modelAssignmentSchema).optional(),
|
|
98
|
+
providers: providersSchema.optional()
|
|
99
|
+
}).optional() });
|
|
100
|
+
function loadTopchesterConfig(options) {
|
|
101
|
+
const paths = [
|
|
102
|
+
join(homedir(), ".config/topchester/config.yaml"),
|
|
103
|
+
join(options.workspaceRoot, "topchester.yaml"),
|
|
104
|
+
join(options.workspaceRoot, ".topchester/config.local.yaml"),
|
|
105
|
+
process.env.TOPCHESTER_CONFIG,
|
|
106
|
+
options.configPath
|
|
107
|
+
].filter((path) => Boolean(path));
|
|
108
|
+
let merged = {};
|
|
109
|
+
for (const path of paths) {
|
|
110
|
+
const resolvedPath = isAbsolute(path) ? path : resolve(options.workspaceRoot, path);
|
|
111
|
+
if (!existsSync(resolvedPath)) continue;
|
|
112
|
+
const parsed = parse(readFileSync(resolvedPath, "utf8"));
|
|
113
|
+
merged = deepMerge(merged, topchesterConfigSchema.parse(parsed));
|
|
114
|
+
}
|
|
115
|
+
return topchesterConfigSchema.parse(merged);
|
|
116
|
+
}
|
|
117
|
+
function deepMerge(base, override) {
|
|
118
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return override;
|
|
119
|
+
const result = { ...base };
|
|
120
|
+
for (const [key, value] of Object.entries(override)) result[key] = key in result ? deepMerge(result[key], value) : value;
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
function isPlainObject(value) {
|
|
124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/app/paths.ts
|
|
128
|
+
const TOPCHESTER_STATE_DIR = ".agents/topchester";
|
|
129
|
+
const TOPCHESTER_SESSIONS_DIR = `${TOPCHESTER_STATE_DIR}/sessions`;
|
|
130
|
+
const TOPCHESTER_LOGS_DIR = `${TOPCHESTER_STATE_DIR}/logs`;
|
|
131
|
+
`${TOPCHESTER_LOGS_DIR}`;
|
|
132
|
+
function resolveWorkspacePath(workspaceRoot, path) {
|
|
133
|
+
return isAbsolute(path) ? path : resolve(workspaceRoot, path);
|
|
134
|
+
}
|
|
135
|
+
function getTopchesterStatePath(workspaceRoot) {
|
|
136
|
+
return resolveWorkspacePath(workspaceRoot, TOPCHESTER_STATE_DIR);
|
|
137
|
+
}
|
|
138
|
+
function getTopchesterSessionsPath(workspaceRoot) {
|
|
139
|
+
return resolveWorkspacePath(workspaceRoot, TOPCHESTER_SESSIONS_DIR);
|
|
140
|
+
}
|
|
141
|
+
function getTopchesterLogsPath(workspaceRoot) {
|
|
142
|
+
return resolveWorkspacePath(workspaceRoot, TOPCHESTER_LOGS_DIR);
|
|
143
|
+
}
|
|
144
|
+
function getTopchesterLogFilePath(workspaceRoot, logFile = process.env.TOPCHESTER_LOG_FILE) {
|
|
145
|
+
return logFile ? resolveWorkspacePath(workspaceRoot, logFile) : join(getTopchesterLogsPath(workspaceRoot), "topchester.log");
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/logging/index.ts
|
|
149
|
+
const LOG_LEVELS = new Set([
|
|
150
|
+
"fatal",
|
|
151
|
+
"error",
|
|
152
|
+
"warn",
|
|
153
|
+
"info",
|
|
154
|
+
"debug",
|
|
155
|
+
"trace",
|
|
156
|
+
"silent"
|
|
157
|
+
]);
|
|
158
|
+
function createTopchesterLogger(workspaceRoot) {
|
|
159
|
+
const level = normalizeLogLevel(process.env.TOPCHESTER_LOG_LEVEL);
|
|
160
|
+
if (level === "silent") return {
|
|
161
|
+
logger: pino({ enabled: false }),
|
|
162
|
+
level
|
|
163
|
+
};
|
|
164
|
+
const logFilePath = getTopchesterLogFilePath(workspaceRoot);
|
|
165
|
+
mkdirSync(dirname(logFilePath), { recursive: true });
|
|
166
|
+
const logger = pino({
|
|
167
|
+
base: void 0,
|
|
168
|
+
level,
|
|
169
|
+
timestamp: pino.stdTimeFunctions.isoTime
|
|
170
|
+
}, pino.destination({
|
|
171
|
+
dest: logFilePath,
|
|
172
|
+
sync: true
|
|
173
|
+
}));
|
|
174
|
+
logger.debug({
|
|
175
|
+
event: "logger_ready",
|
|
176
|
+
logFilePath,
|
|
177
|
+
level
|
|
178
|
+
}, "logger ready");
|
|
179
|
+
return {
|
|
180
|
+
logger,
|
|
181
|
+
level,
|
|
182
|
+
logFilePath
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function normalizeLogLevel(level) {
|
|
186
|
+
const normalized = level?.trim().toLowerCase();
|
|
187
|
+
if (!normalized || normalized === "off") return "silent";
|
|
188
|
+
return LOG_LEVELS.has(normalized) ? normalized : "info";
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/app/context.ts
|
|
192
|
+
function createAppContext(options) {
|
|
193
|
+
const config = loadTopchesterConfig(options);
|
|
194
|
+
const modelGateway = new ModelGateway(normalizeModelGatewayConfig(config));
|
|
195
|
+
const loggerInfo = createTopchesterLogger(options.workspaceRoot);
|
|
196
|
+
return {
|
|
197
|
+
workspaceRoot: options.workspaceRoot,
|
|
198
|
+
config,
|
|
199
|
+
modelGateway,
|
|
200
|
+
devFlags: new Set(options.devFlags ?? []),
|
|
201
|
+
logger: loggerInfo.logger,
|
|
202
|
+
logFilePath: loggerInfo.logFilePath
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function normalizeModelGatewayConfig(config) {
|
|
206
|
+
const { default: defaultProvider, ...namedProviders } = config.models?.providers ?? {};
|
|
207
|
+
return {
|
|
208
|
+
defaultPurpose: config.models?.defaultPurpose ?? "agent.primary",
|
|
209
|
+
models: config.models?.assignments ?? {},
|
|
210
|
+
defaultProvider: typeof defaultProvider === "string" ? defaultProvider : void 0,
|
|
211
|
+
providers: Object.fromEntries(Object.entries(namedProviders).filter((entry) => {
|
|
212
|
+
return typeof entry[1] !== "string";
|
|
213
|
+
}))
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/cli/ui.ts
|
|
218
|
+
const colors = {
|
|
219
|
+
bgSoftGray: "\x1B[48;5;236m",
|
|
220
|
+
cyan: "\x1B[36m",
|
|
221
|
+
dim: "\x1B[2m",
|
|
222
|
+
green: "\x1B[32m",
|
|
223
|
+
red: "\x1B[31m",
|
|
224
|
+
reset: "\x1B[0m",
|
|
225
|
+
yellow: "\x1B[33m"
|
|
226
|
+
};
|
|
227
|
+
const ui = {
|
|
228
|
+
heading(text) {
|
|
229
|
+
return color(`Topchester ${text}`, "cyan");
|
|
230
|
+
},
|
|
231
|
+
label(text) {
|
|
232
|
+
return color(text, "dim");
|
|
233
|
+
},
|
|
234
|
+
ok(text) {
|
|
235
|
+
return color(text, "green");
|
|
236
|
+
},
|
|
237
|
+
warn(text) {
|
|
238
|
+
return color(text, "yellow");
|
|
239
|
+
},
|
|
240
|
+
error(text) {
|
|
241
|
+
return color(text, "red");
|
|
242
|
+
},
|
|
243
|
+
softBackground(text) {
|
|
244
|
+
return color(text, "bgSoftGray");
|
|
245
|
+
},
|
|
246
|
+
async spinner(text, action) {
|
|
247
|
+
return withStatusLine(text, action, void 0, 80, false);
|
|
248
|
+
},
|
|
249
|
+
async progress(text, action) {
|
|
250
|
+
let latest = text;
|
|
251
|
+
return withStatusLine(text, () => action((message) => {
|
|
252
|
+
latest = message;
|
|
253
|
+
}), () => latest, 80, true);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
async function withStatusLine(text, action, getText = () => text, progressEveryMs = 80, emitPlainProgress = false) {
|
|
257
|
+
if (!shouldUseColor()) {
|
|
258
|
+
if (!emitPlainProgress) return action();
|
|
259
|
+
const timer = setInterval(() => {
|
|
260
|
+
stderr.write(`${getText()}\n`);
|
|
261
|
+
}, Math.max(progressEveryMs, 5e3));
|
|
262
|
+
try {
|
|
263
|
+
return await action();
|
|
264
|
+
} finally {
|
|
265
|
+
clearInterval(timer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const frames = [
|
|
269
|
+
"⠋",
|
|
270
|
+
"⠙",
|
|
271
|
+
"⠹",
|
|
272
|
+
"⠸",
|
|
273
|
+
"⠼",
|
|
274
|
+
"⠴",
|
|
275
|
+
"⠦",
|
|
276
|
+
"⠧",
|
|
277
|
+
"⠇",
|
|
278
|
+
"⠏"
|
|
279
|
+
];
|
|
280
|
+
let index = 0;
|
|
281
|
+
stderr.write(`${color(frames[index], "cyan")} ${getText()}`);
|
|
282
|
+
const timer = setInterval(() => {
|
|
283
|
+
index = (index + 1) % frames.length;
|
|
284
|
+
stderr.write(`\r\u001b[2K${color(frames[index], "cyan")} ${getText()}`);
|
|
285
|
+
}, progressEveryMs);
|
|
286
|
+
try {
|
|
287
|
+
return await action();
|
|
288
|
+
} finally {
|
|
289
|
+
clearInterval(timer);
|
|
290
|
+
stderr.write(`\r\u001b[2K`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function color(text, colorName) {
|
|
294
|
+
if (!shouldUseColor()) return text;
|
|
295
|
+
return `${colors[colorName]}${text}${colors.reset}`;
|
|
296
|
+
}
|
|
297
|
+
function shouldUseColor() {
|
|
298
|
+
if (process.env.NO_COLOR) return false;
|
|
299
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== "0") return true;
|
|
300
|
+
return stdout.isTTY === true;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/knowledge/status.ts
|
|
304
|
+
function getKnowledgeStatus(workspaceRoot) {
|
|
305
|
+
const kbPathSource = process.env.TOPCHESTER_KB_DIR ? "env" : "default";
|
|
306
|
+
const cachePathSource = process.env.TOPCHESTER_KB_CACHE_DIR ? "env" : "default";
|
|
307
|
+
const kbPath = resolveWorkspacePath(workspaceRoot, process.env.TOPCHESTER_KB_DIR ?? "topchester-kb");
|
|
308
|
+
const cachePath = resolveWorkspacePath(workspaceRoot, process.env.TOPCHESTER_KB_CACHE_DIR ?? ".agents/topchester-kb-cache");
|
|
309
|
+
const kbStat = safeStat(kbPath);
|
|
310
|
+
const cacheStat = safeStat(cachePath);
|
|
311
|
+
return {
|
|
312
|
+
workspaceRoot,
|
|
313
|
+
kbPath,
|
|
314
|
+
cachePath,
|
|
315
|
+
kbExists: Boolean(kbStat),
|
|
316
|
+
kbIsDirectory: kbStat?.isDirectory() ?? false,
|
|
317
|
+
cacheExists: Boolean(cacheStat),
|
|
318
|
+
cacheIsDirectory: cacheStat?.isDirectory() ?? false,
|
|
319
|
+
kbPathSource,
|
|
320
|
+
cachePathSource
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function safeStat(path) {
|
|
324
|
+
if (!existsSync(path)) return;
|
|
325
|
+
return statSync(path);
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/knowledge/compiler/inventory.ts
|
|
329
|
+
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
330
|
+
".git",
|
|
331
|
+
"node_modules",
|
|
332
|
+
"dist",
|
|
333
|
+
"coverage",
|
|
334
|
+
".agents/topchester",
|
|
335
|
+
".agents/topchester-kb-cache",
|
|
336
|
+
"topchester-kb"
|
|
337
|
+
]);
|
|
338
|
+
const BINARY_FILE_EXTENSIONS = new Set([
|
|
339
|
+
".avif",
|
|
340
|
+
".bmp",
|
|
341
|
+
".class",
|
|
342
|
+
".db",
|
|
343
|
+
".dll",
|
|
344
|
+
".dmg",
|
|
345
|
+
".doc",
|
|
346
|
+
".docx",
|
|
347
|
+
".dylib",
|
|
348
|
+
".eot",
|
|
349
|
+
".exe",
|
|
350
|
+
".gif",
|
|
351
|
+
".gz",
|
|
352
|
+
".ico",
|
|
353
|
+
".jar",
|
|
354
|
+
".jpeg",
|
|
355
|
+
".jpg",
|
|
356
|
+
".mov",
|
|
357
|
+
".mp3",
|
|
358
|
+
".mp4",
|
|
359
|
+
".otf",
|
|
360
|
+
".pdf",
|
|
361
|
+
".png",
|
|
362
|
+
".sqlite",
|
|
363
|
+
".tar",
|
|
364
|
+
".tgz",
|
|
365
|
+
".ttf",
|
|
366
|
+
".wasm",
|
|
367
|
+
".webm",
|
|
368
|
+
".webp",
|
|
369
|
+
".woff",
|
|
370
|
+
".woff2",
|
|
371
|
+
".zip"
|
|
372
|
+
]);
|
|
373
|
+
const BINARY_FILE_NAMES = new Set([".ds_store"]);
|
|
374
|
+
const BINARY_SNIFF_BYTES = 4096;
|
|
375
|
+
const BINARY_CONTROL_BYTE_RATIO_THRESHOLD = .3;
|
|
376
|
+
async function listProjectFilesForL1(workspaceRoot, options = {}) {
|
|
377
|
+
const excludedDirs = buildExcludedDirs(workspaceRoot, options.excludedPaths ?? []);
|
|
378
|
+
const rules = await loadGitignoreRules(workspaceRoot, excludedDirs);
|
|
379
|
+
const files = [];
|
|
380
|
+
await walkDirectory(workspaceRoot, workspaceRoot, rules, files, excludedDirs);
|
|
381
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
382
|
+
return {
|
|
383
|
+
workspaceRoot,
|
|
384
|
+
gitignoreFiles: rules.map((rule) => join(rule.baseDir, ".gitignore")).filter(unique).sort(),
|
|
385
|
+
files
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function loadGitignoreRules(workspaceRoot, excludedDirs) {
|
|
389
|
+
const gitignorePaths = [];
|
|
390
|
+
await collectGitignorePaths(workspaceRoot, workspaceRoot, gitignorePaths, excludedDirs);
|
|
391
|
+
const rules = [];
|
|
392
|
+
for (const gitignorePath of gitignorePaths.sort()) {
|
|
393
|
+
const content = await readFile(gitignorePath, "utf8");
|
|
394
|
+
const baseDir = dirname(gitignorePath);
|
|
395
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
396
|
+
const rule = parseGitignoreLine(baseDir, rawLine);
|
|
397
|
+
if (rule) rules.push(rule);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return rules;
|
|
401
|
+
}
|
|
402
|
+
async function collectGitignorePaths(workspaceRoot, dir, gitignorePaths, excludedDirs) {
|
|
403
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
404
|
+
for (const entry of entries) {
|
|
405
|
+
const absolutePath = join(dir, entry.name);
|
|
406
|
+
const relativePath = toPosixPath(relative(workspaceRoot, absolutePath));
|
|
407
|
+
if (entry.name === ".gitignore") {
|
|
408
|
+
gitignorePaths.push(absolutePath);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (!entry.isDirectory() || shouldSkipDirectoryByDefault(relativePath, excludedDirs)) continue;
|
|
412
|
+
await collectGitignorePaths(workspaceRoot, absolutePath, gitignorePaths, excludedDirs);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
async function walkDirectory(workspaceRoot, dir, rules, files, excludedDirs) {
|
|
416
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
417
|
+
for (const entry of entries) {
|
|
418
|
+
const absolutePath = join(dir, entry.name);
|
|
419
|
+
const relativePath = toPosixPath(relative(workspaceRoot, absolutePath));
|
|
420
|
+
if (entry.isDirectory()) {
|
|
421
|
+
if (!shouldSkipDirectoryByDefault(relativePath, excludedDirs) && !isIgnored(workspaceRoot, absolutePath, true, rules, excludedDirs)) await walkDirectory(workspaceRoot, absolutePath, rules, files, excludedDirs);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (!entry.isFile() || isIgnored(workspaceRoot, absolutePath, false, rules, excludedDirs)) continue;
|
|
425
|
+
const fileStat = await stat(absolutePath);
|
|
426
|
+
if (await isBinaryFile(absolutePath, relativePath)) continue;
|
|
427
|
+
files.push({
|
|
428
|
+
path: relativePath,
|
|
429
|
+
sizeBytes: fileStat.size,
|
|
430
|
+
hash: await hashFile$1(absolutePath)
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
async function isBinaryFile(absolutePath, relativePath) {
|
|
435
|
+
const lowerRelativePath = relativePath.toLowerCase();
|
|
436
|
+
const fileName = lowerRelativePath.split("/").at(-1) ?? lowerRelativePath;
|
|
437
|
+
if (BINARY_FILE_NAMES.has(fileName) || BINARY_FILE_EXTENSIONS.has(extname(lowerRelativePath))) return true;
|
|
438
|
+
const fileHandle = await open(absolutePath, "r");
|
|
439
|
+
try {
|
|
440
|
+
const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
|
|
441
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, BINARY_SNIFF_BYTES, 0);
|
|
442
|
+
return looksBinary(buffer.subarray(0, bytesRead));
|
|
443
|
+
} finally {
|
|
444
|
+
await fileHandle.close();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function looksBinary(buffer) {
|
|
448
|
+
if (buffer.length === 0) return false;
|
|
449
|
+
let suspiciousControlBytes = 0;
|
|
450
|
+
for (const byte of buffer) {
|
|
451
|
+
if (byte === 0) return true;
|
|
452
|
+
if (byte < 32 && !(byte === 7 || byte === 8 || byte === 9 || byte === 10 || byte === 12 || byte === 13 || byte === 27)) suspiciousControlBytes += 1;
|
|
453
|
+
}
|
|
454
|
+
return suspiciousControlBytes / buffer.length > BINARY_CONTROL_BYTE_RATIO_THRESHOLD;
|
|
455
|
+
}
|
|
456
|
+
async function hashFile$1(absolutePath) {
|
|
457
|
+
const fileHandle = await open(absolutePath, "r");
|
|
458
|
+
try {
|
|
459
|
+
const hash = createHash("sha256");
|
|
460
|
+
const stream = fileHandle.createReadStream();
|
|
461
|
+
for await (const chunk of stream) hash.update(chunk);
|
|
462
|
+
return `sha256:${hash.digest("hex")}`;
|
|
463
|
+
} finally {
|
|
464
|
+
await fileHandle.close();
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function parseGitignoreLine(baseDir, rawLine) {
|
|
468
|
+
let line = rawLine.trim();
|
|
469
|
+
if (!line || line.startsWith("#")) return;
|
|
470
|
+
const negated = line.startsWith("!");
|
|
471
|
+
if (negated) line = line.slice(1);
|
|
472
|
+
if (!line) return;
|
|
473
|
+
const directoryOnly = line.endsWith("/");
|
|
474
|
+
line = line.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
475
|
+
if (!line) return;
|
|
476
|
+
return {
|
|
477
|
+
baseDir,
|
|
478
|
+
pattern: line,
|
|
479
|
+
negated,
|
|
480
|
+
directoryOnly
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function isIgnored(workspaceRoot, absolutePath, isDirectory, rules, excludedDirs) {
|
|
484
|
+
let ignored = false;
|
|
485
|
+
for (const rule of rules) {
|
|
486
|
+
const relativeToRule = toPosixPath(relative(rule.baseDir, absolutePath));
|
|
487
|
+
if (relativeToRule.startsWith("../") || relativeToRule === "..") continue;
|
|
488
|
+
if (rule.directoryOnly && !isDirectory) continue;
|
|
489
|
+
if (matchesRule(relativeToRule, rule.pattern)) ignored = !rule.negated;
|
|
490
|
+
}
|
|
491
|
+
if (ignored) return true;
|
|
492
|
+
return shouldSkipDirectoryByDefault(toPosixPath(relative(workspaceRoot, absolutePath)), excludedDirs) && isDirectory;
|
|
493
|
+
}
|
|
494
|
+
function matchesRule(relativePath, pattern) {
|
|
495
|
+
if (pattern.includes("/")) return matchGlob(relativePath, pattern) || relativePath.startsWith(`${pattern}/`);
|
|
496
|
+
return relativePath.split("/").some((part) => matchGlob(part, pattern));
|
|
497
|
+
}
|
|
498
|
+
function matchGlob(value, pattern) {
|
|
499
|
+
let regex = "^";
|
|
500
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
501
|
+
const char = pattern[index];
|
|
502
|
+
const nextChar = pattern[index + 1];
|
|
503
|
+
if (char === "*" && nextChar === "*") {
|
|
504
|
+
regex += ".*";
|
|
505
|
+
index += 1;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (char === "*") {
|
|
509
|
+
regex += "[^/]*";
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (char === "?") {
|
|
513
|
+
regex += "[^/]";
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
regex += escapeRegexChar(char);
|
|
517
|
+
}
|
|
518
|
+
regex += "$";
|
|
519
|
+
return new RegExp(regex).test(value);
|
|
520
|
+
}
|
|
521
|
+
function escapeRegexChar(value) {
|
|
522
|
+
return /[.+^${}()|[\]\\]/.test(value) ? `\\${value}` : value;
|
|
523
|
+
}
|
|
524
|
+
function shouldSkipDirectoryByDefault(relativePath, excludedDirs = DEFAULT_EXCLUDED_DIRS) {
|
|
525
|
+
return excludedDirs.has(relativePath) || [...excludedDirs].some((dir) => relativePath.startsWith(`${dir}/`));
|
|
526
|
+
}
|
|
527
|
+
function toPosixPath(path) {
|
|
528
|
+
return path.split(sep).join("/");
|
|
529
|
+
}
|
|
530
|
+
function unique(value, index, array) {
|
|
531
|
+
return array.indexOf(value) === index;
|
|
532
|
+
}
|
|
533
|
+
function buildExcludedDirs(workspaceRoot, excludedPaths) {
|
|
534
|
+
const dirs = new Set(DEFAULT_EXCLUDED_DIRS);
|
|
535
|
+
for (const excludedPath of excludedPaths) {
|
|
536
|
+
const workspaceRelativePath = toPosixPath(relative(workspaceRoot, excludedPath));
|
|
537
|
+
if (!workspaceRelativePath || workspaceRelativePath === "." || workspaceRelativePath.startsWith("../")) continue;
|
|
538
|
+
dirs.add(workspaceRelativePath);
|
|
539
|
+
}
|
|
540
|
+
return dirs;
|
|
541
|
+
}
|
|
542
|
+
//#endregion
|
|
543
|
+
//#region src/knowledge/compiler/l1-entry.ts
|
|
544
|
+
const l1FileEntrySchemaPath = "../schema/file-entry.v1.json";
|
|
545
|
+
const l1FileScanStatuses = [
|
|
546
|
+
"current",
|
|
547
|
+
"changed",
|
|
548
|
+
"missing_entry",
|
|
549
|
+
"missing_file",
|
|
550
|
+
"suspect",
|
|
551
|
+
"invalid"
|
|
552
|
+
];
|
|
553
|
+
const l1ConfidenceLevels = [
|
|
554
|
+
"low",
|
|
555
|
+
"medium",
|
|
556
|
+
"high"
|
|
557
|
+
];
|
|
558
|
+
const nonEmptyStringSchema = z.string().min(1);
|
|
559
|
+
const sha256HashSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/);
|
|
560
|
+
const isoUtcTimestampSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/).refine((value) => !Number.isNaN(Date.parse(value)), { message: "Expected a valid UTC ISO timestamp" });
|
|
561
|
+
const l1FileIdSchema = nonEmptyStringSchema.refine((value) => value.startsWith("file:"), { message: "Expected a file: id" });
|
|
562
|
+
const l1ModuleIdSchema = nonEmptyStringSchema.refine((value) => value.startsWith("module:"), { message: "Expected a module: id" });
|
|
563
|
+
const l1FeatureIdSchema = nonEmptyStringSchema.refine((value) => value.startsWith("feature:"), { message: "Expected a feature: id" });
|
|
564
|
+
const l1FileSymbolSchema = z.object({
|
|
565
|
+
id: nonEmptyStringSchema.refine((value) => value.startsWith("symbol:"), { message: "Expected a symbol: id" }),
|
|
566
|
+
kind: nonEmptyStringSchema,
|
|
567
|
+
name: nonEmptyStringSchema,
|
|
568
|
+
exported: z.boolean(),
|
|
569
|
+
summary: nonEmptyStringSchema
|
|
570
|
+
}).strict();
|
|
571
|
+
const l1FileEvidenceSchema = z.object({
|
|
572
|
+
kind: nonEmptyStringSchema,
|
|
573
|
+
value: nonEmptyStringSchema
|
|
574
|
+
}).strict();
|
|
575
|
+
const l1FileEntrySchema = z.object({
|
|
576
|
+
$schema: z.literal(l1FileEntrySchemaPath),
|
|
577
|
+
id: l1FileIdSchema,
|
|
578
|
+
layer: z.literal("L1"),
|
|
579
|
+
type: z.literal("file"),
|
|
580
|
+
path: nonEmptyStringSchema,
|
|
581
|
+
language: nonEmptyStringSchema,
|
|
582
|
+
content_hash: sha256HashSchema,
|
|
583
|
+
size_bytes: z.number().int().nonnegative(),
|
|
584
|
+
last_scanned_at: isoUtcTimestampSchema,
|
|
585
|
+
scan_status: z.enum(l1FileScanStatuses),
|
|
586
|
+
summary: nonEmptyStringSchema,
|
|
587
|
+
responsibilities: z.array(nonEmptyStringSchema),
|
|
588
|
+
symbols: z.array(l1FileSymbolSchema),
|
|
589
|
+
imports: z.array(l1FileIdSchema),
|
|
590
|
+
exports: z.array(nonEmptyStringSchema),
|
|
591
|
+
module_ids: z.array(l1ModuleIdSchema),
|
|
592
|
+
feature_ids: z.array(l1FeatureIdSchema),
|
|
593
|
+
test_ids: z.array(l1FileIdSchema),
|
|
594
|
+
evidence: z.array(l1FileEvidenceSchema),
|
|
595
|
+
confidence: z.enum(l1ConfidenceLevels)
|
|
596
|
+
}).strict().refine((entry) => entry.id === `file:${entry.path}`, {
|
|
597
|
+
message: "File entry id must match path",
|
|
598
|
+
path: ["id"]
|
|
599
|
+
});
|
|
600
|
+
function parseL1FileEntry(value) {
|
|
601
|
+
return l1FileEntrySchema.parse(value);
|
|
602
|
+
}
|
|
603
|
+
const l1QueueStatusSchema = z.enum([
|
|
604
|
+
"queued",
|
|
605
|
+
"in_progress",
|
|
606
|
+
"completed",
|
|
607
|
+
"failed",
|
|
608
|
+
"changed",
|
|
609
|
+
"missing_file"
|
|
610
|
+
]);
|
|
611
|
+
const l1QueueFailureSchema = z.object({
|
|
612
|
+
code: z.string().min(1),
|
|
613
|
+
message: z.string().min(1),
|
|
614
|
+
failedAt: isoUtcTimestampSchema
|
|
615
|
+
}).strict();
|
|
616
|
+
const l1QueueItemSchema = z.object({
|
|
617
|
+
id: l1FileIdSchema,
|
|
618
|
+
path: z.string().min(1),
|
|
619
|
+
sizeBytes: z.number().int().nonnegative(),
|
|
620
|
+
hash: sha256HashSchema,
|
|
621
|
+
status: l1QueueStatusSchema,
|
|
622
|
+
failure: l1QueueFailureSchema.optional()
|
|
623
|
+
}).strict().refine((item) => item.id === `file:${item.path}`, {
|
|
624
|
+
message: "L1 queue item id must match path",
|
|
625
|
+
path: ["id"]
|
|
626
|
+
});
|
|
627
|
+
const l1QueueFileSchema = z.object({
|
|
628
|
+
layer: z.literal("L1"),
|
|
629
|
+
generatedAt: isoUtcTimestampSchema,
|
|
630
|
+
queuedFiles: z.array(l1QueueItemSchema)
|
|
631
|
+
}).strict();
|
|
632
|
+
function createL1QueueItem(file) {
|
|
633
|
+
return l1QueueItemSchema.parse({
|
|
634
|
+
...file,
|
|
635
|
+
id: `file:${file.path}`,
|
|
636
|
+
status: "queued"
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
function createL1QueueFile(queuedFiles, generatedAt) {
|
|
640
|
+
return l1QueueFileSchema.parse({
|
|
641
|
+
layer: "L1",
|
|
642
|
+
generatedAt,
|
|
643
|
+
queuedFiles
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/knowledge/progress.ts
|
|
648
|
+
function formatProgressBar(completed, total, width = 20) {
|
|
649
|
+
const safeTotal = Math.max(total, 0);
|
|
650
|
+
const filled = safeTotal === 0 ? width : Math.floor((safeTotal === 0 ? 0 : Math.min(Math.max(completed, 0), safeTotal)) / safeTotal * width);
|
|
651
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
652
|
+
}
|
|
653
|
+
function formatCountProgress(label, completed, total, detail) {
|
|
654
|
+
const safeTotal = Math.max(total, 0);
|
|
655
|
+
const safeCompleted = safeTotal === 0 ? 0 : Math.min(Math.max(completed, 0), safeTotal);
|
|
656
|
+
const percent = safeTotal === 0 ? 100 : Math.floor(safeCompleted / safeTotal * 100);
|
|
657
|
+
const suffix = detail ? ` ${detail}` : "";
|
|
658
|
+
return `${label} [${formatProgressBar(safeCompleted, safeTotal)}] ${safeCompleted}/${safeTotal} (${percent}%)${suffix}`;
|
|
659
|
+
}
|
|
660
|
+
//#endregion
|
|
661
|
+
//#region src/knowledge/compiler/path-encoding.ts
|
|
662
|
+
function getL1FileEntryRelativePath(filePath) {
|
|
663
|
+
return `${normalizeL1FilePath(filePath)}.json`;
|
|
664
|
+
}
|
|
665
|
+
function getL1FileEntryPath(kbPath, filePath) {
|
|
666
|
+
return join(kbPath, "l1-files", getL1FileEntryRelativePath(filePath));
|
|
667
|
+
}
|
|
668
|
+
function normalizeL1FilePath(filePath) {
|
|
669
|
+
const normalizedPath = filePath.replace(/^\.\//, "");
|
|
670
|
+
if (!normalizedPath || /^[A-Za-z]:/.test(normalizedPath) || normalizedPath.startsWith("/") || normalizedPath.includes("\0") || normalizedPath.includes("\\") || normalizedPath.split("/").some((part) => !part || part === "." || part === "..")) throw new Error(`Invalid workspace-relative file path: ${filePath}`);
|
|
671
|
+
return normalizedPath;
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/knowledge/compiler/l1-processor.ts
|
|
675
|
+
const MAX_L1_PROMPT_FILE_BYTES = 256 * 1024;
|
|
676
|
+
async function processL1Queue(options) {
|
|
677
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
678
|
+
let queuedFiles = l1QueueFileSchema.parse(JSON.parse(await readFile(options.queuePath, "utf8"))).queuedFiles.map(validateQueueItemPath);
|
|
679
|
+
await removeOrphanedL1Entries(options.kbPath, new Set(queuedFiles.map((item) => item.path)));
|
|
680
|
+
for (const [index, item] of queuedFiles.entries()) {
|
|
681
|
+
options.onProgress?.({ message: formatL1ProgressMessage("Processing L1 files", index, queuedFiles.length, item.path) });
|
|
682
|
+
if (item.status === "completed" && await hasCurrentEntry(options.kbPath, item)) {
|
|
683
|
+
options.onProgress?.({ message: formatL1ProgressMessage("Processing L1 files", index + 1, queuedFiles.length, item.path) });
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
queuedFiles[index] = markInProgress(item);
|
|
687
|
+
await persistQueue(options.queuePath, queuedFiles, now().toISOString());
|
|
688
|
+
if (await hasCurrentEntry(options.kbPath, item)) queuedFiles[index] = markTerminal(item, "completed");
|
|
689
|
+
else queuedFiles[index] = (await processL1QueueItem({
|
|
690
|
+
...options,
|
|
691
|
+
item,
|
|
692
|
+
now
|
|
693
|
+
})).item;
|
|
694
|
+
await persistQueue(options.queuePath, queuedFiles, now().toISOString());
|
|
695
|
+
options.onProgress?.({ message: formatL1ProgressMessage("Processing L1 files", index + 1, queuedFiles.length, item.path) });
|
|
696
|
+
}
|
|
697
|
+
const summary = await summarizeL1Queue(options.kbPath, queuedFiles);
|
|
698
|
+
await writeManifest(options, summary, now().toISOString());
|
|
699
|
+
return {
|
|
700
|
+
queuedFiles,
|
|
701
|
+
summary
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function formatL1ProgressMessage(label, completed, total, path) {
|
|
705
|
+
return formatCountProgress(label, completed, total, path);
|
|
706
|
+
}
|
|
707
|
+
async function processL1QueueItem(options) {
|
|
708
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
709
|
+
const failedAt = () => now().toISOString();
|
|
710
|
+
try {
|
|
711
|
+
const normalizedPath = normalizeL1FilePath(options.item.path);
|
|
712
|
+
const realWorkspaceRoot = await realpath(options.workspaceRoot);
|
|
713
|
+
const absolutePath = await realpath(join(realWorkspaceRoot, normalizedPath)).catch((error) => {
|
|
714
|
+
if (isNodeErrorCode(error, "ENOENT")) return;
|
|
715
|
+
throw error;
|
|
716
|
+
});
|
|
717
|
+
if (!absolutePath) return { item: markTerminal(options.item, "missing_file") };
|
|
718
|
+
if (!isInsideDirectory(realWorkspaceRoot, absolutePath)) return { item: markTerminal(options.item, "changed") };
|
|
719
|
+
const fileStat = await stat(absolutePath).catch((error) => {
|
|
720
|
+
if (isNodeErrorCode(error, "ENOENT")) return;
|
|
721
|
+
throw error;
|
|
722
|
+
});
|
|
723
|
+
if (!fileStat) return { item: markTerminal(options.item, "missing_file") };
|
|
724
|
+
if (!fileStat.isFile()) return { item: failItem(options.item, "not_file", "Queued path is not a file.", failedAt()) };
|
|
725
|
+
const currentHash = await hashFile(absolutePath);
|
|
726
|
+
if (fileStat.size !== options.item.sizeBytes || currentHash !== options.item.hash) return { item: markTerminal(options.item, "changed") };
|
|
727
|
+
if (fileStat.size > MAX_L1_PROMPT_FILE_BYTES) return { item: failItem(options.item, "file_too_large", `File is too large for V0 L1 prompt processing (${fileStat.size} bytes).`, failedAt()) };
|
|
728
|
+
const content = await readFile(absolutePath, "utf8");
|
|
729
|
+
const entry = normalizeL1FileEntry(parseL1ModelJson((await options.model.generateText({
|
|
730
|
+
purpose: "kb.summarize",
|
|
731
|
+
system: buildL1FileEntrySystemPrompt(),
|
|
732
|
+
prompt: buildL1FileEntryPrompt({
|
|
733
|
+
path: normalizedPath,
|
|
734
|
+
content
|
|
735
|
+
})
|
|
736
|
+
})).text), {
|
|
737
|
+
path: normalizedPath,
|
|
738
|
+
hash: currentHash,
|
|
739
|
+
sizeBytes: fileStat.size,
|
|
740
|
+
scannedAt: now().toISOString()
|
|
741
|
+
});
|
|
742
|
+
const entryPath = getL1FileEntryPath(options.kbPath, normalizedPath);
|
|
743
|
+
await mkdir(dirname(entryPath), { recursive: true });
|
|
744
|
+
await writeFile(entryPath, `${JSON.stringify(entry, null, 2)}\n`);
|
|
745
|
+
return {
|
|
746
|
+
item: markTerminal(options.item, "completed"),
|
|
747
|
+
entry,
|
|
748
|
+
entryPath
|
|
749
|
+
};
|
|
750
|
+
} catch (error) {
|
|
751
|
+
return { item: failItem(options.item, classifyFailure(error), sanitizeErrorMessage(error), failedAt()) };
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function buildL1FileEntrySystemPrompt() {
|
|
755
|
+
return [
|
|
756
|
+
"You summarize one repository file for Topchester's L1 knowledge base.",
|
|
757
|
+
"Return exactly one JSON object and no markdown.",
|
|
758
|
+
"Do not include secrets, credentials, or raw provider payloads."
|
|
759
|
+
].join("\n");
|
|
760
|
+
}
|
|
761
|
+
function buildL1FileEntryPrompt(input) {
|
|
762
|
+
return [
|
|
763
|
+
"Create an L1 file entry for this workspace-relative path.",
|
|
764
|
+
"The compiler will overwrite id, path, content_hash, size_bytes, last_scanned_at, and scan_status.",
|
|
765
|
+
"Use this JSON shape:",
|
|
766
|
+
JSON.stringify({
|
|
767
|
+
$schema: l1FileEntrySchemaPath,
|
|
768
|
+
id: "file:<path>",
|
|
769
|
+
layer: "L1",
|
|
770
|
+
type: "file",
|
|
771
|
+
path: "<path>",
|
|
772
|
+
language: "typescript",
|
|
773
|
+
content_hash: "sha256:<hash>",
|
|
774
|
+
size_bytes: 0,
|
|
775
|
+
last_scanned_at: "2026-05-11T00:00:00Z",
|
|
776
|
+
scan_status: "current",
|
|
777
|
+
summary: "One clear sentence.",
|
|
778
|
+
responsibilities: ["What this file owns or does."],
|
|
779
|
+
symbols: [],
|
|
780
|
+
imports: [],
|
|
781
|
+
exports: [],
|
|
782
|
+
module_ids: [],
|
|
783
|
+
feature_ids: [],
|
|
784
|
+
test_ids: [],
|
|
785
|
+
evidence: [{
|
|
786
|
+
kind: "path",
|
|
787
|
+
value: "<path>"
|
|
788
|
+
}],
|
|
789
|
+
confidence: "medium"
|
|
790
|
+
}, null, 2),
|
|
791
|
+
`Path: ${input.path}`,
|
|
792
|
+
"File content:",
|
|
793
|
+
"```",
|
|
794
|
+
input.content,
|
|
795
|
+
"```"
|
|
796
|
+
].join("\n");
|
|
797
|
+
}
|
|
798
|
+
function parseL1ModelJson(text) {
|
|
799
|
+
const trimmed = text.trim();
|
|
800
|
+
if (!trimmed) throw new Error("Model returned empty output.");
|
|
801
|
+
const jsonObjects = extractTopLevelJsonObjects(trimmed);
|
|
802
|
+
if (jsonObjects.length !== 1) throw new Error(jsonObjects.length === 0 ? "Model output did not contain a JSON object." : "Model output was ambiguous.");
|
|
803
|
+
return JSON.parse(jsonObjects[0]);
|
|
804
|
+
}
|
|
805
|
+
function normalizeL1FileEntry(value, deterministic) {
|
|
806
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Model JSON was not an object.");
|
|
807
|
+
return parseL1FileEntry({
|
|
808
|
+
...normalizeModelOwnedL1Fields(value, deterministic.path),
|
|
809
|
+
$schema: l1FileEntrySchemaPath,
|
|
810
|
+
id: `file:${deterministic.path}`,
|
|
811
|
+
layer: "L1",
|
|
812
|
+
type: "file",
|
|
813
|
+
path: deterministic.path,
|
|
814
|
+
content_hash: deterministic.hash,
|
|
815
|
+
size_bytes: deterministic.sizeBytes,
|
|
816
|
+
last_scanned_at: deterministic.scannedAt,
|
|
817
|
+
scan_status: "current"
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
function normalizeModelOwnedL1Fields(value, path) {
|
|
821
|
+
const record = value;
|
|
822
|
+
return {
|
|
823
|
+
...record,
|
|
824
|
+
responsibilities: normalizeStringArray(record.responsibilities),
|
|
825
|
+
symbols: normalizeSymbols(record.symbols, path),
|
|
826
|
+
imports: normalizePrefixedIds(record.imports, "file:"),
|
|
827
|
+
exports: normalizeStringArray(record.exports),
|
|
828
|
+
module_ids: normalizePrefixedIds(record.module_ids, "module:"),
|
|
829
|
+
feature_ids: normalizePrefixedIds(record.feature_ids, "feature:"),
|
|
830
|
+
test_ids: normalizePrefixedIds(record.test_ids, "file:"),
|
|
831
|
+
evidence: normalizeEvidence(record.evidence)
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
function normalizeStringArray(value) {
|
|
835
|
+
if (!Array.isArray(value)) return [];
|
|
836
|
+
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
837
|
+
}
|
|
838
|
+
function normalizePrefixedIds(value, prefix) {
|
|
839
|
+
return normalizeStringArray(value).filter((item) => item.startsWith(prefix));
|
|
840
|
+
}
|
|
841
|
+
function normalizeEvidence(value) {
|
|
842
|
+
if (!Array.isArray(value)) return [];
|
|
843
|
+
return value.flatMap((item) => {
|
|
844
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
|
845
|
+
const record = item;
|
|
846
|
+
if (typeof record.kind !== "string" || record.kind.trim().length === 0) return [];
|
|
847
|
+
if (typeof record.value !== "string" || record.value.trim().length === 0) return [];
|
|
848
|
+
return [{
|
|
849
|
+
kind: record.kind,
|
|
850
|
+
value: record.value
|
|
851
|
+
}];
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
function normalizeSymbols(value, path) {
|
|
855
|
+
if (!Array.isArray(value)) return [];
|
|
856
|
+
return value.flatMap((item) => {
|
|
857
|
+
if (typeof item === "string") {
|
|
858
|
+
const name = item.trim();
|
|
859
|
+
if (!name || !path) return [];
|
|
860
|
+
return [{
|
|
861
|
+
id: `symbol:${path}#${name}`,
|
|
862
|
+
kind: "symbol",
|
|
863
|
+
name,
|
|
864
|
+
exported: false,
|
|
865
|
+
summary: `Symbol named ${name}.`
|
|
866
|
+
}];
|
|
867
|
+
}
|
|
868
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
|
869
|
+
const record = item;
|
|
870
|
+
const rawId = typeof record.id === "string" && record.id.startsWith("symbol:") ? record.id : void 0;
|
|
871
|
+
const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name : rawId?.slice(rawId.lastIndexOf("#") + 1);
|
|
872
|
+
if (!name || !path) return [];
|
|
873
|
+
return [{
|
|
874
|
+
id: rawId ?? `symbol:${path}#${name}`,
|
|
875
|
+
kind: typeof record.kind === "string" && record.kind.trim().length > 0 ? record.kind : "symbol",
|
|
876
|
+
name,
|
|
877
|
+
exported: typeof record.exported === "boolean" ? record.exported : false,
|
|
878
|
+
summary: typeof record.summary === "string" && record.summary.trim().length > 0 ? record.summary : `Symbol named ${name}.`
|
|
879
|
+
}];
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
function extractTopLevelJsonObjects(text) {
|
|
883
|
+
const objects = [];
|
|
884
|
+
let depth = 0;
|
|
885
|
+
let start = -1;
|
|
886
|
+
let inString = false;
|
|
887
|
+
let escaped = false;
|
|
888
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
889
|
+
const char = text[index];
|
|
890
|
+
if (inString) {
|
|
891
|
+
if (escaped) escaped = false;
|
|
892
|
+
else if (char === "\\") escaped = true;
|
|
893
|
+
else if (char === "\"") inString = false;
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
if (char === "\"") {
|
|
897
|
+
inString = true;
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (char === "{") {
|
|
901
|
+
if (depth === 0) start = index;
|
|
902
|
+
depth += 1;
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (char === "}" && depth > 0) {
|
|
906
|
+
depth -= 1;
|
|
907
|
+
if (depth === 0 && start >= 0) {
|
|
908
|
+
objects.push(text.slice(start, index + 1));
|
|
909
|
+
start = -1;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return objects.filter((objectText) => {
|
|
914
|
+
try {
|
|
915
|
+
JSON.parse(objectText);
|
|
916
|
+
return true;
|
|
917
|
+
} catch {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
async function hashFile(absolutePath) {
|
|
923
|
+
const fileHandle = await open(absolutePath, "r");
|
|
924
|
+
try {
|
|
925
|
+
const hash = createHash("sha256");
|
|
926
|
+
const stream = fileHandle.createReadStream();
|
|
927
|
+
for await (const chunk of stream) hash.update(chunk);
|
|
928
|
+
return `sha256:${hash.digest("hex")}`;
|
|
929
|
+
} finally {
|
|
930
|
+
await fileHandle.close();
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function markTerminal(item, status) {
|
|
934
|
+
const { failure: _failure, ...rest } = item;
|
|
935
|
+
return {
|
|
936
|
+
...rest,
|
|
937
|
+
status
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
function markInProgress(item) {
|
|
941
|
+
const { failure: _failure, ...rest } = item;
|
|
942
|
+
return {
|
|
943
|
+
...rest,
|
|
944
|
+
status: "in_progress"
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
function failItem(item, code, message, failedAt) {
|
|
948
|
+
return {
|
|
949
|
+
...item,
|
|
950
|
+
status: "failed",
|
|
951
|
+
failure: sanitizeFailure({
|
|
952
|
+
code,
|
|
953
|
+
message,
|
|
954
|
+
failedAt
|
|
955
|
+
})
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function sanitizeFailure(failure) {
|
|
959
|
+
return {
|
|
960
|
+
code: failure.code.replace(/[^a-z0-9_]/gi, "_").slice(0, 64) || "failed",
|
|
961
|
+
message: sanitizeErrorText(failure.message),
|
|
962
|
+
failedAt: failure.failedAt
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function sanitizeErrorMessage(error) {
|
|
966
|
+
if (error instanceof ZodError) return `L1 entry validation failed: ${error.issues.map((issue) => issue.path.join(".") || issue.message).join(", ")}`;
|
|
967
|
+
if (error instanceof SyntaxError) return "Model output was not valid JSON.";
|
|
968
|
+
if (error instanceof Error) return error.message;
|
|
969
|
+
return "L1 processing failed.";
|
|
970
|
+
}
|
|
971
|
+
function sanitizeErrorText(text) {
|
|
972
|
+
return text.replace(/sk-[A-Za-z0-9_-]+/g, "[redacted]").replace(/SECRET_SENTINEL_[A-Za-z0-9_-]+/g, "[redacted]").slice(0, 500);
|
|
973
|
+
}
|
|
974
|
+
function classifyFailure(error) {
|
|
975
|
+
if (error instanceof ZodError) return "validation_error";
|
|
976
|
+
if (error instanceof SyntaxError) return "json_parse_error";
|
|
977
|
+
return "processing_error";
|
|
978
|
+
}
|
|
979
|
+
function isNodeErrorCode(error, code) {
|
|
980
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
981
|
+
}
|
|
982
|
+
function isInsideDirectory(directory, target) {
|
|
983
|
+
const relativePath = relative(directory, target);
|
|
984
|
+
return relativePath === "" || !relativePath.startsWith("..") && !relativePath.startsWith("/") && relativePath !== "..";
|
|
985
|
+
}
|
|
986
|
+
function validateQueueItemPath(item) {
|
|
987
|
+
const normalizedPath = normalizeL1FilePath(item.path);
|
|
988
|
+
if (item.path !== normalizedPath || item.id !== `file:${normalizedPath}`) throw new Error(`Invalid persisted L1 queue item path: ${item.path}`);
|
|
989
|
+
return item;
|
|
990
|
+
}
|
|
991
|
+
async function persistQueue(queuePath, queuedFiles, generatedAt) {
|
|
992
|
+
const queue = createL1QueueFile(queuedFiles, generatedAt);
|
|
993
|
+
await writeFile(queuePath, `${JSON.stringify(queue, null, 2)}\n`);
|
|
994
|
+
}
|
|
995
|
+
async function hasCurrentEntry(kbPath, item) {
|
|
996
|
+
try {
|
|
997
|
+
const entryPath = getL1FileEntryPath(kbPath, item.path);
|
|
998
|
+
const entry = parseL1FileEntry(JSON.parse(await readFile(entryPath, "utf8")));
|
|
999
|
+
return entry.scan_status === "current" && entry.path === item.path && entry.content_hash === item.hash;
|
|
1000
|
+
} catch {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async function removeOrphanedL1Entries(kbPath, currentPaths) {
|
|
1005
|
+
const entryPaths = await listL1EntryJsonFiles(join(kbPath, "l1-files")).catch((error) => {
|
|
1006
|
+
if (isNodeErrorCode(error, "ENOENT")) return [];
|
|
1007
|
+
throw error;
|
|
1008
|
+
});
|
|
1009
|
+
for (const entryPath of entryPaths) try {
|
|
1010
|
+
const entry = parseL1FileEntry(JSON.parse(await readFile(entryPath, "utf8")));
|
|
1011
|
+
if (!currentPaths.has(entry.path)) await rm(entryPath, { force: true });
|
|
1012
|
+
} catch {
|
|
1013
|
+
await rm(entryPath, { force: true });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async function listL1EntryJsonFiles(directory) {
|
|
1017
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
1018
|
+
const filePaths = [];
|
|
1019
|
+
for (const entry of entries) {
|
|
1020
|
+
const entryPath = join(directory, entry.name);
|
|
1021
|
+
if (entry.isDirectory()) filePaths.push(...await listL1EntryJsonFiles(entryPath));
|
|
1022
|
+
else if (entry.isFile() && entry.name.endsWith(".json")) filePaths.push(entryPath);
|
|
1023
|
+
}
|
|
1024
|
+
return filePaths;
|
|
1025
|
+
}
|
|
1026
|
+
async function summarizeL1Queue(kbPath, queuedFiles) {
|
|
1027
|
+
let currentEntries = 0;
|
|
1028
|
+
for (const item of queuedFiles) if (await hasCurrentEntry(kbPath, item)) currentEntries += 1;
|
|
1029
|
+
return {
|
|
1030
|
+
queued: queuedFiles.filter((item) => item.status === "queued" || item.status === "in_progress").length,
|
|
1031
|
+
completed: queuedFiles.filter((item) => item.status === "completed").length,
|
|
1032
|
+
failed: queuedFiles.filter((item) => item.status === "failed").length,
|
|
1033
|
+
changed: queuedFiles.filter((item) => item.status === "changed").length,
|
|
1034
|
+
missing: queuedFiles.filter((item) => item.status === "missing_file").length,
|
|
1035
|
+
currentEntries
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
async function writeManifest(options, summary, generatedAt) {
|
|
1039
|
+
await writeFile(options.manifestPath, `${JSON.stringify({
|
|
1040
|
+
name: "topchester-kb",
|
|
1041
|
+
version: 1,
|
|
1042
|
+
generatedAt,
|
|
1043
|
+
workspaceRoot: options.workspaceRoot,
|
|
1044
|
+
l1QueuePath: options.queuePath,
|
|
1045
|
+
queuedFileCount: summary.queued + summary.completed + summary.failed + summary.changed + summary.missing,
|
|
1046
|
+
l1: summary,
|
|
1047
|
+
gitignoreFiles: options.gitignoreFiles
|
|
1048
|
+
}, null, 2)}\n`);
|
|
1049
|
+
}
|
|
1050
|
+
//#endregion
|
|
1051
|
+
//#region src/knowledge/compiler/index.ts
|
|
1052
|
+
async function compileKnowledgeBase(workspaceRoot, options = {}) {
|
|
1053
|
+
options.onProgress?.({ message: "Checking project knowledge folders..." });
|
|
1054
|
+
const status = getKnowledgeStatus(workspaceRoot);
|
|
1055
|
+
if (!status.kbExists || !status.kbIsDirectory) throw new Error("Run `topchester kb init` before compiling the project knowledge base.");
|
|
1056
|
+
if (options.requireModel) assertKbSummarizeModelConfigured(options.model);
|
|
1057
|
+
await mkdir(status.cachePath, { recursive: true });
|
|
1058
|
+
options.onProgress?.({ message: "Reading .gitignore files and listing project files..." });
|
|
1059
|
+
const inventory = await listProjectFilesForL1(workspaceRoot, { excludedPaths: [status.kbPath, status.cachePath] });
|
|
1060
|
+
options.onProgress?.({ message: `Queued ${inventory.files.length} project files for L1...` });
|
|
1061
|
+
const queuedFiles = inventory.files.map(createL1QueueItem);
|
|
1062
|
+
const queuePath = join(status.cachePath, "l1-queue.json");
|
|
1063
|
+
const manifestPath = join(status.kbPath, "manifest.json");
|
|
1064
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1065
|
+
const queue = createL1QueueFile(queuedFiles, generatedAt);
|
|
1066
|
+
options.onProgress?.({ message: "Writing L1 queue and manifest..." });
|
|
1067
|
+
await writeFile(queuePath, `${JSON.stringify(queue, null, 2)}\n`);
|
|
1068
|
+
const l1 = {
|
|
1069
|
+
queued: queuedFiles.length,
|
|
1070
|
+
completed: 0,
|
|
1071
|
+
failed: 0,
|
|
1072
|
+
changed: 0,
|
|
1073
|
+
missing: 0,
|
|
1074
|
+
currentEntries: 0
|
|
1075
|
+
};
|
|
1076
|
+
await writeFile(manifestPath, `${JSON.stringify({
|
|
1077
|
+
name: "topchester-kb",
|
|
1078
|
+
version: 1,
|
|
1079
|
+
generatedAt,
|
|
1080
|
+
workspaceRoot,
|
|
1081
|
+
l1QueuePath: queuePath,
|
|
1082
|
+
queuedFileCount: queuedFiles.length,
|
|
1083
|
+
l1,
|
|
1084
|
+
gitignoreFiles: inventory.gitignoreFiles
|
|
1085
|
+
}, null, 2)}\n`);
|
|
1086
|
+
options.onProgress?.({ message: "Processing L1 file entries with the configured model..." });
|
|
1087
|
+
const processed = options.model ? await processL1Queue({
|
|
1088
|
+
workspaceRoot,
|
|
1089
|
+
kbPath: status.kbPath,
|
|
1090
|
+
queuePath,
|
|
1091
|
+
manifestPath,
|
|
1092
|
+
gitignoreFiles: inventory.gitignoreFiles,
|
|
1093
|
+
model: options.model,
|
|
1094
|
+
onProgress: options.onProgress
|
|
1095
|
+
}) : void 0;
|
|
1096
|
+
return {
|
|
1097
|
+
workspaceRoot,
|
|
1098
|
+
kbPath: status.kbPath,
|
|
1099
|
+
cachePath: status.cachePath,
|
|
1100
|
+
gitignoreFiles: inventory.gitignoreFiles,
|
|
1101
|
+
queuedFiles: processed?.queuedFiles ?? queuedFiles,
|
|
1102
|
+
queuePath,
|
|
1103
|
+
manifestPath,
|
|
1104
|
+
l1: processed?.summary ?? l1
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
function formatKnowledgeCompileResult(result) {
|
|
1108
|
+
const l1 = result.l1 ?? {
|
|
1109
|
+
queued: result.queuedFiles.length,
|
|
1110
|
+
completed: 0,
|
|
1111
|
+
failed: 0,
|
|
1112
|
+
changed: 0,
|
|
1113
|
+
missing: 0,
|
|
1114
|
+
currentEntries: 0
|
|
1115
|
+
};
|
|
1116
|
+
const totalQueued = result.queuedFiles.length;
|
|
1117
|
+
const hasPartialOutcomes = l1.failed > 0 || l1.changed > 0 || l1.missing > 0;
|
|
1118
|
+
const state = l1.completed === totalQueued && !hasPartialOutcomes ? "L1 entries are ready and current" : hasPartialOutcomes ? "partial L1 compile; some files need attention" : "L1 file queue is ready";
|
|
1119
|
+
return [
|
|
1120
|
+
"KB compile",
|
|
1121
|
+
`workspace: ${result.workspaceRoot}`,
|
|
1122
|
+
`gitignore files read: ${result.gitignoreFiles.length}`,
|
|
1123
|
+
`queue: ${result.queuePath}`,
|
|
1124
|
+
`manifest: ${result.manifestPath}`,
|
|
1125
|
+
`queued: ${totalQueued}`,
|
|
1126
|
+
`completed: ${l1.completed}`,
|
|
1127
|
+
`failed: ${l1.failed}`,
|
|
1128
|
+
`changed: ${l1.changed}`,
|
|
1129
|
+
`missing: ${l1.missing}`,
|
|
1130
|
+
`current L1 entries: ${l1.currentEntries}`,
|
|
1131
|
+
`state: ${state}`
|
|
1132
|
+
];
|
|
1133
|
+
}
|
|
1134
|
+
function isPartialKnowledgeCompileResult(result) {
|
|
1135
|
+
const l1 = result.l1;
|
|
1136
|
+
return Boolean(l1 && (l1.failed > 0 || l1.changed > 0 || l1.missing > 0 || l1.completed !== result.queuedFiles.length));
|
|
1137
|
+
}
|
|
1138
|
+
function assertKbSummarizeModelConfigured(model) {
|
|
1139
|
+
if (!model) throw new Error("No model configured for purpose \"kb.summarize\"; L1 entries were not processed.");
|
|
1140
|
+
const maybeResolvable = model;
|
|
1141
|
+
if (maybeResolvable.resolveModel) maybeResolvable.resolveModel("kb.summarize");
|
|
1142
|
+
}
|
|
1143
|
+
//#endregion
|
|
1144
|
+
//#region src/knowledge/init.ts
|
|
1145
|
+
async function initializeKnowledgeBase(workspaceRoot, options = {}) {
|
|
1146
|
+
options.onProgress?.({ message: "Checking project knowledge folders..." });
|
|
1147
|
+
const status = getKnowledgeStatus(workspaceRoot);
|
|
1148
|
+
const paths = [
|
|
1149
|
+
getTopchesterStatePath(workspaceRoot),
|
|
1150
|
+
getTopchesterSessionsPath(workspaceRoot),
|
|
1151
|
+
getTopchesterLogsPath(workspaceRoot),
|
|
1152
|
+
status.kbPath,
|
|
1153
|
+
`${status.kbPath}/l1-files`,
|
|
1154
|
+
`${status.kbPath}/l2-modules`,
|
|
1155
|
+
`${status.kbPath}/l3-features`,
|
|
1156
|
+
`${status.kbPath}/graph`,
|
|
1157
|
+
`${status.kbPath}/reviews`,
|
|
1158
|
+
status.cachePath
|
|
1159
|
+
];
|
|
1160
|
+
const createdPaths = [];
|
|
1161
|
+
const existingPaths = [];
|
|
1162
|
+
for (const path of paths) {
|
|
1163
|
+
options.onProgress?.({ message: `Preparing ${path}...` });
|
|
1164
|
+
if (await directoryExists(path)) {
|
|
1165
|
+
existingPaths.push(path);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
await mkdir(path, { recursive: true });
|
|
1169
|
+
createdPaths.push(path);
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
workspaceRoot,
|
|
1173
|
+
createdPaths,
|
|
1174
|
+
existingPaths
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function formatKnowledgeInitResult(result) {
|
|
1178
|
+
const lines = ["KB init", `workspace: ${result.workspaceRoot}`];
|
|
1179
|
+
for (const path of result.createdPaths) lines.push(`created: ${path}`);
|
|
1180
|
+
for (const path of result.existingPaths) lines.push(`already exists: ${path}`);
|
|
1181
|
+
lines.push("state: project knowledge folders are ready");
|
|
1182
|
+
return lines;
|
|
1183
|
+
}
|
|
1184
|
+
async function directoryExists(path) {
|
|
1185
|
+
try {
|
|
1186
|
+
if (!(await stat(path)).isDirectory()) throw new Error(`${path} exists but is not a folder`);
|
|
1187
|
+
return true;
|
|
1188
|
+
} catch (error) {
|
|
1189
|
+
if (isNodeError$1(error) && error.code === "ENOENT") return false;
|
|
1190
|
+
throw error;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
function isNodeError$1(error) {
|
|
1194
|
+
return error instanceof Error && "code" in error;
|
|
1195
|
+
}
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/knowledge/reset.ts
|
|
1198
|
+
async function resetKnowledgeBase(workspaceRoot, options = {}) {
|
|
1199
|
+
options.onProgress?.({ message: "Checking project knowledge paths..." });
|
|
1200
|
+
const status = getKnowledgeStatus(workspaceRoot);
|
|
1201
|
+
const paths = dedupePaths([status.kbPath, status.cachePath]);
|
|
1202
|
+
const removedPaths = [];
|
|
1203
|
+
const missingPaths = [];
|
|
1204
|
+
for (const path of paths) {
|
|
1205
|
+
assertSafeResetPath(workspaceRoot, path);
|
|
1206
|
+
options.onProgress?.({ message: `Removing ${path}...` });
|
|
1207
|
+
if (await removeIfPresent(path)) removedPaths.push(path);
|
|
1208
|
+
else missingPaths.push(path);
|
|
1209
|
+
}
|
|
1210
|
+
return {
|
|
1211
|
+
workspaceRoot,
|
|
1212
|
+
removedPaths,
|
|
1213
|
+
missingPaths
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
function formatKnowledgeResetResult(result) {
|
|
1217
|
+
const lines = ["KB reset", `workspace: ${result.workspaceRoot}`];
|
|
1218
|
+
for (const path of result.removedPaths) lines.push(`removed: ${path}`);
|
|
1219
|
+
for (const path of result.missingPaths) lines.push(`already missing: ${path}`);
|
|
1220
|
+
lines.push("state: project knowledge base was reset");
|
|
1221
|
+
lines.push("next: run `topchester kb init` to start clean");
|
|
1222
|
+
return lines;
|
|
1223
|
+
}
|
|
1224
|
+
async function removeIfPresent(path) {
|
|
1225
|
+
try {
|
|
1226
|
+
await rm(path, {
|
|
1227
|
+
recursive: true,
|
|
1228
|
+
force: false
|
|
1229
|
+
});
|
|
1230
|
+
return true;
|
|
1231
|
+
} catch (error) {
|
|
1232
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function dedupePaths(paths) {
|
|
1237
|
+
return [...new Set(paths.map((path) => resolve(path)))];
|
|
1238
|
+
}
|
|
1239
|
+
function assertSafeResetPath(workspaceRoot, path) {
|
|
1240
|
+
const workspace = resolve(workspaceRoot);
|
|
1241
|
+
const target = resolve(path);
|
|
1242
|
+
if (target === workspace) throw new Error(`Refusing to reset KB because the configured KB path is the workspace root: ${target}`);
|
|
1243
|
+
if (dirname(target) === target) throw new Error(`Refusing to reset KB because the configured KB path is a filesystem root: ${target}`);
|
|
1244
|
+
}
|
|
1245
|
+
function isNodeError(error) {
|
|
1246
|
+
return error instanceof Error && "code" in error;
|
|
1247
|
+
}
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/tui/busy.ts
|
|
1250
|
+
var BusyIndicator = class {
|
|
1251
|
+
app;
|
|
1252
|
+
tui;
|
|
1253
|
+
options;
|
|
1254
|
+
frames = [
|
|
1255
|
+
"⠋",
|
|
1256
|
+
"⠙",
|
|
1257
|
+
"⠹",
|
|
1258
|
+
"⠸",
|
|
1259
|
+
"⠼",
|
|
1260
|
+
"⠴",
|
|
1261
|
+
"⠦",
|
|
1262
|
+
"⠧",
|
|
1263
|
+
"⠇",
|
|
1264
|
+
"⠏"
|
|
1265
|
+
];
|
|
1266
|
+
timer;
|
|
1267
|
+
index = 0;
|
|
1268
|
+
ticks = 0;
|
|
1269
|
+
activityOverride;
|
|
1270
|
+
constructor(app, tui, options) {
|
|
1271
|
+
this.app = app;
|
|
1272
|
+
this.tui = tui;
|
|
1273
|
+
this.options = options;
|
|
1274
|
+
}
|
|
1275
|
+
start() {
|
|
1276
|
+
this.app.setStatus(this.options.status);
|
|
1277
|
+
this.app.setPromptHint(this.options.promptHint);
|
|
1278
|
+
this.render();
|
|
1279
|
+
this.timer = setInterval(() => {
|
|
1280
|
+
this.index = (this.index + 1) % this.frames.length;
|
|
1281
|
+
this.ticks += 1;
|
|
1282
|
+
this.render();
|
|
1283
|
+
this.tui.requestRender();
|
|
1284
|
+
}, 80);
|
|
1285
|
+
}
|
|
1286
|
+
stop() {
|
|
1287
|
+
if (this.timer) {
|
|
1288
|
+
clearInterval(this.timer);
|
|
1289
|
+
this.timer = void 0;
|
|
1290
|
+
}
|
|
1291
|
+
this.app.setPromptHint(void 0);
|
|
1292
|
+
this.app.setEphemeralLine(void 0);
|
|
1293
|
+
}
|
|
1294
|
+
setActivity(activity) {
|
|
1295
|
+
this.activityOverride = activity;
|
|
1296
|
+
this.render();
|
|
1297
|
+
this.tui.requestRender();
|
|
1298
|
+
}
|
|
1299
|
+
render() {
|
|
1300
|
+
if (this.activityOverride) {
|
|
1301
|
+
this.app.setEphemeralLine(`${this.frames[this.index]} ${this.activityOverride}`);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const activityEveryMs = this.options.activityEveryMs ?? 1200;
|
|
1305
|
+
const activityIndex = Math.floor(this.ticks * 80 / activityEveryMs) % this.options.activities.length;
|
|
1306
|
+
this.app.setEphemeralLine(`${this.frames[this.index]} ${this.options.activities[activityIndex]}`);
|
|
1307
|
+
}
|
|
1308
|
+
};
|
|
1309
|
+
//#endregion
|
|
1310
|
+
//#region src/agent/commands.ts
|
|
1311
|
+
const slashCommandSuggestions = [
|
|
1312
|
+
{
|
|
1313
|
+
value: "/kb status",
|
|
1314
|
+
description: "show project knowledge base status"
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
value: "/kb compile",
|
|
1318
|
+
description: "process project files into L1 entries"
|
|
1319
|
+
},
|
|
1320
|
+
{
|
|
1321
|
+
value: "/kb init",
|
|
1322
|
+
description: "start project knowledge base setup"
|
|
1323
|
+
},
|
|
1324
|
+
{
|
|
1325
|
+
value: "/kb reset",
|
|
1326
|
+
description: "delete the local knowledge base and cache"
|
|
1327
|
+
}
|
|
1328
|
+
];
|
|
1329
|
+
const slashCommands = [{
|
|
1330
|
+
name: "kb",
|
|
1331
|
+
description: "knowledge base commands",
|
|
1332
|
+
execute: executeKbCommand
|
|
1333
|
+
}];
|
|
1334
|
+
function parseSlashCommand(input) {
|
|
1335
|
+
const trimmed = input.trim();
|
|
1336
|
+
if (!trimmed.startsWith("/")) return;
|
|
1337
|
+
const parts = trimmed.slice(1).split(/\s+/).filter(Boolean);
|
|
1338
|
+
const name = parts[0];
|
|
1339
|
+
if (!name) return;
|
|
1340
|
+
return {
|
|
1341
|
+
name,
|
|
1342
|
+
args: parts.slice(1)
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
async function executeSlashCommand(input, context) {
|
|
1346
|
+
const parsed = parseSlashCommand(input);
|
|
1347
|
+
if (!parsed) return { messages: ["That is not a slash command."] };
|
|
1348
|
+
const command = slashCommands.find((candidate) => candidate.name === parsed.name);
|
|
1349
|
+
if (!command) return { messages: [`Unknown command: /${parsed.name}`, "Try /kb status."] };
|
|
1350
|
+
return command.execute(parsed.args, context);
|
|
1351
|
+
}
|
|
1352
|
+
function getSlashCommandSuggestions(input) {
|
|
1353
|
+
const trimmed = input.trimStart();
|
|
1354
|
+
if (!trimmed.startsWith("/")) return [];
|
|
1355
|
+
const query = trimmed.toLowerCase();
|
|
1356
|
+
return slashCommandSuggestions.filter((suggestion) => suggestion.value.toLowerCase().startsWith(query));
|
|
1357
|
+
}
|
|
1358
|
+
async function executeKbCommand(args, context) {
|
|
1359
|
+
const subcommand = args[0];
|
|
1360
|
+
if (subcommand === "status") return { messages: formatKnowledgeStatus(getKnowledgeStatus(context.workspaceRoot)) };
|
|
1361
|
+
if (subcommand === "init") return { messages: formatKnowledgeInitResult(await initializeKnowledgeBase(context.workspaceRoot)) };
|
|
1362
|
+
if (subcommand === "compile") {
|
|
1363
|
+
if (!context.modelGateway) return { messages: ["No model configured for purpose \"kb.summarize\"; L1 entries were not processed."] };
|
|
1364
|
+
try {
|
|
1365
|
+
return { messages: formatKnowledgeCompileResult(await compileKnowledgeBase(context.workspaceRoot, {
|
|
1366
|
+
model: context.modelGateway,
|
|
1367
|
+
requireModel: true,
|
|
1368
|
+
onProgress: context.onProgress
|
|
1369
|
+
})) };
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
return { messages: [`KB compile failed: ${error instanceof Error ? error.message : "Unknown error."}`] };
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
if (subcommand === "reset") return { messages: formatKnowledgeResetResult(await resetKnowledgeBase(context.workspaceRoot)) };
|
|
1375
|
+
return { messages: ["Usage: /kb init, /kb compile, /kb reset, or /kb status"] };
|
|
1376
|
+
}
|
|
1377
|
+
function formatKnowledgeStatus(status) {
|
|
1378
|
+
const lines = [
|
|
1379
|
+
"KB status",
|
|
1380
|
+
`workspace: ${status.workspaceRoot}`,
|
|
1381
|
+
`knowledge folder: ${formatPathStatus$2(status.kbPath, status.kbExists, status.kbIsDirectory)} (${status.kbPathSource})`,
|
|
1382
|
+
`local cache folder: ${formatPathStatus$2(status.cachePath, status.cacheExists, status.cacheIsDirectory)} (${status.cachePathSource})`
|
|
1383
|
+
];
|
|
1384
|
+
if (!status.kbExists) lines.push("state: no knowledge base found yet");
|
|
1385
|
+
else if (!status.kbIsDirectory) lines.push("state: knowledge base path is not a folder");
|
|
1386
|
+
else lines.push("state: knowledge base found");
|
|
1387
|
+
return lines;
|
|
1388
|
+
}
|
|
1389
|
+
function formatPathStatus$2(path, exists, isDirectory) {
|
|
1390
|
+
if (!exists) return `${path} [missing]`;
|
|
1391
|
+
if (!isDirectory) return `${path} [not a folder]`;
|
|
1392
|
+
return `${path} [ok]`;
|
|
1393
|
+
}
|
|
1394
|
+
//#endregion
|
|
1395
|
+
//#region src/tui/messages.ts
|
|
1396
|
+
function systemMessage(text) {
|
|
1397
|
+
return {
|
|
1398
|
+
kind: "system",
|
|
1399
|
+
text
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
function userMessage(text) {
|
|
1403
|
+
return {
|
|
1404
|
+
kind: "user",
|
|
1405
|
+
text
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
function agentMessage(text, meta) {
|
|
1409
|
+
return {
|
|
1410
|
+
kind: "agent",
|
|
1411
|
+
text,
|
|
1412
|
+
meta
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
function modalMessage(message) {
|
|
1416
|
+
return {
|
|
1417
|
+
kind: "modal",
|
|
1418
|
+
...message
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
function renderChatMessage(message, options = {}) {
|
|
1422
|
+
if (message.kind === "modal") return renderChatModal(message, options.selectedActionIndex);
|
|
1423
|
+
if (message.text.length === 0) return [""];
|
|
1424
|
+
const lines = message.text.split("\n");
|
|
1425
|
+
if (message.kind === "user") return renderUserMessage(lines);
|
|
1426
|
+
if (message.kind === "system") return renderSystemMessage(lines);
|
|
1427
|
+
const prefix = getPrefix(message.kind);
|
|
1428
|
+
const rendered = prefix.length === 0 ? lines : lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`);
|
|
1429
|
+
if (message.meta) rendered.push(ui.label(message.meta));
|
|
1430
|
+
return rendered;
|
|
1431
|
+
}
|
|
1432
|
+
function renderUserMessage(lines) {
|
|
1433
|
+
const prefix = "You: ";
|
|
1434
|
+
return [
|
|
1435
|
+
"",
|
|
1436
|
+
...lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(5)}${line}`),
|
|
1437
|
+
""
|
|
1438
|
+
];
|
|
1439
|
+
}
|
|
1440
|
+
function renderSystemMessage(lines) {
|
|
1441
|
+
const bodyPrefix = " ";
|
|
1442
|
+
return [`${ui.ok("✦")} ${ui.label("System")}:`, ...lines.map((line) => `${bodyPrefix}${line}`)];
|
|
1443
|
+
}
|
|
1444
|
+
function getPrefix(kind) {
|
|
1445
|
+
switch (kind) {
|
|
1446
|
+
case "agent": return "";
|
|
1447
|
+
case "user": return `${ui.label("You")}: `;
|
|
1448
|
+
case "system": return `${ui.label("System")}: `;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function renderChatModal(message, selectedActionIndex) {
|
|
1452
|
+
const icon = message.tone === "warning" ? "⚠️" : "ℹ️";
|
|
1453
|
+
const title = message.tone === "warning" ? ui.warn(message.title) : ui.label(message.title);
|
|
1454
|
+
const bodyLines = message.body ? ["", ...message.body.split("\n")] : [];
|
|
1455
|
+
const actionLines = message.actions.map((action, index) => {
|
|
1456
|
+
return `${selectedActionIndex === index ? ">" : " "} ${index + 1}) ${action.label}`;
|
|
1457
|
+
});
|
|
1458
|
+
const contentLines = [
|
|
1459
|
+
`${icon} ${title}:`,
|
|
1460
|
+
...bodyLines,
|
|
1461
|
+
"",
|
|
1462
|
+
...actionLines
|
|
1463
|
+
];
|
|
1464
|
+
const contentWidth = Math.max(...contentLines.map(stripAnsi$1).map((line) => line.length), 1);
|
|
1465
|
+
const top = `╭${"─".repeat(contentWidth + 2)}╮`;
|
|
1466
|
+
const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
|
|
1467
|
+
return [
|
|
1468
|
+
top,
|
|
1469
|
+
...contentLines.map((line) => `│ ${line}${" ".repeat(contentWidth - stripAnsi$1(line).length)} │`),
|
|
1470
|
+
bottom
|
|
1471
|
+
];
|
|
1472
|
+
}
|
|
1473
|
+
function stripAnsi$1(text) {
|
|
1474
|
+
let plain = "";
|
|
1475
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1476
|
+
if (text.charCodeAt(index) === 27 && text[index + 1] === "[") {
|
|
1477
|
+
index += 2;
|
|
1478
|
+
while (index < text.length && text[index] !== "m") index += 1;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
plain += text[index];
|
|
1482
|
+
}
|
|
1483
|
+
return plain;
|
|
1484
|
+
}
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region src/tui/keys.ts
|
|
1487
|
+
function isUpKey(data) {
|
|
1488
|
+
return matchesKey(data, "up") || data === "\x1B[A";
|
|
1489
|
+
}
|
|
1490
|
+
function isDownKey(data) {
|
|
1491
|
+
return matchesKey(data, "down") || data === "\x1B[B";
|
|
1492
|
+
}
|
|
1493
|
+
function isEnterKey(data) {
|
|
1494
|
+
return matchesKey(data, "enter") || data === "\n" || data === "\r";
|
|
1495
|
+
}
|
|
1496
|
+
function isTabKey(data) {
|
|
1497
|
+
return matchesKey(data, "tab") || data === " ";
|
|
1498
|
+
}
|
|
1499
|
+
function isPageUpKey(data) {
|
|
1500
|
+
return data === "\x1B[5~";
|
|
1501
|
+
}
|
|
1502
|
+
function isPageDownKey(data) {
|
|
1503
|
+
return data === "\x1B[6~";
|
|
1504
|
+
}
|
|
1505
|
+
function isHomeKey(data) {
|
|
1506
|
+
return matchesKey(data, "home") || data === "\x1B[H" || data === "\x1B[1~";
|
|
1507
|
+
}
|
|
1508
|
+
function isEndKey(data) {
|
|
1509
|
+
return matchesKey(data, "end") || data === "\x1B[F" || data === "\x1B[4~";
|
|
1510
|
+
}
|
|
1511
|
+
function parseMouseWheel(data) {
|
|
1512
|
+
const sgrMatch = data.match(new RegExp(`^${escapeRegex("\x1B")}${escapeRegex("[<")}(\\d+);\\d+;\\d+M$`));
|
|
1513
|
+
if (sgrMatch) return getWheelDirection(Number(sgrMatch[1]));
|
|
1514
|
+
if (data.startsWith("\x1B[M") && data.length >= 6) return getWheelDirection(data.charCodeAt(3) - 32);
|
|
1515
|
+
}
|
|
1516
|
+
function getWheelDirection(button) {
|
|
1517
|
+
if ((button & 64) !== 64) return;
|
|
1518
|
+
return (button & 1) === 0 ? "up" : "down";
|
|
1519
|
+
}
|
|
1520
|
+
function escapeRegex(value) {
|
|
1521
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1522
|
+
}
|
|
1523
|
+
//#endregion
|
|
1524
|
+
//#region src/tui/status.ts
|
|
1525
|
+
function getStartupThreadMessages(context) {
|
|
1526
|
+
const assignments = context.config.models?.assignments ?? {};
|
|
1527
|
+
const providers = context.config.models?.providers ?? {};
|
|
1528
|
+
const lines = [
|
|
1529
|
+
ui.heading(""),
|
|
1530
|
+
`${ui.label("workspace")}: ${context.workspaceRoot}`,
|
|
1531
|
+
`${ui.label("default model")}: ${context.config.models?.defaultPurpose ?? "agent.primary"}`
|
|
1532
|
+
];
|
|
1533
|
+
if (Object.keys(assignments).length === 0) lines.push(`${ui.label("model assignments")}: none configured`);
|
|
1534
|
+
else {
|
|
1535
|
+
lines.push(`${ui.label("model assignments")}:`);
|
|
1536
|
+
for (const [purpose, model] of Object.entries(assignments)) {
|
|
1537
|
+
const provider = model.provider ? ` [${model.provider}]` : "";
|
|
1538
|
+
lines.push(` ${purpose}: ${model.name}${provider}`);
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const namedProviders = Object.entries(providers).filter(([providerId]) => providerId !== "default");
|
|
1542
|
+
if (namedProviders.length === 0) lines.push(`${ui.label("providers")}: none configured`);
|
|
1543
|
+
else {
|
|
1544
|
+
lines.push(`${ui.label("providers")}:`);
|
|
1545
|
+
if (typeof providers.default === "string") lines.push(` default: ${providers.default}`);
|
|
1546
|
+
for (const [providerId, provider] of namedProviders) {
|
|
1547
|
+
if (typeof provider === "string") continue;
|
|
1548
|
+
const auth = provider.apiKeyEnv ? `env:${provider.apiKeyEnv}` : provider.apiKey ? "inline" : "none";
|
|
1549
|
+
lines.push(` ${providerId}: ${provider.type} ${provider.baseURL} auth=${auth}`);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
lines.push("");
|
|
1553
|
+
lines.push("Ask Topchester what you want to change.");
|
|
1554
|
+
return [systemMessage(lines.join("\n"))];
|
|
1555
|
+
}
|
|
1556
|
+
function renderStaticLayout(messages, folderName = "", modelLabel = "") {
|
|
1557
|
+
const threadLines = messages.flatMap((message) => renderChatMessage(message));
|
|
1558
|
+
const status = formatStatusLine(folderName, modelLabel);
|
|
1559
|
+
return [
|
|
1560
|
+
...threadLines,
|
|
1561
|
+
"",
|
|
1562
|
+
"┌──────────────────────────────────────────────────────────────────────┐",
|
|
1563
|
+
"│ > │",
|
|
1564
|
+
"└──────────────────────────────────────────────────────────────────────┘",
|
|
1565
|
+
status
|
|
1566
|
+
].join("\n");
|
|
1567
|
+
}
|
|
1568
|
+
function getFolderName(path) {
|
|
1569
|
+
return basename(path) || path;
|
|
1570
|
+
}
|
|
1571
|
+
function formatStatusLine(folderName, modelLabel, status = "ready") {
|
|
1572
|
+
const folder = folderName ? ` · folder: ${folderName}` : "";
|
|
1573
|
+
const model = modelLabel ? ` · model: ${modelLabel}` : "";
|
|
1574
|
+
return `${ui.label("status")}: ${status}${folder}${model}`;
|
|
1575
|
+
}
|
|
1576
|
+
function formatPathStatus$1(path, exists, isDirectory) {
|
|
1577
|
+
if (!exists) return `${path} ${ui.warn("[missing]")}`;
|
|
1578
|
+
if (!isDirectory) return `${path} ${ui.error("[not a folder]")}`;
|
|
1579
|
+
return `${path} ${ui.ok("[ok]")}`;
|
|
1580
|
+
}
|
|
1581
|
+
function getModelLabel(context) {
|
|
1582
|
+
const purpose = context.config.models?.defaultPurpose ?? "agent.primary";
|
|
1583
|
+
const model = context.config.models?.assignments?.[purpose] ?? context.config.models?.assignments?.fallback;
|
|
1584
|
+
if (!model) return "not set";
|
|
1585
|
+
const provider = model.provider ?? context.config.models?.providers?.default;
|
|
1586
|
+
return typeof provider === "string" ? `${model.name} [${provider}]` : model.name;
|
|
1587
|
+
}
|
|
1588
|
+
//#endregion
|
|
1589
|
+
//#region src/tui/text.ts
|
|
1590
|
+
function padLines(lines, height, width) {
|
|
1591
|
+
return [...Array.from({ length: Math.max(0, height - lines.length) }, () => ""), ...lines].map((line) => truncateToWidth(line, width, "…", true));
|
|
1592
|
+
}
|
|
1593
|
+
function padThreadLine(line, innerWidth, width) {
|
|
1594
|
+
return truncateToWidth(` ${truncateToWidth(line, innerWidth, "…", true)} `, width, "…", true);
|
|
1595
|
+
}
|
|
1596
|
+
function stripAnsi(text) {
|
|
1597
|
+
let plain = "";
|
|
1598
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1599
|
+
if (text.charCodeAt(index) === 27 && text[index + 1] === "[") {
|
|
1600
|
+
index += 2;
|
|
1601
|
+
while (index < text.length && text[index] !== "m") index += 1;
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
plain += text[index];
|
|
1605
|
+
}
|
|
1606
|
+
return plain;
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/tui/layout.ts
|
|
1610
|
+
var ChatLayout = class {
|
|
1611
|
+
terminal;
|
|
1612
|
+
messages;
|
|
1613
|
+
folderName;
|
|
1614
|
+
modelLabel;
|
|
1615
|
+
exitAgent;
|
|
1616
|
+
input = new Input();
|
|
1617
|
+
status = "ready";
|
|
1618
|
+
ephemeralLine;
|
|
1619
|
+
noticeLine;
|
|
1620
|
+
promptHint;
|
|
1621
|
+
cancelPending;
|
|
1622
|
+
submitMessage;
|
|
1623
|
+
submitCommand;
|
|
1624
|
+
activeModalActionIndex = 0;
|
|
1625
|
+
activeSlashSuggestionIndex = 0;
|
|
1626
|
+
threadScrollOffset = 0;
|
|
1627
|
+
constructor(terminal, messages, folderName, modelLabel, exitAgent = () => {}) {
|
|
1628
|
+
this.terminal = terminal;
|
|
1629
|
+
this.messages = messages;
|
|
1630
|
+
this.folderName = folderName;
|
|
1631
|
+
this.modelLabel = modelLabel;
|
|
1632
|
+
this.exitAgent = exitAgent;
|
|
1633
|
+
this.input.onSubmit = (value) => {
|
|
1634
|
+
if (value.trim().length > 0) {
|
|
1635
|
+
const message = value.trim();
|
|
1636
|
+
this.addMessage(userMessage(message));
|
|
1637
|
+
this.input.setValue("");
|
|
1638
|
+
this.submitUserInput(message);
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
addMessage(message) {
|
|
1643
|
+
this.messages.push(message);
|
|
1644
|
+
this.threadScrollOffset = 0;
|
|
1645
|
+
if (message.kind === "modal") this.activeModalActionIndex = 0;
|
|
1646
|
+
}
|
|
1647
|
+
setStatus(status) {
|
|
1648
|
+
this.status = status;
|
|
1649
|
+
}
|
|
1650
|
+
isReady() {
|
|
1651
|
+
return this.status === "ready";
|
|
1652
|
+
}
|
|
1653
|
+
setEphemeralLine(line) {
|
|
1654
|
+
this.ephemeralLine = line;
|
|
1655
|
+
}
|
|
1656
|
+
setNoticeLine(line) {
|
|
1657
|
+
this.noticeLine = line;
|
|
1658
|
+
}
|
|
1659
|
+
setPromptHint(hint) {
|
|
1660
|
+
this.promptHint = hint;
|
|
1661
|
+
}
|
|
1662
|
+
setCancelPending(cancel) {
|
|
1663
|
+
this.cancelPending = cancel;
|
|
1664
|
+
}
|
|
1665
|
+
setSubmitMessage(submit) {
|
|
1666
|
+
this.submitMessage = submit;
|
|
1667
|
+
}
|
|
1668
|
+
setSubmitCommand(submit) {
|
|
1669
|
+
this.submitCommand = submit;
|
|
1670
|
+
}
|
|
1671
|
+
setInputValue(value) {
|
|
1672
|
+
this.input.setValue(value);
|
|
1673
|
+
}
|
|
1674
|
+
getConversationTurns() {
|
|
1675
|
+
return this.messages.flatMap((message) => {
|
|
1676
|
+
if (message.kind === "user") return [{
|
|
1677
|
+
role: "user",
|
|
1678
|
+
text: message.text
|
|
1679
|
+
}];
|
|
1680
|
+
if (message.kind === "agent" && message.text !== "ready") return [{
|
|
1681
|
+
role: "assistant",
|
|
1682
|
+
text: message.text
|
|
1683
|
+
}];
|
|
1684
|
+
return [];
|
|
1685
|
+
});
|
|
1686
|
+
}
|
|
1687
|
+
get focused() {
|
|
1688
|
+
return this.input.focused;
|
|
1689
|
+
}
|
|
1690
|
+
set focused(value) {
|
|
1691
|
+
this.input.focused = value;
|
|
1692
|
+
}
|
|
1693
|
+
handleInput(data) {
|
|
1694
|
+
if (this.cancelPending && matchesKey(data, "escape")) {
|
|
1695
|
+
this.cancelPending();
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (this.handleModalInput(data)) return;
|
|
1699
|
+
if (this.handleSlashSuggestionInput(data)) return;
|
|
1700
|
+
if (this.handleThreadScrollInput(data)) return;
|
|
1701
|
+
this.input.handleInput(data);
|
|
1702
|
+
}
|
|
1703
|
+
invalidate() {
|
|
1704
|
+
this.input.invalidate();
|
|
1705
|
+
}
|
|
1706
|
+
render(width) {
|
|
1707
|
+
const safeWidth = Math.max(20, width);
|
|
1708
|
+
const footerLines = this.getActiveModal() ? this.renderModalHelp(safeWidth) : this.renderPrompt(safeWidth);
|
|
1709
|
+
const threadHeight = Math.max(1, this.terminal.rows - footerLines.length);
|
|
1710
|
+
const allThreadLines = this.renderThread(safeWidth);
|
|
1711
|
+
const maxScrollOffset = Math.max(0, allThreadLines.length - threadHeight);
|
|
1712
|
+
this.threadScrollOffset = Math.min(this.threadScrollOffset, maxScrollOffset);
|
|
1713
|
+
const end = allThreadLines.length - this.threadScrollOffset;
|
|
1714
|
+
const start = Math.max(0, end - threadHeight);
|
|
1715
|
+
return [...padLines(allThreadLines.slice(start, end), threadHeight, safeWidth), ...footerLines];
|
|
1716
|
+
}
|
|
1717
|
+
renderThread(width) {
|
|
1718
|
+
const innerWidth = Math.max(1, width - 2);
|
|
1719
|
+
const activeModalIndex = this.getActiveModalIndex();
|
|
1720
|
+
const lines = this.messages.flatMap((message, index) => {
|
|
1721
|
+
const messageLines = renderChatMessage(message, { selectedActionIndex: index === activeModalIndex ? this.activeModalActionIndex : void 0 });
|
|
1722
|
+
const spacer = index === this.messages.length - 1 ? [] : [padThreadLine("", innerWidth, width)];
|
|
1723
|
+
return [...this.renderThreadMessageLines(messageLines, innerWidth, width, message.kind === "user"), ...spacer];
|
|
1724
|
+
});
|
|
1725
|
+
if (this.ephemeralLine) lines.push(...this.renderThreadMessageLines([this.ephemeralLine], innerWidth, width, false));
|
|
1726
|
+
if (this.noticeLine) lines.push(...this.renderThreadMessageLines([this.noticeLine], innerWidth, width, false));
|
|
1727
|
+
return lines;
|
|
1728
|
+
}
|
|
1729
|
+
renderThreadMessageLines(lines, innerWidth, width, highlight) {
|
|
1730
|
+
return lines.flatMap((line) => {
|
|
1731
|
+
const styleLine = (value) => highlight ? ui.softBackground(value) : value;
|
|
1732
|
+
if (line.length === 0) return [styleLine(padThreadLine("", innerWidth, width))];
|
|
1733
|
+
return wrapTextWithAnsi(line, innerWidth).map((wrappedLine) => styleLine(padThreadLine(wrappedLine, innerWidth, width)));
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
renderPrompt(width) {
|
|
1737
|
+
const top = `┌${"─".repeat(Math.max(0, width - 2))}┐`;
|
|
1738
|
+
const bottom = `└${"─".repeat(Math.max(0, width - 2))}┘`;
|
|
1739
|
+
const prefix = "> ";
|
|
1740
|
+
const innerWidth = Math.max(1, width - 4 - 2);
|
|
1741
|
+
const inputLine = this.promptHint ? truncateToWidth(ui.label(this.promptHint), innerWidth, "…", true) : truncateToWidth(renderInputWithoutPrompt(this.input, innerWidth), innerWidth, "…", true);
|
|
1742
|
+
const status = truncateToWidth(formatStatusLine(this.folderName, this.modelLabel, this.status), width, "…", true);
|
|
1743
|
+
return [
|
|
1744
|
+
...this.renderSlashSuggestions(width),
|
|
1745
|
+
top,
|
|
1746
|
+
`│ ${prefix}${inputLine} │`,
|
|
1747
|
+
bottom,
|
|
1748
|
+
status
|
|
1749
|
+
];
|
|
1750
|
+
}
|
|
1751
|
+
renderSlashSuggestions(width) {
|
|
1752
|
+
const suggestions = this.getSlashSuggestions();
|
|
1753
|
+
if (suggestions.length === 0 || this.promptHint) return [];
|
|
1754
|
+
this.activeSlashSuggestionIndex = Math.min(this.activeSlashSuggestionIndex, suggestions.length - 1);
|
|
1755
|
+
const innerWidth = Math.max(1, width - 4);
|
|
1756
|
+
const visibleSuggestions = suggestions.slice(0, 6);
|
|
1757
|
+
const lines = [
|
|
1758
|
+
ui.label("slash commands"),
|
|
1759
|
+
...visibleSuggestions.map((suggestion, index) => {
|
|
1760
|
+
return truncateToWidth(`${index === this.activeSlashSuggestionIndex ? ">" : " "} ${suggestion.value} — ${suggestion.description}`, innerWidth, "…", true);
|
|
1761
|
+
}),
|
|
1762
|
+
ui.label("Tab complete · ↑↓ choose")
|
|
1763
|
+
];
|
|
1764
|
+
const maxLineWidth = Math.max(...lines.map(stripAnsi).map((line) => line.length), 1);
|
|
1765
|
+
const boxWidth = Math.min(innerWidth, maxLineWidth);
|
|
1766
|
+
const top = `╭${"─".repeat(boxWidth + 2)}╮`;
|
|
1767
|
+
const bottom = `╰${"─".repeat(boxWidth + 2)}╯`;
|
|
1768
|
+
return [
|
|
1769
|
+
top,
|
|
1770
|
+
...lines.map((line) => `│ ${line}${" ".repeat(Math.max(0, boxWidth - stripAnsi(line).length))} │`),
|
|
1771
|
+
bottom
|
|
1772
|
+
];
|
|
1773
|
+
}
|
|
1774
|
+
renderModalHelp(width) {
|
|
1775
|
+
const help = "↑↓ navigate Enter select Esc cancel";
|
|
1776
|
+
const status = formatStatusLine(this.folderName, this.modelLabel, this.status);
|
|
1777
|
+
return [truncateToWidth(` ${help}`, width, "…", true), truncateToWidth(` ${status}`, width, "…", true)];
|
|
1778
|
+
}
|
|
1779
|
+
handleModalInput(data) {
|
|
1780
|
+
const activeModal = this.getActiveModal();
|
|
1781
|
+
if (!activeModal) return false;
|
|
1782
|
+
if (isUpKey(data)) {
|
|
1783
|
+
this.activeModalActionIndex = (this.activeModalActionIndex - 1 + activeModal.actions.length) % activeModal.actions.length;
|
|
1784
|
+
return true;
|
|
1785
|
+
}
|
|
1786
|
+
if (isDownKey(data)) {
|
|
1787
|
+
this.activeModalActionIndex = (this.activeModalActionIndex + 1) % activeModal.actions.length;
|
|
1788
|
+
return true;
|
|
1789
|
+
}
|
|
1790
|
+
if (matchesKey(data, "enter") || data === "\n" || data === "\r") {
|
|
1791
|
+
const action = activeModal.actions[this.activeModalActionIndex];
|
|
1792
|
+
if (action.label === "Exit") {
|
|
1793
|
+
this.exitAgent();
|
|
1794
|
+
return true;
|
|
1795
|
+
}
|
|
1796
|
+
this.submitModalAction(action.value ?? action.label);
|
|
1797
|
+
return true;
|
|
1798
|
+
}
|
|
1799
|
+
if (matchesKey(data, "escape")) {
|
|
1800
|
+
this.addMessage(userMessage("Cancel"));
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
return false;
|
|
1804
|
+
}
|
|
1805
|
+
handleThreadScrollInput(data) {
|
|
1806
|
+
const pageSize = Math.max(1, Math.floor(this.terminal.rows / 2));
|
|
1807
|
+
const wheel = parseMouseWheel(data);
|
|
1808
|
+
if (isUpKey(data)) {
|
|
1809
|
+
this.threadScrollOffset += 3;
|
|
1810
|
+
return true;
|
|
1811
|
+
}
|
|
1812
|
+
if (isDownKey(data)) {
|
|
1813
|
+
this.threadScrollOffset = Math.max(0, this.threadScrollOffset - 3);
|
|
1814
|
+
return true;
|
|
1815
|
+
}
|
|
1816
|
+
if (wheel === "up") {
|
|
1817
|
+
this.threadScrollOffset += 3;
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
if (wheel === "down") {
|
|
1821
|
+
this.threadScrollOffset = Math.max(0, this.threadScrollOffset - 3);
|
|
1822
|
+
return true;
|
|
1823
|
+
}
|
|
1824
|
+
if (isPageUpKey(data)) {
|
|
1825
|
+
this.threadScrollOffset += pageSize;
|
|
1826
|
+
return true;
|
|
1827
|
+
}
|
|
1828
|
+
if (isPageDownKey(data)) {
|
|
1829
|
+
this.threadScrollOffset = Math.max(0, this.threadScrollOffset - pageSize);
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
if (isHomeKey(data)) {
|
|
1833
|
+
this.threadScrollOffset = Number.MAX_SAFE_INTEGER;
|
|
1834
|
+
return true;
|
|
1835
|
+
}
|
|
1836
|
+
if (isEndKey(data)) {
|
|
1837
|
+
this.threadScrollOffset = 0;
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
return false;
|
|
1841
|
+
}
|
|
1842
|
+
handleSlashSuggestionInput(data) {
|
|
1843
|
+
const suggestions = this.getSlashSuggestions();
|
|
1844
|
+
if (suggestions.length === 0) {
|
|
1845
|
+
this.activeSlashSuggestionIndex = 0;
|
|
1846
|
+
return false;
|
|
1847
|
+
}
|
|
1848
|
+
if (isUpKey(data)) {
|
|
1849
|
+
this.activeSlashSuggestionIndex = (this.activeSlashSuggestionIndex - 1 + suggestions.length) % suggestions.length;
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
if (isDownKey(data)) {
|
|
1853
|
+
this.activeSlashSuggestionIndex = (this.activeSlashSuggestionIndex + 1) % suggestions.length;
|
|
1854
|
+
return true;
|
|
1855
|
+
}
|
|
1856
|
+
if (isTabKey(data)) {
|
|
1857
|
+
this.completeSlashSuggestion(suggestions);
|
|
1858
|
+
return true;
|
|
1859
|
+
}
|
|
1860
|
+
if (isEnterKey(data) && this.input.getValue().trim() !== suggestions[this.activeSlashSuggestionIndex]?.value) {
|
|
1861
|
+
this.completeSlashSuggestion(suggestions);
|
|
1862
|
+
return true;
|
|
1863
|
+
}
|
|
1864
|
+
return false;
|
|
1865
|
+
}
|
|
1866
|
+
completeSlashSuggestion(suggestions) {
|
|
1867
|
+
this.input.setValue(suggestions[this.activeSlashSuggestionIndex]?.value ?? this.input.getValue());
|
|
1868
|
+
this.input.handleInput("\x1B[F");
|
|
1869
|
+
}
|
|
1870
|
+
getSlashSuggestions() {
|
|
1871
|
+
return getSlashCommandSuggestions(this.input.getValue());
|
|
1872
|
+
}
|
|
1873
|
+
getActiveModal() {
|
|
1874
|
+
return this.messages[this.getActiveModalIndex()];
|
|
1875
|
+
}
|
|
1876
|
+
getActiveModalIndex() {
|
|
1877
|
+
return this.messages[this.messages.length - 1]?.kind === "modal" ? this.messages.length - 1 : -1;
|
|
1878
|
+
}
|
|
1879
|
+
submitModalAction(message) {
|
|
1880
|
+
this.addMessage(userMessage(message));
|
|
1881
|
+
this.submitUserInput(message);
|
|
1882
|
+
}
|
|
1883
|
+
submitUserInput(message) {
|
|
1884
|
+
if (message.startsWith("/")) this.submitCommand?.(message);
|
|
1885
|
+
else this.submitMessage?.(message);
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
function renderInputWithoutPrompt(input, width) {
|
|
1889
|
+
return (input.render(width + 2)[0] ?? "").replace(/^> /, "");
|
|
1890
|
+
}
|
|
1891
|
+
//#endregion
|
|
1892
|
+
//#region src/agent/conversation.ts
|
|
1893
|
+
function buildConversationPrompt(turns, latestMessage) {
|
|
1894
|
+
const lines = turns.map((turn) => {
|
|
1895
|
+
return `${turn.role === "user" ? "User" : "Assistant"}: ${turn.text}`;
|
|
1896
|
+
});
|
|
1897
|
+
if (lines.at(-1) !== `User: ${latestMessage}`) lines.push(`User: ${latestMessage}`);
|
|
1898
|
+
return lines.join("\n\n");
|
|
1899
|
+
}
|
|
1900
|
+
//#endregion
|
|
1901
|
+
//#region src/agent/events.ts
|
|
1902
|
+
const agentEvent = {
|
|
1903
|
+
status(status) {
|
|
1904
|
+
return {
|
|
1905
|
+
type: "status",
|
|
1906
|
+
status
|
|
1907
|
+
};
|
|
1908
|
+
},
|
|
1909
|
+
systemMessage(text) {
|
|
1910
|
+
return {
|
|
1911
|
+
type: "message",
|
|
1912
|
+
role: "system",
|
|
1913
|
+
text
|
|
1914
|
+
};
|
|
1915
|
+
},
|
|
1916
|
+
assistantMessage(text, meta) {
|
|
1917
|
+
return meta === void 0 ? {
|
|
1918
|
+
type: "message",
|
|
1919
|
+
role: "assistant",
|
|
1920
|
+
text
|
|
1921
|
+
} : {
|
|
1922
|
+
type: "message",
|
|
1923
|
+
role: "assistant",
|
|
1924
|
+
text,
|
|
1925
|
+
meta
|
|
1926
|
+
};
|
|
1927
|
+
},
|
|
1928
|
+
toolCall(call, label) {
|
|
1929
|
+
return {
|
|
1930
|
+
type: "tool_call",
|
|
1931
|
+
call,
|
|
1932
|
+
label
|
|
1933
|
+
};
|
|
1934
|
+
},
|
|
1935
|
+
knowledgeStatus(status) {
|
|
1936
|
+
return {
|
|
1937
|
+
type: "knowledge_status",
|
|
1938
|
+
status
|
|
1939
|
+
};
|
|
1940
|
+
},
|
|
1941
|
+
choice(options) {
|
|
1942
|
+
return {
|
|
1943
|
+
type: "choice",
|
|
1944
|
+
...options
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
function choiceAction(label, value) {
|
|
1949
|
+
return value === void 0 ? { label } : {
|
|
1950
|
+
label,
|
|
1951
|
+
value
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
//#endregion
|
|
1955
|
+
//#region src/agent/health.ts
|
|
1956
|
+
async function checkAgentReady(modelGateway, abortSignal) {
|
|
1957
|
+
const abortController = new AbortController();
|
|
1958
|
+
let timedOut = false;
|
|
1959
|
+
const timeout = setTimeout(() => {
|
|
1960
|
+
timedOut = true;
|
|
1961
|
+
abortController.abort();
|
|
1962
|
+
}, 3e4);
|
|
1963
|
+
const abort = () => abortController.abort();
|
|
1964
|
+
abortSignal?.addEventListener("abort", abort, { once: true });
|
|
1965
|
+
try {
|
|
1966
|
+
return (await modelGateway.generateText({
|
|
1967
|
+
purpose: "agent.fast",
|
|
1968
|
+
system: "You are a startup health check. Reply with exactly one word: ready",
|
|
1969
|
+
prompt: "Reply with exactly: ready",
|
|
1970
|
+
abortSignal: abortController.signal
|
|
1971
|
+
})).text.trim().toLowerCase().includes("ready") ? "ready" : "not-ready";
|
|
1972
|
+
} catch (error) {
|
|
1973
|
+
if (timedOut && isAbortError(error)) return "timed-out";
|
|
1974
|
+
throw error;
|
|
1975
|
+
} finally {
|
|
1976
|
+
clearTimeout(timeout);
|
|
1977
|
+
abortSignal?.removeEventListener("abort", abort);
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
function isAbortError(error) {
|
|
1981
|
+
if (!(error instanceof Error)) return false;
|
|
1982
|
+
return error.name === "AbortError" || error.message.toLowerCase().includes("aborted");
|
|
1983
|
+
}
|
|
1984
|
+
//#endregion
|
|
1985
|
+
//#region src/agent/tools/types.ts
|
|
1986
|
+
function defineTool(definition) {
|
|
1987
|
+
return definition;
|
|
1988
|
+
}
|
|
1989
|
+
//#endregion
|
|
1990
|
+
//#region src/agent/tools/find-file.ts
|
|
1991
|
+
const findFileArgsSchema = z.object({
|
|
1992
|
+
query: z.string().min(1),
|
|
1993
|
+
path: z.string().optional().default("."),
|
|
1994
|
+
limit: z.number().int().min(1).max(200).optional().default(50)
|
|
1995
|
+
});
|
|
1996
|
+
const ignoredDirectories = new Set([
|
|
1997
|
+
".git",
|
|
1998
|
+
".agents",
|
|
1999
|
+
".cache",
|
|
2000
|
+
".next",
|
|
2001
|
+
".turbo",
|
|
2002
|
+
"build",
|
|
2003
|
+
"coverage",
|
|
2004
|
+
"dist",
|
|
2005
|
+
"node_modules"
|
|
2006
|
+
]);
|
|
2007
|
+
const findFileTool = defineTool({
|
|
2008
|
+
name: "find_file",
|
|
2009
|
+
description: "Find files by fuzzy name inside the workspace.",
|
|
2010
|
+
prompt: "find_file: find files by fuzzy name inside the workspace. To use it, reply with only JSON: {\"tool\":\"find_file\",\"args\":{\"query\":\"runtime\"}}",
|
|
2011
|
+
argsSchema: findFileArgsSchema,
|
|
2012
|
+
execute: (context, args) => findWorkspaceFilesByName(context.workspaceRoot, args, {
|
|
2013
|
+
pathEnv: context.pathEnv,
|
|
2014
|
+
logger: context.logger
|
|
2015
|
+
})
|
|
2016
|
+
});
|
|
2017
|
+
async function findWorkspaceFilesByName(workspaceRoot, args, options = {}) {
|
|
2018
|
+
const scopedPath = resolveWorkspaceScopedPath$1(workspaceRoot, args.path);
|
|
2019
|
+
const matches = (await collectWorkspaceFiles(scopedPath.workspaceRoot, scopedPath.path, scopedPath.relativePath, options)).map((path) => {
|
|
2020
|
+
const score = scoreFileMatch(args.query, path);
|
|
2021
|
+
return score > 0 ? {
|
|
2022
|
+
path,
|
|
2023
|
+
score
|
|
2024
|
+
} : void 0;
|
|
2025
|
+
}).filter((match) => Boolean(match)).sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)).slice(0, args.limit);
|
|
2026
|
+
return {
|
|
2027
|
+
tool: "find_file",
|
|
2028
|
+
path: scopedPath.relativePath,
|
|
2029
|
+
content: matches.length > 0 ? matches.map((match) => match.path).join("\n") : "No matching files."
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
async function collectWorkspaceFiles(workspaceRoot, startPath, relativeStartPath, options) {
|
|
2033
|
+
const nativeFiles = await collectWorkspaceFilesWithNativeCommand(workspaceRoot, relativeStartPath, options);
|
|
2034
|
+
if (nativeFiles) return nativeFiles;
|
|
2035
|
+
options.logger?.debug({
|
|
2036
|
+
event: "native_tool_selected",
|
|
2037
|
+
tool: "find_file",
|
|
2038
|
+
nativeTool: "node",
|
|
2039
|
+
path: relativeStartPath
|
|
2040
|
+
}, "native tool selected");
|
|
2041
|
+
return collectWorkspaceFilesWithNode(workspaceRoot, startPath);
|
|
2042
|
+
}
|
|
2043
|
+
async function collectWorkspaceFilesWithNativeCommand(workspaceRoot, relativeStartPath, options) {
|
|
2044
|
+
const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
|
|
2045
|
+
const collectors = [
|
|
2046
|
+
createRipgrepCollector,
|
|
2047
|
+
createFdCollector,
|
|
2048
|
+
createFindCollector
|
|
2049
|
+
];
|
|
2050
|
+
for (const createCollector of collectors) {
|
|
2051
|
+
const collector = await createCollector(pathEnv, relativeStartPath);
|
|
2052
|
+
if (!collector) continue;
|
|
2053
|
+
options.logger?.debug({
|
|
2054
|
+
event: "native_tool_selected",
|
|
2055
|
+
tool: "find_file",
|
|
2056
|
+
nativeTool: collector.name,
|
|
2057
|
+
path: relativeStartPath
|
|
2058
|
+
}, "native tool selected");
|
|
2059
|
+
const result = await runCommand$1(collector.command, collector.args, workspaceRoot);
|
|
2060
|
+
options.logger?.debug({
|
|
2061
|
+
event: "find_file_command_result",
|
|
2062
|
+
command: collector.name,
|
|
2063
|
+
exitCode: result.exitCode,
|
|
2064
|
+
stdoutLength: result.stdout.length,
|
|
2065
|
+
stderrLength: result.stderr.length
|
|
2066
|
+
}, "find_file command result");
|
|
2067
|
+
options.logger?.trace({
|
|
2068
|
+
event: "find_file_command_output",
|
|
2069
|
+
command: collector.name,
|
|
2070
|
+
stdout: result.stdout,
|
|
2071
|
+
stderr: result.stderr
|
|
2072
|
+
}, "find_file command output");
|
|
2073
|
+
if (result.exitCode === 0) return normalizeCommandFileList(workspaceRoot, result.stdout);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
async function collectWorkspaceFilesWithNode(workspaceRoot, startPath) {
|
|
2077
|
+
const files = [];
|
|
2078
|
+
const pending = [startPath];
|
|
2079
|
+
while (pending.length > 0) {
|
|
2080
|
+
const currentPath = pending.pop() ?? startPath;
|
|
2081
|
+
const entries = await readdir(currentPath, { withFileTypes: true });
|
|
2082
|
+
for (const entry of entries) {
|
|
2083
|
+
const absolutePath = resolve(currentPath, entry.name);
|
|
2084
|
+
const relativePath = relative(workspaceRoot, absolutePath);
|
|
2085
|
+
if (entry.isDirectory()) {
|
|
2086
|
+
if (!ignoredDirectories.has(entry.name)) pending.push(absolutePath);
|
|
2087
|
+
continue;
|
|
2088
|
+
}
|
|
2089
|
+
if (entry.isFile()) files.push(relativePath || ".");
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
return files;
|
|
2093
|
+
}
|
|
2094
|
+
async function createRipgrepCollector(pathEnv, relativeStartPath) {
|
|
2095
|
+
const command = await findExecutable$1("rg", pathEnv);
|
|
2096
|
+
if (!command) return;
|
|
2097
|
+
return {
|
|
2098
|
+
name: "rg",
|
|
2099
|
+
command,
|
|
2100
|
+
args: [
|
|
2101
|
+
"--files",
|
|
2102
|
+
"--hidden",
|
|
2103
|
+
...ignoredDirectoryGlobArgs(),
|
|
2104
|
+
"--",
|
|
2105
|
+
relativeStartPath
|
|
2106
|
+
]
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
async function createFdCollector(pathEnv, relativeStartPath) {
|
|
2110
|
+
const fdCommand = await findExecutable$1("fd", pathEnv);
|
|
2111
|
+
const fdfindCommand = fdCommand ? void 0 : await findExecutable$1("fdfind", pathEnv);
|
|
2112
|
+
const command = fdCommand ?? fdfindCommand;
|
|
2113
|
+
if (!command) return;
|
|
2114
|
+
return {
|
|
2115
|
+
name: fdCommand ? "fd" : "fdfind",
|
|
2116
|
+
command,
|
|
2117
|
+
args: [
|
|
2118
|
+
"--type",
|
|
2119
|
+
"f",
|
|
2120
|
+
"--hidden",
|
|
2121
|
+
"--color",
|
|
2122
|
+
"never",
|
|
2123
|
+
...ignoredDirectoryExcludeArgs(),
|
|
2124
|
+
".",
|
|
2125
|
+
relativeStartPath
|
|
2126
|
+
]
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
async function createFindCollector(pathEnv, relativeStartPath) {
|
|
2130
|
+
const command = await findExecutable$1("find", pathEnv);
|
|
2131
|
+
if (!command) return;
|
|
2132
|
+
return {
|
|
2133
|
+
name: "find",
|
|
2134
|
+
command,
|
|
2135
|
+
args: [
|
|
2136
|
+
relativeStartPath,
|
|
2137
|
+
"(",
|
|
2138
|
+
...ignoredDirectoryFindPruneArgs(),
|
|
2139
|
+
")",
|
|
2140
|
+
"-prune",
|
|
2141
|
+
"-o",
|
|
2142
|
+
"-type",
|
|
2143
|
+
"f",
|
|
2144
|
+
"-print"
|
|
2145
|
+
]
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
function ignoredDirectoryGlobArgs() {
|
|
2149
|
+
return [...ignoredDirectories].flatMap((directory) => ["--glob", `!${directory}/**`]);
|
|
2150
|
+
}
|
|
2151
|
+
function ignoredDirectoryExcludeArgs() {
|
|
2152
|
+
return [...ignoredDirectories].flatMap((directory) => ["--exclude", directory]);
|
|
2153
|
+
}
|
|
2154
|
+
function ignoredDirectoryFindPruneArgs() {
|
|
2155
|
+
return [...ignoredDirectories].flatMap((directory, index) => index === 0 ? ["-name", directory] : [
|
|
2156
|
+
"-o",
|
|
2157
|
+
"-name",
|
|
2158
|
+
directory
|
|
2159
|
+
]);
|
|
2160
|
+
}
|
|
2161
|
+
function normalizeCommandFileList(workspaceRoot, stdout) {
|
|
2162
|
+
return stdout.split("\n").map((line) => normalizeCommandFilePath(workspaceRoot, line)).filter((path) => Boolean(path));
|
|
2163
|
+
}
|
|
2164
|
+
function normalizeCommandFilePath(workspaceRoot, path) {
|
|
2165
|
+
const trimmed = path.trim();
|
|
2166
|
+
if (!trimmed) return;
|
|
2167
|
+
const relativePath = isAbsolute(trimmed) ? relative(workspaceRoot, trimmed) : trimmed.replace(/^\.\//, "");
|
|
2168
|
+
if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) return;
|
|
2169
|
+
return relativePath;
|
|
2170
|
+
}
|
|
2171
|
+
function scoreFileMatch(query, path) {
|
|
2172
|
+
const normalizedQuery = normalize(query);
|
|
2173
|
+
const normalizedPath = normalize(path);
|
|
2174
|
+
const normalizedName = normalize(basename(path));
|
|
2175
|
+
if (!normalizedQuery) return 0;
|
|
2176
|
+
const exactScore = scoreExactMatch(normalizedQuery, normalizedPath, normalizedName);
|
|
2177
|
+
if (exactScore > 0) return exactScore;
|
|
2178
|
+
const pathTokenScore = scoreTokenMatch(normalizedQuery, normalizedPath);
|
|
2179
|
+
const nameTokenScore = scoreTokenMatch(normalizedQuery, normalizedName);
|
|
2180
|
+
const nameFuzzyScore = scoreSubsequenceMatch(normalizedQuery, normalizedName);
|
|
2181
|
+
const pathFuzzyScore = scoreSubsequenceMatch(normalizedQuery, normalizedPath);
|
|
2182
|
+
return Math.max(pathTokenScore, nameTokenScore, nameFuzzyScore, pathFuzzyScore);
|
|
2183
|
+
}
|
|
2184
|
+
function scoreExactMatch(query, path, name) {
|
|
2185
|
+
if (name === query) return 1e3;
|
|
2186
|
+
if (path === query) return 950;
|
|
2187
|
+
const nameIndex = name.indexOf(query);
|
|
2188
|
+
if (nameIndex >= 0) return 900 - nameIndex - Math.max(0, name.length - query.length) / 100;
|
|
2189
|
+
const pathIndex = path.indexOf(query);
|
|
2190
|
+
if (pathIndex >= 0) return 800 - pathIndex - Math.max(0, path.length - query.length) / 100;
|
|
2191
|
+
return 0;
|
|
2192
|
+
}
|
|
2193
|
+
function scoreTokenMatch(query, value) {
|
|
2194
|
+
const tokens = query.split(/[^a-z0-9]+/).filter(Boolean);
|
|
2195
|
+
if (tokens.length <= 1 || !tokens.every((token) => value.includes(token))) return 0;
|
|
2196
|
+
return 700 - Math.max(0, value.length - query.length) / 100;
|
|
2197
|
+
}
|
|
2198
|
+
function scoreSubsequenceMatch(query, value) {
|
|
2199
|
+
let queryIndex = 0;
|
|
2200
|
+
let gapCount = 0;
|
|
2201
|
+
let lastMatchIndex = -1;
|
|
2202
|
+
for (let valueIndex = 0; valueIndex < value.length && queryIndex < query.length; valueIndex += 1) {
|
|
2203
|
+
if (value[valueIndex] !== query[queryIndex]) continue;
|
|
2204
|
+
if (lastMatchIndex >= 0) gapCount += valueIndex - lastMatchIndex - 1;
|
|
2205
|
+
lastMatchIndex = valueIndex;
|
|
2206
|
+
queryIndex += 1;
|
|
2207
|
+
}
|
|
2208
|
+
if (queryIndex !== query.length) return 0;
|
|
2209
|
+
return 600 - gapCount - Math.max(0, value.length - query.length) / 100;
|
|
2210
|
+
}
|
|
2211
|
+
function normalize(value) {
|
|
2212
|
+
return value.trim().toLowerCase().replaceAll("\\", "/");
|
|
2213
|
+
}
|
|
2214
|
+
function resolveWorkspaceScopedPath$1(workspaceRoot, path) {
|
|
2215
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
2216
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
2217
|
+
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
2218
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`find_file can only search inside the workspace: ${path}`);
|
|
2219
|
+
return {
|
|
2220
|
+
workspaceRoot: resolvedWorkspace,
|
|
2221
|
+
path: resolvedPath,
|
|
2222
|
+
relativePath: relativePath || "."
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
async function findExecutable$1(name, pathEnv) {
|
|
2226
|
+
for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
|
|
2227
|
+
const executablePath = join(pathEntry, name);
|
|
2228
|
+
try {
|
|
2229
|
+
await access(executablePath, constants.X_OK);
|
|
2230
|
+
return executablePath;
|
|
2231
|
+
} catch {
|
|
2232
|
+
continue;
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
function runCommand$1(command, args, cwd) {
|
|
2237
|
+
return new Promise((resolveCommand) => {
|
|
2238
|
+
execFile(command, args, {
|
|
2239
|
+
cwd,
|
|
2240
|
+
maxBuffer: 5e6
|
|
2241
|
+
}, (error, stdout, stderr) => {
|
|
2242
|
+
resolveCommand({
|
|
2243
|
+
exitCode: getExitCode$1(error),
|
|
2244
|
+
stdout: String(stdout),
|
|
2245
|
+
stderr: String(stderr)
|
|
2246
|
+
});
|
|
2247
|
+
});
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
function getExitCode$1(error) {
|
|
2251
|
+
if (!error) return 0;
|
|
2252
|
+
if (isRecord$2(error) && typeof error.code === "number") return error.code;
|
|
2253
|
+
return 1;
|
|
2254
|
+
}
|
|
2255
|
+
function isRecord$2(value) {
|
|
2256
|
+
return typeof value === "object" && value !== null;
|
|
2257
|
+
}
|
|
2258
|
+
const grepTool = defineTool({
|
|
2259
|
+
name: "grep",
|
|
2260
|
+
description: "Search text inside the workspace.",
|
|
2261
|
+
prompt: "grep: search text inside the workspace. To use it, reply with only JSON: {\"tool\":\"grep\",\"args\":{\"pattern\":\"function name\",\"path\":\"src\"}}",
|
|
2262
|
+
argsSchema: z.object({
|
|
2263
|
+
pattern: z.string(),
|
|
2264
|
+
path: z.string().optional()
|
|
2265
|
+
}),
|
|
2266
|
+
execute: (context, args) => grepWorkspace(context.workspaceRoot, args, {
|
|
2267
|
+
pathEnv: context.pathEnv,
|
|
2268
|
+
logger: context.logger
|
|
2269
|
+
})
|
|
2270
|
+
});
|
|
2271
|
+
async function grepWorkspace(workspaceRoot, args, options = {}) {
|
|
2272
|
+
const scopedPath = resolveWorkspaceScopedPath(workspaceRoot, args.path ?? ".");
|
|
2273
|
+
const executable = await findSearchExecutable(options.pathEnv);
|
|
2274
|
+
if (!executable) {
|
|
2275
|
+
const warning = "grep could not run because neither rg nor grep is available on PATH.";
|
|
2276
|
+
options.logger?.debug({
|
|
2277
|
+
event: "native_tool_unavailable",
|
|
2278
|
+
tool: "grep",
|
|
2279
|
+
candidates: ["rg", "grep"],
|
|
2280
|
+
path: scopedPath.relativePath
|
|
2281
|
+
}, "native tool unavailable");
|
|
2282
|
+
return {
|
|
2283
|
+
tool: "grep",
|
|
2284
|
+
path: scopedPath.relativePath,
|
|
2285
|
+
content: warning,
|
|
2286
|
+
warning
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
const commandArgs = executable.name === "rg" ? [
|
|
2290
|
+
"--line-number",
|
|
2291
|
+
"--color",
|
|
2292
|
+
"never",
|
|
2293
|
+
"--hidden",
|
|
2294
|
+
"--glob",
|
|
2295
|
+
"!.git/**",
|
|
2296
|
+
"--no-heading",
|
|
2297
|
+
"--",
|
|
2298
|
+
args.pattern,
|
|
2299
|
+
scopedPath.relativePath
|
|
2300
|
+
] : [
|
|
2301
|
+
"-R",
|
|
2302
|
+
"-n",
|
|
2303
|
+
"--",
|
|
2304
|
+
args.pattern,
|
|
2305
|
+
scopedPath.relativePath
|
|
2306
|
+
];
|
|
2307
|
+
options.logger?.debug({
|
|
2308
|
+
event: "native_tool_selected",
|
|
2309
|
+
tool: "grep",
|
|
2310
|
+
nativeTool: executable.name,
|
|
2311
|
+
path: scopedPath.relativePath
|
|
2312
|
+
}, "native tool selected");
|
|
2313
|
+
const result = await runCommand(executable.path, commandArgs, scopedPath.workspaceRoot);
|
|
2314
|
+
options.logger?.debug({
|
|
2315
|
+
event: "grep_command_result",
|
|
2316
|
+
command: executable.name,
|
|
2317
|
+
exitCode: result.exitCode,
|
|
2318
|
+
stdoutLength: result.stdout.length,
|
|
2319
|
+
stderrLength: result.stderr.length
|
|
2320
|
+
}, "grep command result");
|
|
2321
|
+
options.logger?.trace({
|
|
2322
|
+
event: "grep_command_output",
|
|
2323
|
+
command: executable.name,
|
|
2324
|
+
stdout: result.stdout,
|
|
2325
|
+
stderr: result.stderr
|
|
2326
|
+
}, "grep command output");
|
|
2327
|
+
if (result.exitCode > 1) throw new Error(`${executable.name} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`}`);
|
|
2328
|
+
return {
|
|
2329
|
+
tool: "grep",
|
|
2330
|
+
path: scopedPath.relativePath,
|
|
2331
|
+
command: executable.name,
|
|
2332
|
+
content: truncateToolOutput(result.stdout.trimEnd() || "No matches.")
|
|
2333
|
+
};
|
|
2334
|
+
}
|
|
2335
|
+
function resolveWorkspaceScopedPath(workspaceRoot, path) {
|
|
2336
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
2337
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
2338
|
+
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
2339
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`grep can only search inside the workspace: ${path}`);
|
|
2340
|
+
return {
|
|
2341
|
+
workspaceRoot: resolvedWorkspace,
|
|
2342
|
+
path: resolvedPath,
|
|
2343
|
+
relativePath: relativePath || "."
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
async function findSearchExecutable(pathEnv = process.env.PATH ?? "") {
|
|
2347
|
+
for (const name of ["rg", "grep"]) {
|
|
2348
|
+
const executablePath = await findExecutable(name, pathEnv);
|
|
2349
|
+
if (executablePath) return {
|
|
2350
|
+
name,
|
|
2351
|
+
path: executablePath
|
|
2352
|
+
};
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
async function findExecutable(name, pathEnv) {
|
|
2356
|
+
for (const pathEntry of pathEnv.split(delimiter).filter(Boolean)) {
|
|
2357
|
+
const executablePath = join(pathEntry, name);
|
|
2358
|
+
try {
|
|
2359
|
+
await access(executablePath, constants.X_OK);
|
|
2360
|
+
return executablePath;
|
|
2361
|
+
} catch {
|
|
2362
|
+
continue;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
function runCommand(command, args, cwd) {
|
|
2367
|
+
return new Promise((resolveCommand) => {
|
|
2368
|
+
execFile(command, args, {
|
|
2369
|
+
cwd,
|
|
2370
|
+
maxBuffer: 5e6
|
|
2371
|
+
}, (error, stdout, stderr) => {
|
|
2372
|
+
const code = getExitCode(error);
|
|
2373
|
+
resolveCommand({
|
|
2374
|
+
exitCode: error ? code : 0,
|
|
2375
|
+
stdout: String(stdout),
|
|
2376
|
+
stderr: String(stderr)
|
|
2377
|
+
});
|
|
2378
|
+
});
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
function getExitCode(error) {
|
|
2382
|
+
if (isRecord$1(error) && typeof error.code === "number") return error.code;
|
|
2383
|
+
return 1;
|
|
2384
|
+
}
|
|
2385
|
+
function truncateToolOutput(output) {
|
|
2386
|
+
const maxLength = 4e4;
|
|
2387
|
+
if (output.length <= maxLength) return output;
|
|
2388
|
+
return `${output.slice(0, maxLength)}\n\n[truncated]`;
|
|
2389
|
+
}
|
|
2390
|
+
function isRecord$1(value) {
|
|
2391
|
+
return typeof value === "object" && value !== null;
|
|
2392
|
+
}
|
|
2393
|
+
const readFileTool = defineTool({
|
|
2394
|
+
name: "read_file",
|
|
2395
|
+
description: "Read a UTF-8 file inside the workspace.",
|
|
2396
|
+
prompt: "read_file: read a UTF-8 file inside the workspace. To use it, reply with only JSON: {\"tool\":\"read_file\",\"args\":{\"path\":\"package.json\"}}",
|
|
2397
|
+
argsSchema: z.object({ path: z.string() }),
|
|
2398
|
+
execute: (context, args) => readWorkspaceFile(context.workspaceRoot, args.path)
|
|
2399
|
+
});
|
|
2400
|
+
async function readWorkspaceFile(workspaceRoot, path) {
|
|
2401
|
+
const resolvedWorkspace = resolve(workspaceRoot);
|
|
2402
|
+
const resolvedPath = isAbsolute(path) ? resolve(path) : resolve(resolvedWorkspace, path);
|
|
2403
|
+
const relativePath = relative(resolvedWorkspace, resolvedPath);
|
|
2404
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) throw new Error(`read_file can only read files inside the workspace: ${path}`);
|
|
2405
|
+
const content = await readFile(resolvedPath, "utf8");
|
|
2406
|
+
return {
|
|
2407
|
+
tool: "read_file",
|
|
2408
|
+
path: relativePath || ".",
|
|
2409
|
+
content
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
//#endregion
|
|
2413
|
+
//#region src/agent/tools/registry.ts
|
|
2414
|
+
const toolRegistry = {
|
|
2415
|
+
[readFileTool.name]: readFileTool,
|
|
2416
|
+
[grepTool.name]: grepTool,
|
|
2417
|
+
[findFileTool.name]: findFileTool
|
|
2418
|
+
};
|
|
2419
|
+
function isToolName(name) {
|
|
2420
|
+
return name in toolRegistry;
|
|
2421
|
+
}
|
|
2422
|
+
function getToolDefinition(name) {
|
|
2423
|
+
return toolRegistry[name];
|
|
2424
|
+
}
|
|
2425
|
+
function getToolPromptLines() {
|
|
2426
|
+
return Object.values(toolRegistry).map((tool) => tool.prompt);
|
|
2427
|
+
}
|
|
2428
|
+
//#endregion
|
|
2429
|
+
//#region src/agent/tools/executor.ts
|
|
2430
|
+
async function executeToolCall(workspaceRoot, call, options = {}) {
|
|
2431
|
+
const definition = getToolDefinition(call.tool);
|
|
2432
|
+
const startedAt = Date.now();
|
|
2433
|
+
const context = {
|
|
2434
|
+
workspaceRoot,
|
|
2435
|
+
pathEnv: options.pathEnv,
|
|
2436
|
+
logger: options.logger
|
|
2437
|
+
};
|
|
2438
|
+
options.logger?.debug({
|
|
2439
|
+
event: "tool_call",
|
|
2440
|
+
tool: call.tool,
|
|
2441
|
+
args: call.args
|
|
2442
|
+
}, "tool call");
|
|
2443
|
+
try {
|
|
2444
|
+
const result = await definition.execute(context, call.args);
|
|
2445
|
+
const durationMs = Date.now() - startedAt;
|
|
2446
|
+
options.logger?.debug({
|
|
2447
|
+
event: "tool_result",
|
|
2448
|
+
tool: result.tool,
|
|
2449
|
+
path: result.path,
|
|
2450
|
+
command: result.command,
|
|
2451
|
+
warning: result.warning,
|
|
2452
|
+
durationMs,
|
|
2453
|
+
contentLength: result.content.length
|
|
2454
|
+
}, "tool result");
|
|
2455
|
+
options.logger?.trace({
|
|
2456
|
+
event: "tool_result_content",
|
|
2457
|
+
tool: result.tool,
|
|
2458
|
+
path: result.path,
|
|
2459
|
+
content: result.content
|
|
2460
|
+
}, "tool result content");
|
|
2461
|
+
return result;
|
|
2462
|
+
} catch (error) {
|
|
2463
|
+
options.logger?.error({
|
|
2464
|
+
event: "tool_error",
|
|
2465
|
+
tool: call.tool,
|
|
2466
|
+
durationMs: Date.now() - startedAt,
|
|
2467
|
+
err: error
|
|
2468
|
+
}, "tool failed");
|
|
2469
|
+
throw error;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
//#endregion
|
|
2473
|
+
//#region src/agent/tools/parser.ts
|
|
2474
|
+
function parseToolCall(text) {
|
|
2475
|
+
const trimmed = stripJsonFence(text.trim());
|
|
2476
|
+
let value;
|
|
2477
|
+
try {
|
|
2478
|
+
value = JSON.parse(trimmed);
|
|
2479
|
+
} catch {
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
if (!isRecord(value) || typeof value.tool !== "string") return;
|
|
2483
|
+
if (!isToolName(value.tool)) return;
|
|
2484
|
+
const definition = getToolDefinition(value.tool);
|
|
2485
|
+
const parsed = definition.argsSchema.safeParse(value.args);
|
|
2486
|
+
if (!parsed.success) return;
|
|
2487
|
+
return {
|
|
2488
|
+
tool: definition.name,
|
|
2489
|
+
args: parsed.data
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
function stripJsonFence(text) {
|
|
2493
|
+
return text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1] ?? text;
|
|
2494
|
+
}
|
|
2495
|
+
function isRecord(value) {
|
|
2496
|
+
return typeof value === "object" && value !== null;
|
|
2497
|
+
}
|
|
2498
|
+
//#endregion
|
|
2499
|
+
//#region src/agent/prompts.ts
|
|
2500
|
+
function getChatSystemPrompt() {
|
|
2501
|
+
return [
|
|
2502
|
+
"You are Topchester, a plain-spoken terminal coding agent for software engineering work.",
|
|
2503
|
+
"Your job is to turn ordinary user requests into concrete repository work: inspect the codebase, make focused changes when tools allow it, verify the result when possible, and report the outcome clearly.",
|
|
2504
|
+
"",
|
|
2505
|
+
"Working style:",
|
|
2506
|
+
"- Start by understanding the user's intent and the surrounding code before proposing or changing anything non-trivial.",
|
|
2507
|
+
"- Prefer local project evidence over assumptions. Use search and read tools to find relevant files, examples, tests, commands, and conventions.",
|
|
2508
|
+
"- Break multi-step work into a short internal plan. If a planning or todo tool is available, use it for non-trivial tasks and keep it current as work progresses.",
|
|
2509
|
+
"- Use the most specific available tool for the job. Prefer dedicated file/search/edit/test tools over shell commands when both are available.",
|
|
2510
|
+
"- Follow existing project style, naming, dependencies, and test patterns. Do not introduce new libraries or broad abstractions unless the existing code clearly supports that choice.",
|
|
2511
|
+
"- Verify changes with the narrowest relevant test or check when tools allow it. If verification is not possible, say what was not run and why.",
|
|
2512
|
+
"- Do not commit changes unless the user explicitly asks.",
|
|
2513
|
+
"- Keep user-facing responses concise and concrete. Mention changed files, verification, and any remaining risk.",
|
|
2514
|
+
"- Ask a clarifying question only when the missing information blocks useful progress or the safe interpretation is genuinely unclear.",
|
|
2515
|
+
"",
|
|
2516
|
+
"You have these tools available:",
|
|
2517
|
+
...getToolPromptLines(),
|
|
2518
|
+
"",
|
|
2519
|
+
"Tool use:",
|
|
2520
|
+
"- Use read/search tools when the user asks about files, code, symbols, usages, tests, or project behavior.",
|
|
2521
|
+
"- Use edit/write tools when they are available and the user asks you to implement, fix, add, update, or refactor code.",
|
|
2522
|
+
"- Use command/test tools when they are available and you need to inspect the environment, run tests, format, lint, typecheck, or verify behavior.",
|
|
2523
|
+
"- After each tool result, decide the next useful action from the new evidence. Continue until the request is handled or blocked.",
|
|
2524
|
+
"Do not make up file contents or search results."
|
|
2525
|
+
].join("\n");
|
|
2526
|
+
}
|
|
2527
|
+
//#endregion
|
|
2528
|
+
//#region src/agent/runtime.ts
|
|
2529
|
+
var TopchesterAgentRuntime = class {
|
|
2530
|
+
context;
|
|
2531
|
+
constructor(context) {
|
|
2532
|
+
this.context = context;
|
|
2533
|
+
}
|
|
2534
|
+
async checkAgent(abortSignal) {
|
|
2535
|
+
const result = await checkAgentReady(this.context.modelGateway, abortSignal);
|
|
2536
|
+
if (result === "ready") return [agentEvent.assistantMessage("ready"), agentEvent.status("ready")];
|
|
2537
|
+
if (result === "timed-out") return [agentEvent.systemMessage("Agent is taking a while, so I skipped the startup check."), agentEvent.status("ready")];
|
|
2538
|
+
return [agentEvent.systemMessage("Agent did not say it was ready."), agentEvent.status("ready")];
|
|
2539
|
+
}
|
|
2540
|
+
checkKnowledgeBase() {
|
|
2541
|
+
return getKnowledgeStatusEvents(getKnowledgeStatus(this.context.workspaceRoot), this.context.devFlags);
|
|
2542
|
+
}
|
|
2543
|
+
async submitMessage(conversation, message, abortSignal) {
|
|
2544
|
+
const prompt = buildConversationPrompt(conversation, message);
|
|
2545
|
+
const startedAt = Date.now();
|
|
2546
|
+
const result = await this.context.modelGateway.generateText({
|
|
2547
|
+
purpose: "agent.primary",
|
|
2548
|
+
system: getChatSystemPrompt(),
|
|
2549
|
+
prompt,
|
|
2550
|
+
abortSignal
|
|
2551
|
+
});
|
|
2552
|
+
const durationMs = Date.now() - startedAt;
|
|
2553
|
+
const meta = formatAgentMessageMeta(result.modelId, durationMs);
|
|
2554
|
+
const toolCall = parseToolCall(result.text);
|
|
2555
|
+
this.context.logger.debug({
|
|
2556
|
+
event: "model_response",
|
|
2557
|
+
purpose: "agent.primary",
|
|
2558
|
+
modelId: result.modelId,
|
|
2559
|
+
durationMs,
|
|
2560
|
+
textLength: result.text.length,
|
|
2561
|
+
hasToolCall: Boolean(toolCall)
|
|
2562
|
+
}, "model response");
|
|
2563
|
+
this.context.logger.trace({
|
|
2564
|
+
event: "model_response_text",
|
|
2565
|
+
purpose: "agent.primary",
|
|
2566
|
+
modelId: result.modelId,
|
|
2567
|
+
text: result.text
|
|
2568
|
+
}, "model response text");
|
|
2569
|
+
if (toolCall) {
|
|
2570
|
+
const toolResult = await executeToolCall(this.context.workspaceRoot, toolCall, { logger: this.context.logger });
|
|
2571
|
+
const finalStartedAt = Date.now();
|
|
2572
|
+
const finalResult = await this.context.modelGateway.generateText({
|
|
2573
|
+
purpose: "agent.primary",
|
|
2574
|
+
system: getChatSystemPrompt(),
|
|
2575
|
+
prompt: `${prompt}\n\n${formatToolResultForPrompt(toolResult)}\n\nAnswer the user's request using the tool result above. Do not guess.`,
|
|
2576
|
+
abortSignal
|
|
2577
|
+
});
|
|
2578
|
+
const finalModelDurationMs = Date.now() - finalStartedAt;
|
|
2579
|
+
const finalDurationMs = durationMs + finalModelDurationMs;
|
|
2580
|
+
const finalMeta = formatAgentMessageMeta(finalResult.modelId, finalDurationMs);
|
|
2581
|
+
this.context.logger.debug({
|
|
2582
|
+
event: "model_response",
|
|
2583
|
+
purpose: "agent.primary",
|
|
2584
|
+
modelId: finalResult.modelId,
|
|
2585
|
+
durationMs: finalModelDurationMs,
|
|
2586
|
+
totalDurationMs: finalDurationMs,
|
|
2587
|
+
textLength: finalResult.text.length,
|
|
2588
|
+
afterTool: toolCall.tool
|
|
2589
|
+
}, "model response after tool");
|
|
2590
|
+
this.context.logger.trace({
|
|
2591
|
+
event: "model_response_text",
|
|
2592
|
+
purpose: "agent.primary",
|
|
2593
|
+
modelId: finalResult.modelId,
|
|
2594
|
+
afterTool: toolCall.tool,
|
|
2595
|
+
text: finalResult.text
|
|
2596
|
+
}, "model response text after tool");
|
|
2597
|
+
return [
|
|
2598
|
+
agentEvent.toolCall(toolCall, formatToolCallMessage(toolCall)),
|
|
2599
|
+
agentEvent.assistantMessage(finalResult.text.trim() || "I got an empty response from the model.", finalMeta),
|
|
2600
|
+
agentEvent.status("ready")
|
|
2601
|
+
];
|
|
2602
|
+
}
|
|
2603
|
+
return [agentEvent.assistantMessage(result.text.trim() || "I got an empty response from the model.", meta), agentEvent.status("ready")];
|
|
2604
|
+
}
|
|
2605
|
+
async submitSlashCommand(command, onProgress) {
|
|
2606
|
+
const result = await executeSlashCommand(command, {
|
|
2607
|
+
workspaceRoot: this.context.workspaceRoot,
|
|
2608
|
+
modelGateway: this.context.modelGateway,
|
|
2609
|
+
onProgress
|
|
2610
|
+
});
|
|
2611
|
+
return [agentEvent.systemMessage(result.messages.join("\n")), agentEvent.status("ready")];
|
|
2612
|
+
}
|
|
2613
|
+
};
|
|
2614
|
+
function getKnowledgeStatusEvents(status, devFlags = /* @__PURE__ */ new Set()) {
|
|
2615
|
+
const events = [agentEvent.knowledgeStatus(status)];
|
|
2616
|
+
if (devFlags.has("disable-kb-check-modal")) return events;
|
|
2617
|
+
if (!status.kbExists) events.push(agentEvent.choice({
|
|
2618
|
+
tone: "warning",
|
|
2619
|
+
title: "No KB found",
|
|
2620
|
+
body: "Topchester needs a project knowledge base before normal coding can start.",
|
|
2621
|
+
actions: [choiceAction("Create KB now", "/kb init"), choiceAction("Exit")]
|
|
2622
|
+
}));
|
|
2623
|
+
else if (!status.kbIsDirectory) events.push(agentEvent.choice({
|
|
2624
|
+
tone: "warning",
|
|
2625
|
+
title: "KB path is not a folder",
|
|
2626
|
+
body: `This path exists but is not a folder:\n${status.kbPath}`,
|
|
2627
|
+
actions: [choiceAction("Exit")]
|
|
2628
|
+
}));
|
|
2629
|
+
return events;
|
|
2630
|
+
}
|
|
2631
|
+
function formatToolResultForPrompt(result) {
|
|
2632
|
+
const path = result.path ? ` ${JSON.stringify(result.path)}` : "";
|
|
2633
|
+
const command = result.command ? ` via ${result.command}` : "";
|
|
2634
|
+
const warning = result.warning ? `\nWarning: ${result.warning}` : "";
|
|
2635
|
+
return [
|
|
2636
|
+
`Tool result from ${result.tool}${path}${command}:${warning}`,
|
|
2637
|
+
"```",
|
|
2638
|
+
result.content,
|
|
2639
|
+
"```"
|
|
2640
|
+
].join("\n");
|
|
2641
|
+
}
|
|
2642
|
+
function formatToolCallMessage(call) {
|
|
2643
|
+
switch (call.tool) {
|
|
2644
|
+
case "read_file": return `Tool read_file: ${call.args.path}`;
|
|
2645
|
+
case "grep": return `Tool grep: ${call.args.pattern} in ${call.args.path ?? "."}`;
|
|
2646
|
+
case "find_file": return `Tool find_file: ${call.args.query} in ${call.args.path}`;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
function formatAgentMessageMeta(model, durationMs) {
|
|
2650
|
+
return `${model} · ${formatDuration(durationMs)}`;
|
|
2651
|
+
}
|
|
2652
|
+
function formatDuration(durationMs) {
|
|
2653
|
+
const totalSeconds = Math.max(0, durationMs / 1e3);
|
|
2654
|
+
if (totalSeconds < 10) return `${formatNumber(totalSeconds, 1)} sec`;
|
|
2655
|
+
if (totalSeconds < 60) return `${Math.round(totalSeconds)} sec`;
|
|
2656
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
2657
|
+
const seconds = Math.round(totalSeconds % 60);
|
|
2658
|
+
if (seconds === 0) return `${minutes} min`;
|
|
2659
|
+
return `${minutes} min ${seconds} sec`;
|
|
2660
|
+
}
|
|
2661
|
+
function formatNumber(value, fractionDigits) {
|
|
2662
|
+
return value.toLocaleString("en", {
|
|
2663
|
+
minimumFractionDigits: fractionDigits,
|
|
2664
|
+
maximumFractionDigits: fractionDigits
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
//#endregion
|
|
2668
|
+
//#region src/tui/runtime-events.ts
|
|
2669
|
+
function renderRuntimeEvent(event) {
|
|
2670
|
+
switch (event.type) {
|
|
2671
|
+
case "message": return [event.role === "assistant" ? agentMessage(event.text, event.meta) : systemMessage(event.text)];
|
|
2672
|
+
case "tool_call": return [systemMessage(event.label)];
|
|
2673
|
+
case "knowledge_status": return [systemMessage(`KB status: ${formatPathStatus$1(event.status.kbPath, event.status.kbExists, event.status.kbIsDirectory)} (${event.status.kbPathSource})`)];
|
|
2674
|
+
case "choice": return [modalMessage({
|
|
2675
|
+
tone: event.tone,
|
|
2676
|
+
title: event.title,
|
|
2677
|
+
body: event.body,
|
|
2678
|
+
actions: event.actions
|
|
2679
|
+
})];
|
|
2680
|
+
case "status": return [];
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
//#endregion
|
|
2684
|
+
//#region src/tui/terminal.ts
|
|
2685
|
+
function enterAlternateScreen(terminal) {
|
|
2686
|
+
terminal.write("\x1B[?1049h");
|
|
2687
|
+
terminal.clearScreen();
|
|
2688
|
+
}
|
|
2689
|
+
function exitAlternateScreen(terminal) {
|
|
2690
|
+
terminal.write("\x1B[?1049l");
|
|
2691
|
+
}
|
|
2692
|
+
//#endregion
|
|
2693
|
+
//#region src/tui/shell.ts
|
|
2694
|
+
var TopchesterTuiShell = class {
|
|
2695
|
+
context;
|
|
2696
|
+
runtime;
|
|
2697
|
+
constructor(context, runtime) {
|
|
2698
|
+
this.context = context;
|
|
2699
|
+
this.runtime = runtime ?? new TopchesterAgentRuntime(context);
|
|
2700
|
+
}
|
|
2701
|
+
async render() {
|
|
2702
|
+
const messages = getStartupThreadMessages(this.context);
|
|
2703
|
+
const folderName = getFolderName(this.context.workspaceRoot);
|
|
2704
|
+
const modelLabel = getModelLabel(this.context);
|
|
2705
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2706
|
+
console.log(renderStaticLayout(messages, folderName, modelLabel));
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
const terminal = new ProcessTerminal();
|
|
2710
|
+
enterAlternateScreen(terminal);
|
|
2711
|
+
const tui = new TUI(terminal, true);
|
|
2712
|
+
const exit = () => {
|
|
2713
|
+
tui.stop();
|
|
2714
|
+
exitAlternateScreen(terminal);
|
|
2715
|
+
};
|
|
2716
|
+
const app = new ChatLayout(terminal, messages, folderName, modelLabel, () => {
|
|
2717
|
+
exit();
|
|
2718
|
+
process.exit(0);
|
|
2719
|
+
});
|
|
2720
|
+
app.setSubmitMessage((message) => {
|
|
2721
|
+
this.submitChatMessage(app, tui, message);
|
|
2722
|
+
});
|
|
2723
|
+
app.setSubmitCommand((command) => {
|
|
2724
|
+
this.submitSlashCommand(app, tui, command);
|
|
2725
|
+
});
|
|
2726
|
+
tui.addChild(app);
|
|
2727
|
+
tui.setFocus(app);
|
|
2728
|
+
tui.addInputListener(createExitConfirmationInputListener({
|
|
2729
|
+
setNoticeLine: (line) => {
|
|
2730
|
+
app.setNoticeLine(line);
|
|
2731
|
+
},
|
|
2732
|
+
requestRender: () => {
|
|
2733
|
+
tui.requestRender();
|
|
2734
|
+
},
|
|
2735
|
+
exit: () => {
|
|
2736
|
+
exit();
|
|
2737
|
+
process.exit(0);
|
|
2738
|
+
}
|
|
2739
|
+
}));
|
|
2740
|
+
tui.start();
|
|
2741
|
+
this.checkAgent(app, tui);
|
|
2742
|
+
}
|
|
2743
|
+
async checkAgent(app, tui) {
|
|
2744
|
+
const busy = new BusyIndicator(app, tui, {
|
|
2745
|
+
status: "checking agent",
|
|
2746
|
+
promptHint: "press Esc to stop",
|
|
2747
|
+
activities: [
|
|
2748
|
+
"Checking model config...",
|
|
2749
|
+
"Calling agent.fast...",
|
|
2750
|
+
"Waiting for model..."
|
|
2751
|
+
]
|
|
2752
|
+
});
|
|
2753
|
+
const abortController = new AbortController();
|
|
2754
|
+
let cancelled = false;
|
|
2755
|
+
app.setCancelPending(() => {
|
|
2756
|
+
cancelled = true;
|
|
2757
|
+
abortController.abort();
|
|
2758
|
+
});
|
|
2759
|
+
busy.start();
|
|
2760
|
+
tui.requestRender();
|
|
2761
|
+
try {
|
|
2762
|
+
this.applyRuntimeEvents(app, await this.runtime.checkAgent(abortController.signal));
|
|
2763
|
+
} catch (error) {
|
|
2764
|
+
if (cancelled) {
|
|
2765
|
+
app.addMessage(systemMessage("Agent check stopped."));
|
|
2766
|
+
app.setStatus("ready");
|
|
2767
|
+
} else {
|
|
2768
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2769
|
+
app.addMessage(systemMessage(`Agent check failed: ${message}`));
|
|
2770
|
+
app.setStatus("agent check failed");
|
|
2771
|
+
}
|
|
2772
|
+
} finally {
|
|
2773
|
+
app.setCancelPending(void 0);
|
|
2774
|
+
busy.stop();
|
|
2775
|
+
}
|
|
2776
|
+
if (app.isReady()) this.applyRuntimeEvents(app, this.runtime.checkKnowledgeBase());
|
|
2777
|
+
tui.requestRender();
|
|
2778
|
+
}
|
|
2779
|
+
async submitChatMessage(app, tui, message) {
|
|
2780
|
+
const busy = new BusyIndicator(app, tui, {
|
|
2781
|
+
status: "thinking",
|
|
2782
|
+
promptHint: "press Esc to stop",
|
|
2783
|
+
activities: [
|
|
2784
|
+
"Thinking...",
|
|
2785
|
+
"Calling model...",
|
|
2786
|
+
"Writing response..."
|
|
2787
|
+
]
|
|
2788
|
+
});
|
|
2789
|
+
const abortController = new AbortController();
|
|
2790
|
+
let cancelled = false;
|
|
2791
|
+
app.setCancelPending(() => {
|
|
2792
|
+
cancelled = true;
|
|
2793
|
+
abortController.abort();
|
|
2794
|
+
});
|
|
2795
|
+
busy.start();
|
|
2796
|
+
tui.requestRender();
|
|
2797
|
+
try {
|
|
2798
|
+
this.applyRuntimeEvents(app, await this.runtime.submitMessage(app.getConversationTurns(), message, abortController.signal));
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
if (cancelled) {
|
|
2801
|
+
app.addMessage(systemMessage("Response stopped."));
|
|
2802
|
+
app.setStatus("ready");
|
|
2803
|
+
} else {
|
|
2804
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2805
|
+
app.addMessage(systemMessage(`Chat failed: ${errorMessage}`));
|
|
2806
|
+
app.setStatus("chat failed");
|
|
2807
|
+
}
|
|
2808
|
+
} finally {
|
|
2809
|
+
app.setCancelPending(void 0);
|
|
2810
|
+
busy.stop();
|
|
2811
|
+
tui.requestRender();
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
async submitSlashCommand(app, tui, command) {
|
|
2815
|
+
const busy = new BusyIndicator(app, tui, {
|
|
2816
|
+
status: "running command",
|
|
2817
|
+
promptHint: "working...",
|
|
2818
|
+
activities: getSlashCommandActivities(command),
|
|
2819
|
+
activityEveryMs: 5e3
|
|
2820
|
+
});
|
|
2821
|
+
busy.start();
|
|
2822
|
+
tui.requestRender();
|
|
2823
|
+
try {
|
|
2824
|
+
this.applyRuntimeEvents(app, await this.runtime.submitSlashCommand(command, (event) => {
|
|
2825
|
+
busy.setActivity(event.message);
|
|
2826
|
+
}));
|
|
2827
|
+
} catch (error) {
|
|
2828
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2829
|
+
app.addMessage(systemMessage(`Command failed: ${errorMessage}`));
|
|
2830
|
+
app.setStatus("command failed");
|
|
2831
|
+
} finally {
|
|
2832
|
+
busy.stop();
|
|
2833
|
+
tui.requestRender();
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
applyRuntimeEvents(app, events) {
|
|
2837
|
+
for (const event of events) {
|
|
2838
|
+
if (event.type === "status") app.setStatus(event.status);
|
|
2839
|
+
for (const message of renderRuntimeEvent(event)) app.addMessage(message);
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
function getSlashCommandActivities(command) {
|
|
2844
|
+
if (command.startsWith("/kb compile")) return [
|
|
2845
|
+
"Checking project knowledge folders...",
|
|
2846
|
+
"Reading .gitignore files...",
|
|
2847
|
+
"Listing project files...",
|
|
2848
|
+
"Queueing L1 work..."
|
|
2849
|
+
];
|
|
2850
|
+
if (command.startsWith("/kb reset")) return [
|
|
2851
|
+
"Checking project knowledge paths...",
|
|
2852
|
+
"Removing knowledge folder...",
|
|
2853
|
+
"Removing local cache folder..."
|
|
2854
|
+
];
|
|
2855
|
+
return [
|
|
2856
|
+
"Running command...",
|
|
2857
|
+
"Preparing project knowledge folders...",
|
|
2858
|
+
"Writing project knowledge folders..."
|
|
2859
|
+
];
|
|
2860
|
+
}
|
|
2861
|
+
function createExitConfirmationInputListener(options) {
|
|
2862
|
+
const timeoutMs = options.timeoutMs ?? 2500;
|
|
2863
|
+
let exitPending = false;
|
|
2864
|
+
let exitPendingUntil = 0;
|
|
2865
|
+
let clearTimer;
|
|
2866
|
+
const clearExitNotice = () => {
|
|
2867
|
+
clearTimer = void 0;
|
|
2868
|
+
exitPending = false;
|
|
2869
|
+
exitPendingUntil = 0;
|
|
2870
|
+
options.setNoticeLine(void 0);
|
|
2871
|
+
options.requestRender();
|
|
2872
|
+
};
|
|
2873
|
+
return (data) => {
|
|
2874
|
+
if ((isKeyRelease(data) || isKeyRepeat(data)) && matchesKey(data, "ctrl+c")) return { consume: true };
|
|
2875
|
+
if (!matchesKey(data, "ctrl+c")) return;
|
|
2876
|
+
if (exitPending && Date.now() <= exitPendingUntil) {
|
|
2877
|
+
if (clearTimer) {
|
|
2878
|
+
clearTimeout(clearTimer);
|
|
2879
|
+
clearTimer = void 0;
|
|
2880
|
+
}
|
|
2881
|
+
options.exit();
|
|
2882
|
+
return { consume: true };
|
|
2883
|
+
}
|
|
2884
|
+
exitPending = true;
|
|
2885
|
+
exitPendingUntil = Date.now() + timeoutMs;
|
|
2886
|
+
options.setNoticeLine("press Ctrl-C again to exit.");
|
|
2887
|
+
options.requestRender();
|
|
2888
|
+
if (clearTimer) clearTimeout(clearTimer);
|
|
2889
|
+
clearTimer = setTimeout(clearExitNotice, timeoutMs);
|
|
2890
|
+
clearTimer.unref?.();
|
|
2891
|
+
return { consume: true };
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
//#endregion
|
|
2895
|
+
//#region src/cli.ts
|
|
2896
|
+
const program = new Command();
|
|
2897
|
+
program.name("topchester").description("KB-first terminal coding agent").version("0.0.0");
|
|
2898
|
+
program.option("-c, --config <path>", "explicit config file path").option("--workspace <path>", "workspace root", cwd()).option("--dev <flag>", "enable a development flag", collectDevFlag, []);
|
|
2899
|
+
program.action(async () => {
|
|
2900
|
+
await new TopchesterTuiShell(createContextFromOptions()).render();
|
|
2901
|
+
});
|
|
2902
|
+
program.command("dev").description("start local development mode").action(() => {
|
|
2903
|
+
const context = createContextFromOptions();
|
|
2904
|
+
console.log("Topchester local dev mode");
|
|
2905
|
+
printStartupSummary(context);
|
|
2906
|
+
});
|
|
2907
|
+
const kbCommand = program.command("kb").description("knowledge base commands");
|
|
2908
|
+
kbCommand.command("init").description("initialize a project knowledge base").action(async () => {
|
|
2909
|
+
const context = createContextFromOptions();
|
|
2910
|
+
const result = await ui.progress("Preparing project knowledge folders...", (report) => initializeKnowledgeBase(context.workspaceRoot, { onProgress: (event) => report(event.message) }));
|
|
2911
|
+
console.log(formatKnowledgeInitResult(result).join("\n"));
|
|
2912
|
+
});
|
|
2913
|
+
kbCommand.command("compile").description("compile the project knowledge base").action(async () => {
|
|
2914
|
+
const context = createContextFromOptions();
|
|
2915
|
+
const result = await ui.progress("Processing L1 file entries...", (report) => compileKnowledgeBase(context.workspaceRoot, {
|
|
2916
|
+
model: context.modelGateway,
|
|
2917
|
+
requireModel: true,
|
|
2918
|
+
onProgress: (event) => report(event.message)
|
|
2919
|
+
}));
|
|
2920
|
+
console.log(formatKnowledgeCompileResult(result).join("\n"));
|
|
2921
|
+
if (isPartialKnowledgeCompileResult(result)) process.exitCode = 2;
|
|
2922
|
+
});
|
|
2923
|
+
kbCommand.command("reset").description("delete the local project knowledge base and cache").action(async () => {
|
|
2924
|
+
const context = createContextFromOptions();
|
|
2925
|
+
const result = await ui.progress("Resetting project knowledge base...", (report) => resetKnowledgeBase(context.workspaceRoot, { onProgress: (event) => report(event.message) }));
|
|
2926
|
+
console.log(formatKnowledgeResetResult(result).join("\n"));
|
|
2927
|
+
});
|
|
2928
|
+
kbCommand.command("status").description("show project knowledge base status").action(async () => {
|
|
2929
|
+
const context = createContextFromOptions();
|
|
2930
|
+
const status = await ui.spinner("Checking knowledge base...", () => getKnowledgeStatus(context.workspaceRoot));
|
|
2931
|
+
console.log(ui.heading("KB status"));
|
|
2932
|
+
console.log(`${ui.label("workspace")}: ${status.workspaceRoot}`);
|
|
2933
|
+
console.log(`${ui.label("knowledge folder")}: ${formatPathStatus(status.kbPath, status.kbExists, status.kbIsDirectory)} ${ui.label(`(${status.kbPathSource})`)}`);
|
|
2934
|
+
console.log(`${ui.label("local cache folder")}: ${formatPathStatus(status.cachePath, status.cacheExists, status.cacheIsDirectory)} ${ui.label(`(${status.cachePathSource})`)}`);
|
|
2935
|
+
if (!status.kbExists) console.log(`${ui.label("state")}: ${ui.warn("no knowledge base found yet")}`);
|
|
2936
|
+
else if (!status.kbIsDirectory) console.log(`${ui.label("state")}: ${ui.error("knowledge base path is not a folder")}`);
|
|
2937
|
+
else console.log(`${ui.label("state")}: ${ui.ok("knowledge base found")}`);
|
|
2938
|
+
});
|
|
2939
|
+
await program.parseAsync();
|
|
2940
|
+
function printStartupSummary(context) {
|
|
2941
|
+
const assignments = context.config.models?.assignments ?? {};
|
|
2942
|
+
const providers = context.config.models?.providers ?? {};
|
|
2943
|
+
console.log(`workspace: ${context.workspaceRoot}`);
|
|
2944
|
+
console.log(`default model purpose: ${context.config.models?.defaultPurpose ?? "agent.primary"}`);
|
|
2945
|
+
if (context.devFlags.size > 0) console.log(`dev flags: ${[...context.devFlags].join(", ")}`);
|
|
2946
|
+
if (context.logFilePath) console.log(`log file: ${context.logFilePath}`);
|
|
2947
|
+
if (Object.keys(assignments).length === 0) console.log("model assignments: none configured");
|
|
2948
|
+
else {
|
|
2949
|
+
console.log("model assignments:");
|
|
2950
|
+
for (const [purpose, model] of Object.entries(assignments)) {
|
|
2951
|
+
const provider = model.provider ? ` [${model.provider}]` : "";
|
|
2952
|
+
console.log(` ${purpose}: ${model.name}${provider}`);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
const namedProviders = Object.entries(providers).filter(([providerId]) => providerId !== "default");
|
|
2956
|
+
if (namedProviders.length === 0) console.log("providers: none configured");
|
|
2957
|
+
else {
|
|
2958
|
+
console.log("providers:");
|
|
2959
|
+
if (typeof providers.default === "string") console.log(` default: ${providers.default}`);
|
|
2960
|
+
for (const [providerId, provider] of namedProviders) {
|
|
2961
|
+
if (typeof provider === "string") continue;
|
|
2962
|
+
const auth = provider.apiKeyEnv ? `env:${provider.apiKeyEnv}` : provider.apiKey ? "inline" : "none";
|
|
2963
|
+
console.log(` ${providerId}: ${provider.type} ${provider.baseURL} auth=${auth}`);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
function createContextFromOptions() {
|
|
2968
|
+
const options = program.opts();
|
|
2969
|
+
return createAppContext({
|
|
2970
|
+
workspaceRoot: options.workspace,
|
|
2971
|
+
configPath: options.config && (isAbsolute(options.config) ? options.config : resolve(cwd(), options.config)),
|
|
2972
|
+
devFlags: options.dev
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
function collectDevFlag(flag, flags) {
|
|
2976
|
+
return [...flags, flag];
|
|
2977
|
+
}
|
|
2978
|
+
function formatPathStatus(path, exists, isDirectory) {
|
|
2979
|
+
if (!exists) return `${path} ${ui.warn("[missing]")}`;
|
|
2980
|
+
if (!isDirectory) return `${path} ${ui.error("[not a folder]")}`;
|
|
2981
|
+
return `${path} ${ui.ok("[ok]")}`;
|
|
2982
|
+
}
|
|
2983
|
+
//#endregion
|
|
2984
|
+
export {};
|
|
2985
|
+
|
|
2986
|
+
//# sourceMappingURL=cli.mjs.map
|