@a-t-h-i/bot-lobby 0.6.59 → 0.6.60
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/package.json +7 -7
- package/prompts/global.md +2 -1
- package/prompts/worker.md +2 -9
- package/src/classifier/files.ts +18 -6
- package/src/classifier/instance.ts +2 -2
- package/src/classifier/knowledge.ts +124 -1
- package/src/desk/board-client.ts +79 -12
- package/src/desk/board.ts +63 -52
- package/src/desk/delivery.ts +243 -0
- package/src/desk/ipc.ts +34 -1
- package/src/desk/lock.ts +81 -0
- package/src/desk/session.ts +85 -18
- package/src/execution/agent-runner.ts +10 -2
- package/src/execution/delegation.ts +160 -7
- package/src/execution/output-artifacts.ts +176 -0
- package/src/index.ts +3 -0
- package/src/knowledge/bookmarks.ts +452 -0
- package/src/knowledge/catalog-tools.ts +145 -0
- package/src/knowledge/catalogue.ts +550 -0
- package/src/knowledge/file-notes.ts +16 -6
- package/src/knowledge/repo-map.ts +67 -2
- package/src/knowledge/retrieval.ts +431 -0
- package/src/master/master.ts +132 -21
- package/src/pi/events.ts +31 -2
- package/src/pi/tools.ts +12 -1
- package/src/prompts/compiler.ts +24 -7
- package/src/schemas/configuration.ts +4 -0
- package/src/schemas/task.ts +32 -0
- package/src/state/checkpoint.ts +199 -0
- package/src/state/persistence.ts +138 -3
- package/src/workflow/workflow.ts +62 -11
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared knowledge glossary: a derived, persisted catalogue over the
|
|
3
|
+
* existing authoritative sources — the agents' knowledge files (knowledge,
|
|
4
|
+
* standards, decisions, completed tasks) and the scouts' source-backed file
|
|
5
|
+
* notes. The files stay canonical; the catalogue only records where each
|
|
6
|
+
* passage is, what it is about, and whether it can still be found the way it
|
|
7
|
+
* was indexed. Nothing here writes to knowledge.
|
|
8
|
+
*
|
|
9
|
+
* Identity: a passage is its text, not its line numbers. A refresh matches
|
|
10
|
+
* passages by exact text and occurrence, so unrelated insertions and moves
|
|
11
|
+
* keep their reference; a unique edited match keeps its reference marked
|
|
12
|
+
* changed; anything deleted, duplicated or ambiguous goes stale and is never
|
|
13
|
+
* silently replaced. A corrupt catalogue is rebuilt under a new generation
|
|
14
|
+
* with fresh reference ids, so old persisted references cannot redirect.
|
|
15
|
+
*
|
|
16
|
+
* Refresh is incremental: sources are stat'ed and only changed ones are
|
|
17
|
+
* re-read; the whole walk is bounded and cancellable, and reports complete,
|
|
18
|
+
* partial, stale or unavailable coverage. Writes take a fail-closed lock
|
|
19
|
+
* (stale locks from a crashed writer are broken only once clearly old) and
|
|
20
|
+
* land through a unique temp file and an atomic rename.
|
|
21
|
+
*/
|
|
22
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
23
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { dirname, join, sep } from "node:path";
|
|
25
|
+
import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "./paths.ts";
|
|
26
|
+
import { parseEntries, type EntryKind } from "./edit.ts";
|
|
27
|
+
import { fileNotesPath, readFileNotes } from "./file-notes.ts";
|
|
28
|
+
|
|
29
|
+
export const CATALOGUE_VERSION = 1;
|
|
30
|
+
/** Longest the catalogue grows; the stale entries go first. */
|
|
31
|
+
export const MAX_CATALOGUE_ENTRIES = 2000;
|
|
32
|
+
export const MAX_TITLE_CHARS = 120;
|
|
33
|
+
export const MAX_DESCRIPTION_CHARS = 240;
|
|
34
|
+
export const MAX_TOPICS = 12;
|
|
35
|
+
/** How long a refresh walks before it reports partial coverage. */
|
|
36
|
+
export const REFRESH_BUDGET_MS = 1500;
|
|
37
|
+
/** The default character budget for a search page and for an opened passage response. */
|
|
38
|
+
export const RESPONSE_BUDGET_CHARS = 4000;
|
|
39
|
+
const LOCK_WAIT_MS = 2000;
|
|
40
|
+
/** A lock older than this was left by a crashed writer and is broken. */
|
|
41
|
+
const STALE_LOCK_MS = 30_000;
|
|
42
|
+
/**
|
|
43
|
+
* A reference id, matched as one exact token, never split on hyphens: either
|
|
44
|
+
* `K-<generation uuid>-<counter>` (every id minted now, so ids from different
|
|
45
|
+
* catalogue generations can never collide) or a legacy `K-<counter>` preserved
|
|
46
|
+
* from an older catalogue.
|
|
47
|
+
*/
|
|
48
|
+
export const REFERENCE_ID_PATTERN = /K-(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-)?\d+/gi;
|
|
49
|
+
const GENERATION_REF = /^K-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(\d+)$/i;
|
|
50
|
+
const LEGACY_REF = /^K-(\d+)$/i;
|
|
51
|
+
|
|
52
|
+
/** True when the whole string is a well-formed reference id in either namespace. */
|
|
53
|
+
export function isReferenceId(text: string): boolean {
|
|
54
|
+
return GENERATION_REF.test(text) || LEGACY_REF.test(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The generation a generation-tagged reference id was minted in; legacy ids carry none. */
|
|
58
|
+
export function generationOfRef(ref: string): string | undefined {
|
|
59
|
+
return GENERATION_REF.exec(ref)?.[1];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A passage whose text looks like it carries a credential is never indexed. */
|
|
63
|
+
const SECRET_PATTERN = /(api[-_]?key|secret|password|passwd|credential|private[- ]key)\s*[:=]/i;
|
|
64
|
+
|
|
65
|
+
export type CatalogueKind = "knowledge" | "standard" | "decision" | "completed" | "finding";
|
|
66
|
+
export type CatalogueOrigin = "knowledge-file" | "file-note";
|
|
67
|
+
export type CatalogueScope = "project" | "task";
|
|
68
|
+
export type Freshness = "current" | "changed" | "stale";
|
|
69
|
+
export type Coverage = "complete" | "partial" | "stale" | "unavailable";
|
|
70
|
+
|
|
71
|
+
export interface PassageLocator {
|
|
72
|
+
/** The nearest heading above the passage, when the file has one. */
|
|
73
|
+
heading?: string;
|
|
74
|
+
/** Where the passage sat when indexed; navigation hints, never identity. */
|
|
75
|
+
start: number;
|
|
76
|
+
end: number;
|
|
77
|
+
/** The passage's exact text — what identity is reconciled against. */
|
|
78
|
+
text: string;
|
|
79
|
+
/** Which of the passages with this same text it is. */
|
|
80
|
+
occurrence: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A repository file the passage's truth depends on, with its hash when recorded. */
|
|
84
|
+
export interface CodeDependency {
|
|
85
|
+
path: string;
|
|
86
|
+
hash: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface CatalogueEntry {
|
|
90
|
+
id: string;
|
|
91
|
+
sourceKey: string;
|
|
92
|
+
title: string;
|
|
93
|
+
description: string;
|
|
94
|
+
kind: CatalogueKind;
|
|
95
|
+
agent: string;
|
|
96
|
+
origin: CatalogueOrigin;
|
|
97
|
+
scope: CatalogueScope;
|
|
98
|
+
locator: PassageLocator;
|
|
99
|
+
/** Related paths, aliases and symbols the passage names, when any. */
|
|
100
|
+
topics: string[];
|
|
101
|
+
freshness: Freshness;
|
|
102
|
+
/** Bumped when the passage changed under the same id. */
|
|
103
|
+
revision: number;
|
|
104
|
+
/** Reference ids this entry or its notes record as conflicting or superseding. */
|
|
105
|
+
conflicts: string[];
|
|
106
|
+
codeDependency?: CodeDependency;
|
|
107
|
+
staleAt?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CatalogueSource {
|
|
111
|
+
key: string;
|
|
112
|
+
file: string;
|
|
113
|
+
agent: string;
|
|
114
|
+
kind: CatalogueKind;
|
|
115
|
+
origin: CatalogueOrigin;
|
|
116
|
+
fingerprint: string;
|
|
117
|
+
size: number;
|
|
118
|
+
mtimeMs: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface Catalogue {
|
|
122
|
+
version: number;
|
|
123
|
+
generation: string;
|
|
124
|
+
nextId: number;
|
|
125
|
+
revision: number;
|
|
126
|
+
builtAt: string;
|
|
127
|
+
sources: Record<string, CatalogueSource>;
|
|
128
|
+
entries: CatalogueEntry[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface SourceRef {
|
|
132
|
+
key: string;
|
|
133
|
+
file: string;
|
|
134
|
+
agent: KnowledgeAgent;
|
|
135
|
+
kind: CatalogueKind;
|
|
136
|
+
origin: CatalogueOrigin;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface Passage {
|
|
140
|
+
text: string;
|
|
141
|
+
start: number;
|
|
142
|
+
end: number;
|
|
143
|
+
kind: EntryKind;
|
|
144
|
+
heading?: string;
|
|
145
|
+
occurrence: number;
|
|
146
|
+
agent?: string;
|
|
147
|
+
topics?: string[];
|
|
148
|
+
codeDependency?: CodeDependency;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface RefreshOptions {
|
|
152
|
+
now?: Date;
|
|
153
|
+
budgetMs?: number;
|
|
154
|
+
signal?: AbortSignal;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface RefreshResult {
|
|
158
|
+
catalogue: Catalogue;
|
|
159
|
+
coverage: Coverage;
|
|
160
|
+
/** Passages not indexed because their text looks like it carries a secret. */
|
|
161
|
+
skippedSecrets: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sleep(ms: number): void {
|
|
165
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function cataloguePath(dataRoot: string): string {
|
|
169
|
+
return join(dataRoot, "cache", "knowledge-catalogue.json");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The persisted catalogue, or undefined when it is missing, torn or corrupt. */
|
|
173
|
+
export function loadCatalogue(dataRoot: string): Catalogue | undefined {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(readFileSync(cataloguePath(dataRoot), "utf8")) as Partial<Catalogue>;
|
|
176
|
+
const ok = parsed.version === CATALOGUE_VERSION
|
|
177
|
+
&& typeof parsed.generation === "string"
|
|
178
|
+
&& typeof parsed.nextId === "number"
|
|
179
|
+
&& typeof parsed.revision === "number"
|
|
180
|
+
&& typeof parsed.sources === "object"
|
|
181
|
+
&& Array.isArray(parsed.entries);
|
|
182
|
+
return ok ? (parsed as Catalogue) : undefined;
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function canonical(path: string): string | undefined {
|
|
189
|
+
try {
|
|
190
|
+
return realpathSync(path);
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function within(root: string, file: string): boolean {
|
|
197
|
+
const base = root.endsWith(sep) ? root : root + sep;
|
|
198
|
+
return file === root || file.startsWith(base);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The canonical file of a source, or undefined when it escapes its data tree (a symlink out, say). */
|
|
202
|
+
function canonicalSource(dataRoot: string, path: string): string | undefined {
|
|
203
|
+
const file = canonical(path);
|
|
204
|
+
const root = canonical(dataRoot);
|
|
205
|
+
return file && root && within(root, file) ? file : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function kindOf(file: string): CatalogueKind {
|
|
209
|
+
if (file === "decisions.md") return "decision";
|
|
210
|
+
if (file === "completed-tasks.md") return "completed";
|
|
211
|
+
return file === "knowledge.md" ? "knowledge" : "standard";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The accepted sources: every agent's four knowledge files in every data
|
|
216
|
+
* tree (the live one and the read-only pre-rename ones). Nothing else — no
|
|
217
|
+
* repository contents, no session transcripts, no scratchpads — is indexed.
|
|
218
|
+
*/
|
|
219
|
+
export function knowledgeSourceRefs(dataRoots: readonly string[]): SourceRef[] {
|
|
220
|
+
const refs: SourceRef[] = [];
|
|
221
|
+
for (const dataRoot of dataRoots) {
|
|
222
|
+
for (const agent of Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]) {
|
|
223
|
+
for (const file of KNOWLEDGE_FILES[agent]) {
|
|
224
|
+
const path = canonicalSource(dataRoot, join(knowledgeDir(dataRoot, agent), file));
|
|
225
|
+
if (path) refs.push({ key: `kf:${path}`, file: path, agent, kind: kindOf(file), origin: "knowledge-file" });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return refs;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The scouts' file notes, one source with one passage per note. */
|
|
233
|
+
export function fileNoteSourceRef(dataRoot: string | undefined): SourceRef | undefined {
|
|
234
|
+
if (!dataRoot) return undefined;
|
|
235
|
+
const path = canonicalSource(dataRoot, fileNotesPath(dataRoot));
|
|
236
|
+
return path ? { key: `fn:${path}`, file: path, agent: "master", kind: "finding", origin: "file-note" } : undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Every accepted source for these data trees, knowledge files first. */
|
|
240
|
+
export function allSourceRefs(dataRoots: readonly string[]): SourceRef[] {
|
|
241
|
+
const notes = fileNoteSourceRef(dataRoots[0]);
|
|
242
|
+
return notes ? [...knowledgeSourceRefs(dataRoots), notes] : knowledgeSourceRefs(dataRoots);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A source's stat and fingerprint, or undefined when it is missing or not a file. Unchanged sources are not re-read. */
|
|
246
|
+
function sourceState(file: string, known?: CatalogueSource): { fingerprint: string; size: number; mtimeMs: number } | undefined {
|
|
247
|
+
try {
|
|
248
|
+
const info = statSync(file);
|
|
249
|
+
if (!info.isFile()) return undefined;
|
|
250
|
+
if (known && known.size === info.size && known.mtimeMs === info.mtimeMs) {
|
|
251
|
+
return { fingerprint: known.fingerprint, size: info.size, mtimeMs: info.mtimeMs };
|
|
252
|
+
}
|
|
253
|
+
const fingerprint = createHash("sha1").update(readFileSync(file)).digest("hex");
|
|
254
|
+
return { fingerprint, size: info.size, mtimeMs: info.mtimeMs };
|
|
255
|
+
} catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A knowledge file's passages: bullets, paragraphs and inner headings, each with the heading above it. */
|
|
261
|
+
function filePassages(content: string): Passage[] {
|
|
262
|
+
const passages: Passage[] = [];
|
|
263
|
+
let heading: string | undefined;
|
|
264
|
+
for (const entry of parseEntries(content)) {
|
|
265
|
+
if (entry.kind === "heading") {
|
|
266
|
+
if (!entry.text.startsWith("# ")) heading = entry.text.replace(/^#+\s*/, "# ");
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
passages.push({ text: entry.text, start: entry.start, end: entry.end, kind: entry.kind, occurrence: entry.occurrence, ...(heading ? { heading } : {}) });
|
|
270
|
+
}
|
|
271
|
+
return passages;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** One file note as a passage: the note is the content; the path it is about is the code dependency. */
|
|
275
|
+
export function fileNotePassages(dataRoot: string): Passage[] {
|
|
276
|
+
return Object.entries(readFileNotes(dataRoot)).map(([path, note]) => ({
|
|
277
|
+
text: `- ${path}: ${note.note}`,
|
|
278
|
+
start: 0,
|
|
279
|
+
end: 1,
|
|
280
|
+
kind: "bullet" as const,
|
|
281
|
+
occurrence: 0,
|
|
282
|
+
agent: note.domain,
|
|
283
|
+
topics: [path],
|
|
284
|
+
codeDependency: { path, hash: note.hash },
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function titleOf(text: string): string {
|
|
289
|
+
const first = text.split("\n")[0] ?? "";
|
|
290
|
+
return first.replace(/^#+\s*/, "").replace(/^[-*+]\s*/, "").trim().slice(0, MAX_TITLE_CHARS);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Paths and backticked symbols the passage names, bounded; aliases the ranking can use. */
|
|
294
|
+
function topicsOf(text: string): string[] {
|
|
295
|
+
const found = new Set<string>();
|
|
296
|
+
for (const match of text.matchAll(/`([^`]+)`/g)) found.add(match[1]!.trim());
|
|
297
|
+
for (const match of text.matchAll(/[\w./-]+\.(?:ts|tsx|js|jsx|json|md|sh)\b/g)) found.add(match[0]);
|
|
298
|
+
return [...found].slice(0, MAX_TOPICS);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Reference ids the passage itself records as related conflicts or supersessions. */
|
|
302
|
+
function mentionedIds(text: string, self: string): string[] {
|
|
303
|
+
return [...new Set((text.match(REFERENCE_ID_PATTERN) ?? []).filter((id) => id !== self))].slice(0, 8);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function newEntry(catalogue: Catalogue, ref: SourceRef, passage: Passage): CatalogueEntry {
|
|
307
|
+
const id = `K-${catalogue.generation}-${catalogue.nextId}`;
|
|
308
|
+
catalogue.nextId += 1;
|
|
309
|
+
const clean = passage.text.replace(/\s+/g, " ").trim();
|
|
310
|
+
return {
|
|
311
|
+
id,
|
|
312
|
+
sourceKey: ref.key,
|
|
313
|
+
title: titleOf(passage.text),
|
|
314
|
+
description: clean.slice(0, MAX_DESCRIPTION_CHARS),
|
|
315
|
+
kind: ref.kind,
|
|
316
|
+
agent: passage.agent ?? ref.agent,
|
|
317
|
+
origin: ref.origin,
|
|
318
|
+
scope: "project",
|
|
319
|
+
locator: { start: passage.start, end: passage.end, text: passage.text, occurrence: passage.occurrence, ...(passage.heading ? { heading: passage.heading } : {}) },
|
|
320
|
+
topics: passage.topics ?? topicsOf(passage.text),
|
|
321
|
+
freshness: "current",
|
|
322
|
+
revision: 1,
|
|
323
|
+
conflicts: mentionedIds(passage.text, id),
|
|
324
|
+
...(passage.codeDependency ? { codeDependency: passage.codeDependency } : {}),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Carry an id over to a passage found again (or uniquely re-matched after an edit). */
|
|
329
|
+
function carried(entry: CatalogueEntry, passage: Passage, freshness: Exclude<Freshness, "stale">): CatalogueEntry {
|
|
330
|
+
const revision = freshness === "changed" ? entry.revision + 1 : entry.revision;
|
|
331
|
+
const clean = passage.text.replace(/\s+/g, " ").trim();
|
|
332
|
+
return {
|
|
333
|
+
...entry,
|
|
334
|
+
title: titleOf(passage.text),
|
|
335
|
+
description: clean.slice(0, MAX_DESCRIPTION_CHARS),
|
|
336
|
+
locator: { start: passage.start, end: passage.end, text: passage.text, occurrence: passage.occurrence, ...(passage.heading ? { heading: passage.heading } : {}) },
|
|
337
|
+
topics: passage.topics ?? topicsOf(passage.text),
|
|
338
|
+
freshness,
|
|
339
|
+
revision,
|
|
340
|
+
conflicts: mentionedIds(passage.text, entry.id),
|
|
341
|
+
staleAt: undefined,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function staled(entry: CatalogueEntry, now: Date): CatalogueEntry {
|
|
346
|
+
return { ...entry, freshness: "stale", staleAt: now.toISOString() };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function matchKey(text: string, occurrence: number): string {
|
|
350
|
+
return `${occurrence}\u0000${text}`;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
interface ReconcileContext {
|
|
354
|
+
now: Date;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Reconcile one changed source's passages against its previous entries.
|
|
359
|
+
* Exact text + occurrence keeps the id; a unique edited-title match keeps it
|
|
360
|
+
* marked changed; the rest of the old entries go stale and the rest of the
|
|
361
|
+
* passages get fresh ids, so duplicates and guesses never cross-link.
|
|
362
|
+
*/
|
|
363
|
+
function reconcile(prior: CatalogueEntry[], passages: readonly Passage[], catalogue: Catalogue, ref: SourceRef, ctx: ReconcileContext): CatalogueEntry[] {
|
|
364
|
+
const byText = new Map(prior.map((entry) => [matchKey(entry.locator.text, entry.locator.occurrence), entry]));
|
|
365
|
+
const consumed = new Set<string>();
|
|
366
|
+
const next: CatalogueEntry[] = [];
|
|
367
|
+
const unmatched: Passage[] = [];
|
|
368
|
+
for (const passage of passages) {
|
|
369
|
+
const exact = byText.get(matchKey(passage.text, passage.occurrence));
|
|
370
|
+
if (exact && !consumed.has(exact.id)) {
|
|
371
|
+
consumed.add(exact.id);
|
|
372
|
+
next.push(carried(exact, passage, "current"));
|
|
373
|
+
} else unmatched.push(passage);
|
|
374
|
+
}
|
|
375
|
+
const edited = editedMatches(unmatched, prior.filter((entry) => !consumed.has(entry.id)), consumed);
|
|
376
|
+
for (const passage of unmatched) {
|
|
377
|
+
const match = edited.get(passage);
|
|
378
|
+
if (match) next.push(carried(match, passage, "changed"));
|
|
379
|
+
else next.push(newEntry(catalogue, ref, passage));
|
|
380
|
+
}
|
|
381
|
+
for (const entry of prior) if (!consumed.has(entry.id)) next.push(staled(entry, ctx.now));
|
|
382
|
+
return next;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Passages and previous entries that share a unique first line keep the entry's id, marked changed. */
|
|
386
|
+
function editedMatches(passages: readonly Passage[], candidates: readonly CatalogueEntry[], consumed: Set<string>): Map<Passage, CatalogueEntry> {
|
|
387
|
+
const byTitle = new Map<string, CatalogueEntry[]>();
|
|
388
|
+
for (const entry of candidates) {
|
|
389
|
+
const title = titleOf(entry.locator.text);
|
|
390
|
+
byTitle.set(title, [...(byTitle.get(title) ?? []), entry]);
|
|
391
|
+
}
|
|
392
|
+
const byPassageTitle = new Map<string, Passage[]>();
|
|
393
|
+
for (const passage of passages) {
|
|
394
|
+
const title = titleOf(passage.text);
|
|
395
|
+
byPassageTitle.set(title, [...(byPassageTitle.get(title) ?? []), passage]);
|
|
396
|
+
}
|
|
397
|
+
const matches = new Map<Passage, CatalogueEntry>();
|
|
398
|
+
for (const [title, group] of byPassageTitle) {
|
|
399
|
+
const priors = (byTitle.get(title) ?? []).filter((entry) => !consumed.has(entry.id));
|
|
400
|
+
if (group.length === 1 && priors.length === 1) {
|
|
401
|
+
matches.set(group[0]!, priors[0]!);
|
|
402
|
+
consumed.add(priors[0]!.id);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return matches;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
interface WalkContext extends ReconcileContext {
|
|
409
|
+
deadline: number;
|
|
410
|
+
signal?: AbortSignal;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
interface Walk {
|
|
414
|
+
catalogue: Catalogue;
|
|
415
|
+
coverage: Coverage;
|
|
416
|
+
skippedSecrets: number;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function stopped(ctx: WalkContext): boolean {
|
|
420
|
+
return Date.now() > ctx.deadline || (ctx.signal?.aborted ?? false);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Every entry of a source that is gone (or symlinked away) becomes stale; its source record is dropped. */
|
|
424
|
+
function markSourceMissing(catalogue: Catalogue, key: string, now: Date): void {
|
|
425
|
+
delete catalogue.sources[key];
|
|
426
|
+
catalogue.entries = catalogue.entries.map((entry) => (entry.sourceKey === key && entry.freshness !== "stale" ? staled(entry, now) : entry));
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function indexSource(catalogue: Catalogue, ref: SourceRef, state: { fingerprint: string; size: number; mtimeMs: number }, ctx: WalkContext): number {
|
|
430
|
+
const content = readFileSync(ref.file, "utf8");
|
|
431
|
+
const parsed = ref.origin === "file-note" ? fileNotePassages(dirname(dirname(ref.file))) : filePassages(content);
|
|
432
|
+
const kept = parsed.filter((passage) => !SECRET_PATTERN.test(passage.text));
|
|
433
|
+
const prior = catalogue.entries.filter((entry) => entry.sourceKey === ref.key);
|
|
434
|
+
const reconciled = reconcile(prior, kept, catalogue, ref, ctx);
|
|
435
|
+
const others = catalogue.entries.filter((entry) => entry.sourceKey !== ref.key);
|
|
436
|
+
catalogue.entries = [...others, ...reconciled];
|
|
437
|
+
catalogue.sources[ref.key] = { key: ref.key, file: ref.file, agent: ref.agent, kind: ref.kind, origin: ref.origin, fingerprint: state.fingerprint, size: state.size, mtimeMs: state.mtimeMs };
|
|
438
|
+
return parsed.length - kept.length;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Walk every accepted source, re-reading only the changed ones, within the budget. */
|
|
442
|
+
function walkSources(dataRoots: readonly string[], catalogue: Catalogue, ctx: WalkContext): Walk {
|
|
443
|
+
const walk: Walk = { catalogue, coverage: "complete", skippedSecrets: 0 };
|
|
444
|
+
const seen = new Set<string>();
|
|
445
|
+
for (const ref of allSourceRefs(dataRoots)) {
|
|
446
|
+
if (stopped(ctx)) return { ...walk, coverage: "partial" };
|
|
447
|
+
seen.add(ref.key);
|
|
448
|
+
const state = sourceState(ref.file, catalogue.sources[ref.key]);
|
|
449
|
+
if (!state) {
|
|
450
|
+
markSourceMissing(walk.catalogue, ref.key, ctx.now);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (walk.catalogue.sources[ref.key]?.fingerprint === state.fingerprint) continue;
|
|
454
|
+
walk.skippedSecrets += indexSource(walk.catalogue, ref, state, ctx);
|
|
455
|
+
}
|
|
456
|
+
for (const key of Object.keys(walk.catalogue.sources)) if (!seen.has(key)) markSourceMissing(walk.catalogue, key, ctx.now);
|
|
457
|
+
return walk;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Stale entries go first, then the lowest ids, until the catalogue is within its cap. */
|
|
461
|
+
function trimCatalogue(catalogue: Catalogue): Catalogue {
|
|
462
|
+
const over = catalogue.entries.length - MAX_CATALOGUE_ENTRIES;
|
|
463
|
+
if (over <= 0) return catalogue;
|
|
464
|
+
const order = (entry: CatalogueEntry) => `${entry.staleAt ? `0${entry.staleAt}` : "1"}\u0000${entry.id.padStart(12, "0")}`;
|
|
465
|
+
const dropped = new Set([...catalogue.entries].sort((a, b) => order(a).localeCompare(order(b))).slice(0, over).map((entry) => entry.id));
|
|
466
|
+
return { ...catalogue, entries: catalogue.entries.filter((entry) => !dropped.has(entry.id)) };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function saveCatalogue(dataRoot: string, catalogue: Catalogue): void {
|
|
470
|
+
const path = cataloguePath(dataRoot);
|
|
471
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
472
|
+
const tmp = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
473
|
+
writeFileSync(tmp, `${JSON.stringify(catalogue)}\n`);
|
|
474
|
+
renameSync(tmp, path);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Hold the catalogue's lock and run `write`, or fail closed: a writer that
|
|
479
|
+
* cannot get the lock in time throws instead of writing anyway. Only a lock
|
|
480
|
+
* clearly older than any live writer (a crashed one's) is broken.
|
|
481
|
+
*/
|
|
482
|
+
function withLock<T>(path: string, write: () => T): T {
|
|
483
|
+
const lock = `${path}.lock`;
|
|
484
|
+
const until = Date.now() + LOCK_WAIT_MS;
|
|
485
|
+
for (;;) {
|
|
486
|
+
try {
|
|
487
|
+
mkdirSync(lock);
|
|
488
|
+
break;
|
|
489
|
+
} catch {
|
|
490
|
+
try {
|
|
491
|
+
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) rmSync(lock, { recursive: true, force: true });
|
|
492
|
+
} catch {
|
|
493
|
+
// Gone already.
|
|
494
|
+
}
|
|
495
|
+
if (Date.now() > until) throw new Error("the knowledge catalogue is busy; try again");
|
|
496
|
+
sleep(5);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
return write();
|
|
501
|
+
} finally {
|
|
502
|
+
rmSync(lock, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Persist the catalogue under its lock; a caller must not write without it. */
|
|
507
|
+
export function persistCatalogue(dataRoot: string, catalogue: Catalogue): void {
|
|
508
|
+
withLock(cataloguePath(dataRoot), () => saveCatalogue(dataRoot, catalogue));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** A fresh catalogue starts a new generation namespace; its counters begin at 1 inside it. */
|
|
512
|
+
function emptyCatalogue(_dataRoot: string, now: Date): Catalogue {
|
|
513
|
+
return {
|
|
514
|
+
version: CATALOGUE_VERSION,
|
|
515
|
+
generation: randomUUID(),
|
|
516
|
+
nextId: 1,
|
|
517
|
+
revision: 0,
|
|
518
|
+
builtAt: now.toISOString(),
|
|
519
|
+
sources: {},
|
|
520
|
+
entries: [],
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function finishRefresh(walk: Walk, dataRoot: string, now: Date): RefreshResult {
|
|
525
|
+
const next = trimCatalogue({ ...walk.catalogue, revision: walk.catalogue.revision + 1, builtAt: now.toISOString() });
|
|
526
|
+
try {
|
|
527
|
+
persistCatalogue(dataRoot, next);
|
|
528
|
+
} catch {
|
|
529
|
+
return { catalogue: walk.catalogue, coverage: "stale", skippedSecrets: walk.skippedSecrets };
|
|
530
|
+
}
|
|
531
|
+
const empty = next.entries.length === 0 && Object.keys(next.sources).length === 0;
|
|
532
|
+
return { catalogue: next, coverage: empty ? "unavailable" : "complete", skippedSecrets: walk.skippedSecrets };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Bring the catalogue up to date with the sources: stat every accepted
|
|
537
|
+
* source, re-read and re-reconcile only the changed ones, and persist the
|
|
538
|
+
* result. Bounded and cancellable; a budget or lock that gives out is
|
|
539
|
+
* reported as partial or stale coverage, never as a silent full refresh.
|
|
540
|
+
*/
|
|
541
|
+
export function refreshCatalogue(dataRoots: readonly string[], options: RefreshOptions = {}): RefreshResult {
|
|
542
|
+
const now = options.now ?? new Date();
|
|
543
|
+
const dataRoot = dataRoots[0]!;
|
|
544
|
+
const prior = loadCatalogue(dataRoot);
|
|
545
|
+
const start = prior ?? emptyCatalogue(dataRoot, now);
|
|
546
|
+
const ctx: WalkContext = { now, deadline: Date.now() + (options.budgetMs ?? REFRESH_BUDGET_MS), ...(options.signal ? { signal: options.signal } : {}) };
|
|
547
|
+
const walk = walkSources(dataRoots, start, ctx);
|
|
548
|
+
if (walk.coverage !== "complete") return { catalogue: start, coverage: walk.coverage, skippedSecrets: 0 };
|
|
549
|
+
return finishRefresh(walk, dataRoot, now);
|
|
550
|
+
}
|
|
@@ -12,6 +12,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
12
12
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
13
13
|
import type { Domain } from "../schemas/agent.ts";
|
|
14
14
|
import type { RelevantFile } from "../schemas/findings.ts";
|
|
15
|
+
import { tempName, withFileLock } from "../desk/lock.ts";
|
|
15
16
|
|
|
16
17
|
export interface FileNote {
|
|
17
18
|
note: string;
|
|
@@ -51,8 +52,9 @@ function writeFileNotes(dataDir: string, files: Record<string, FileNote>): void
|
|
|
51
52
|
const path = fileNotesPath(dataDir);
|
|
52
53
|
try {
|
|
53
54
|
mkdirSync(dirname(path), { recursive: true });
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
const temp = tempName(path);
|
|
56
|
+
writeFileSync(temp, JSON.stringify({ version: 1, files } satisfies NotesFile));
|
|
57
|
+
renameSync(temp, path);
|
|
56
58
|
} catch {
|
|
57
59
|
// Notes are an optimisation; without them agents search as before.
|
|
58
60
|
}
|
|
@@ -89,10 +91,18 @@ export async function recordFileNotes(dataDir: string, cwd: string, domain: Doma
|
|
|
89
91
|
}));
|
|
90
92
|
const fresh = entries.filter((entry): entry is [string, FileNote] => entry !== undefined);
|
|
91
93
|
if (fresh.length === 0) return 0;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
try {
|
|
95
|
+
// Scouts run in parallel: hold the notes' lock across the read-merge-write so one writer's notes cannot overwrite another's.
|
|
96
|
+
return withFileLock(fileNotesPath(dataDir), () => {
|
|
97
|
+
const notes = { ...readFileNotes(dataDir), ...Object.fromEntries(fresh) };
|
|
98
|
+
const kept = Object.entries(notes).sort((a, b) => b[1].at.localeCompare(a[1].at)).slice(0, MAX_NOTES);
|
|
99
|
+
writeFileNotes(dataDir, Object.fromEntries(kept));
|
|
100
|
+
return fresh.length;
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
// A busy notes file is an optimisation lost for this scout, not a failure of its task.
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
/** The notes for `paths` whose file is unchanged since the note was taken. */
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* the classifier off; a large repository that is not indexed in time is left
|
|
9
9
|
* out rather than waited for.
|
|
10
10
|
*/
|
|
11
|
+
import { posix as pathPosix } from "node:path";
|
|
11
12
|
import type { FileHinter, FileScope, IndexedFile } from "../classifier/files.ts";
|
|
12
13
|
import { indexFiles, terms } from "../classifier/files.ts";
|
|
13
14
|
import { dataRoot } from "../state/project.ts";
|
|
@@ -138,6 +139,55 @@ function within<T>(promise: Promise<T>, ms: number): Promise<T | undefined> {
|
|
|
138
139
|
return Promise.race([promise, late]).finally(() => clearTimeout(timer));
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
/** Relative module paths an excerpt's import lines name, with or without a TS extension — hints, not a graph. */
|
|
143
|
+
function importedPaths(file: IndexedFile): string[] {
|
|
144
|
+
const dir = file.path.slice(0, file.path.lastIndexOf("/") + 1);
|
|
145
|
+
const specs = [...file.excerpt.matchAll(/(?:from|import|require)\s*\(?\s*["'](\.[^"']+)["']/g)].map((match) => match[1]!);
|
|
146
|
+
return specs.flatMap((spec) => {
|
|
147
|
+
const base = pathPosix.normalize(`${dir}${spec}`);
|
|
148
|
+
return [base, `${base}.ts`, `${base}/index.ts`];
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A ranked file's likely sibling by name alone: its tests, or the folder's barrel — never facts, just where to look next. */
|
|
153
|
+
function siblingPointer(file: IndexedFile, byPath: Map<string, IndexedFile>, taken: readonly string[]): string | undefined {
|
|
154
|
+
const stem = file.path.replace(/\.[^./]+$/, "");
|
|
155
|
+
const dir = stem.slice(0, stem.lastIndexOf("/") + 1);
|
|
156
|
+
const base = stem.slice(dir.length);
|
|
157
|
+
for (const [candidate, why] of [[`${dir}${base}.test.ts`, "likely its tests"], [`test/${base}.test.ts`, "likely its tests"], [`${dir}index.ts`, "the folder's entry point"]] as const) {
|
|
158
|
+
const guess = byPath.get(candidate);
|
|
159
|
+
if (!guess || guess.path === file.path || taken.some((line) => line.startsWith(`- \`${guess.path}\``))) continue;
|
|
160
|
+
return `- \`${guess.path}\` — beside \`${file.path}\`: ${why} (heuristic guess, verify it)`;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Conservative guesses beside the ranked files: sibling tests and barrels by name, and pool files whose excerpt imports one. */
|
|
166
|
+
export function heuristicPointers(files: readonly IndexedFile[], pool: readonly IndexedFile[], max = 6): string[] {
|
|
167
|
+
const byPath = new Map(pool.map((file) => [file.path, file]));
|
|
168
|
+
const pointers: string[] = [];
|
|
169
|
+
for (const file of files.slice(0, max)) {
|
|
170
|
+
const guess = siblingPointer(file, byPath, pointers);
|
|
171
|
+
if (guess) pointers.push(guess);
|
|
172
|
+
}
|
|
173
|
+
const ranked = new Set(files.map((file) => file.path));
|
|
174
|
+
for (const candidate of pool) {
|
|
175
|
+
if (pointers.length >= max || ranked.has(candidate.path) || !candidate.excerpt) continue;
|
|
176
|
+
const target = importedPaths(candidate).map((path) => byPath.get(path)).find((file) => file && ranked.has(file.path));
|
|
177
|
+
if (target) pointers.push(`- \`${candidate.path}\` — imports \`${target.path}\`: likely calls into it (heuristic guess, verify it)`);
|
|
178
|
+
}
|
|
179
|
+
return pointers;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The map plus its pointer section, kept within `maxChars`: pointer lines go first when the budget is tight. */
|
|
183
|
+
export function withPointers(map: string, pointers: readonly string[], maxChars: number): string {
|
|
184
|
+
if (pointers.length === 0) return map;
|
|
185
|
+
const header = `${map}\n\nLikely beside them (found by filename, not facts):`;
|
|
186
|
+
let kept = pointers;
|
|
187
|
+
while (kept.length > 0 && header.length + kept.join("\n").length + 1 > maxChars) kept = kept.slice(0, -1);
|
|
188
|
+
return kept.length > 0 ? `${header}\n${kept.join("\n")}` : map;
|
|
189
|
+
}
|
|
190
|
+
|
|
141
191
|
/** The repository map for a step; "" when nothing matches, the index fails or it is not ready within the budget. */
|
|
142
192
|
export async function repoMap(scope: FileScope, query: string, options: RepoMapOptions = {}): Promise<string> {
|
|
143
193
|
const indexing = indexFiles(scope, options.exclude ?? []);
|
|
@@ -148,7 +198,22 @@ export async function repoMap(scope: FileScope, query: string, options: RepoMapO
|
|
|
148
198
|
// Rank twice: notes only exist for some files, so read them for a wider first cut, then rank with them.
|
|
149
199
|
const wide = rankForMap(query, files, max * 2);
|
|
150
200
|
const notes = await currentNotes(dataRoot(scope.root, scope.configDir), scope.cwd, wide.map((file) => file.path));
|
|
151
|
-
|
|
201
|
+
const ranked = rankForMap(query, wide, max, notes);
|
|
202
|
+
const maxChars = options.maxChars ?? REPO_MAP_CHARS;
|
|
203
|
+
const text = renderRepoMap(layoutLine(files), ranked, notes, maxChars);
|
|
204
|
+
return text ? withPointers(text, heuristicPointers(ranked, wide), maxChars) : "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Drop from `likely` the file lines the map already carries, so one file is named once, not twice. */
|
|
208
|
+
export function withoutMapped(likely: string, mapped: string): string {
|
|
209
|
+
if (!likely || !mapped) return likely;
|
|
210
|
+
const listed = new Set([...mapped.matchAll(/^- `([^`]+)`/gm)].map((match) => match[1]!));
|
|
211
|
+
if (listed.size === 0) return likely;
|
|
212
|
+
const kept = likely.split("\n").filter((line) => {
|
|
213
|
+
const path = line.match(/^- `([^`]+)`/)?.[1];
|
|
214
|
+
return path === undefined || !listed.has(path);
|
|
215
|
+
});
|
|
216
|
+
return kept.some((line) => /^- `/.test(line)) ? kept.filter((line, index) => line !== "" || kept[index - 1] !== "").join("\n") : "";
|
|
152
217
|
}
|
|
153
218
|
|
|
154
219
|
/** A hinter that also hands each step the repository map, while `on()` says so. */
|
|
@@ -158,7 +223,7 @@ export function withRepoMap(hinter: FileHinter, scope: FileScope, settings: () =
|
|
|
158
223
|
const { on, exclude } = settings();
|
|
159
224
|
const map = on ? repoMap(scope, query, { exclude }).catch(() => "") : Promise.resolve("");
|
|
160
225
|
const [likely, mapped] = await Promise.all([hinter.block(query, signal, context), map]);
|
|
161
|
-
return [likely, mapped].filter(Boolean).join("\n\n");
|
|
226
|
+
return [withoutMapped(likely, mapped), mapped].filter(Boolean).join("\n\n");
|
|
162
227
|
},
|
|
163
228
|
tools: () => hinter.tools(),
|
|
164
229
|
};
|