@falkenslab/agent-kit 0.14.0 → 0.16.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/README.md +110 -110
- package/assets/knowledge-plugin/.claude-plugin/plugin.json +1 -1
- package/assets/knowledge-plugin/commands/ingest.md +1 -1
- package/assets/knowledge-plugin/commands/lint.md +1 -1
- package/assets/knowledge-plugin/skills/knowledge-ingest/SKILL.md +13 -19
- package/assets/knowledge-plugin/skills/knowledge-lint/SKILL.md +18 -14
- package/assets/knowledge-plugin/skills/knowledge-query/SKILL.md +5 -5
- package/assets/knowledge-plugin-files/.claude-plugin/plugin.json +4 -0
- package/assets/knowledge-plugin-files/commands/ingest.md +7 -0
- package/assets/knowledge-plugin-files/commands/lint.md +5 -0
- package/assets/knowledge-plugin-files/commands/query.md +5 -0
- package/assets/knowledge-plugin-files/skills/knowledge-ingest/SKILL.md +46 -0
- package/assets/knowledge-plugin-files/skills/knowledge-lint/SKILL.md +26 -0
- package/assets/knowledge-plugin-files/skills/knowledge-query/SKILL.md +14 -0
- package/dist/core/agentSpec.d.ts +27 -2
- package/dist/core/extractText.d.ts +13 -0
- package/dist/core/extractText.js +147 -0
- package/dist/core/fileKnowledgeStore.d.ts +10 -0
- package/dist/core/fileKnowledgeStore.js +378 -0
- package/dist/core/hooks/fileScopeGate.d.ts +8 -0
- package/dist/core/hooks/fileScopeGate.js +20 -0
- package/dist/core/hooks/humanInput.d.ts +18 -0
- package/dist/core/hooks/humanInput.js +30 -2
- package/dist/core/hooks/planGate.js +14 -5
- package/dist/core/hooks/stepGate.js +6 -0
- package/dist/core/interaction.d.ts +20 -0
- package/dist/core/knowledge.d.ts +14 -4
- package/dist/core/knowledge.js +36 -6
- package/dist/core/knowledgeStore.d.ts +134 -0
- package/dist/core/knowledgeStore.js +84 -0
- package/dist/core/messages/de.js +65 -0
- package/dist/core/messages/en.d.ts +41 -0
- package/dist/core/messages/en.js +65 -0
- package/dist/core/messages/es.js +65 -0
- package/dist/core/messages/fr.js +65 -0
- package/dist/core/modeControl.d.ts +36 -0
- package/dist/core/modeControl.js +61 -0
- package/dist/core/session.d.ts +6 -31
- package/dist/core/session.js +76 -71
- package/dist/core/sources.d.ts +122 -0
- package/dist/core/sources.js +312 -0
- package/dist/core/todos.d.ts +23 -0
- package/dist/core/todos.js +33 -0
- package/dist/core/toolLabels.d.ts +1 -1
- package/dist/core/toolLabels.js +42 -2
- package/dist/core/tools/humanApproval.d.ts +9 -5
- package/dist/core/tools/humanApproval.js +39 -7
- package/dist/core/tools/knowledgeTools.d.ts +16 -0
- package/dist/core/tools/knowledgeTools.js +161 -0
- package/dist/core/tools/saveToSources.d.ts +27 -8
- package/dist/core/tools/saveToSources.js +203 -55
- package/dist/core/tools/time.d.ts +29 -0
- package/dist/core/tools/time.js +129 -0
- package/dist/index.d.ts +7 -4
- package/dist/index.js +4 -1
- package/dist/tui/chatTui.js +14 -1
- package/dist/tui/consoleRenderer.js +16 -2
- package/dist/tui/ink/SessionView.js +72 -17
- package/dist/tui/ink/inkInteraction.d.ts +4 -2
- package/dist/tui/ink/inkInteraction.js +20 -2
- package/dist/tui/ink/progressView.d.ts +5 -1
- package/dist/tui/ink/runChatInk.d.ts +16 -1
- package/dist/tui/ink/runChatInk.js +7 -0
- package/dist/tui/ink/sessionModel.d.ts +10 -3
- package/dist/tui/ink/sessionModel.js +26 -4
- package/dist/tui/ink/toolGroup.d.ts +23 -2
- package/dist/tui/ink/toolGroup.js +17 -4
- package/dist/tui/terminalInteraction.js +2 -0
- package/package.json +31 -1
- /package/assets/{knowledge-plugin → knowledge-plugin-files}/skills/knowledge-pages/SKILL.md +0 -0
package/dist/core/session.js
CHANGED
|
@@ -9,6 +9,11 @@ import { createPlanGate } from "./hooks/planGate.js";
|
|
|
9
9
|
import { createHumanApprovalServer } from "./tools/humanApproval.js";
|
|
10
10
|
import { createManualLoginServer } from "./tools/manualLogin.js";
|
|
11
11
|
import { createSaveToSourcesServer } from "./tools/saveToSources.js";
|
|
12
|
+
import { createTimeServer } from "./tools/time.js";
|
|
13
|
+
import { TODO_TOOL } from "./todos.js";
|
|
14
|
+
import { createModeControl } from "./modeControl.js";
|
|
15
|
+
import { createFileKnowledgeStore } from "./fileKnowledgeStore.js";
|
|
16
|
+
import { createKnowledgeServer } from "./tools/knowledgeTools.js";
|
|
12
17
|
import { allowAnyMcpTool } from "./mcpPermissions.js";
|
|
13
18
|
import { knowledgePluginRoot, knowledgePromptSection } from "./knowledge.js";
|
|
14
19
|
import { replyLanguageInstruction } from "./language.js";
|
|
@@ -53,24 +58,38 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
53
58
|
// (`knowledgeDir`) or for original files (`sourcesDir`) — there is no separate "context"
|
|
54
59
|
// folder: material the user provides is just more of `sourcesDir`.
|
|
55
60
|
const includeFileTools = Boolean(config.knowledgeDir || config.sourcesDir);
|
|
56
|
-
|
|
61
|
+
// The built-in knowledge base (rules in the system prompt + its skills/commands as a plugin) needs
|
|
62
|
+
// somewhere to keep the wiki, so it follows `knowledgeDir`; `spec.knowledgeBase: false` opts out.
|
|
63
|
+
const includeKnowledgeBase = Boolean(config.knowledgeDir) && spec.knowledgeBase !== false;
|
|
64
|
+
// By default the agent reaches it only through the `knowledge_*` tools, over a store, never
|
|
65
|
+
// with the file tools (ADR-024); `spec.knowledgeTools: "files"` keeps the file tools on it.
|
|
66
|
+
const knowledgeViaStore = includeKnowledgeBase && (spec.knowledgeTools ?? "store") === "store";
|
|
67
|
+
const notesDir = knowledgeViaStore ? undefined : config.knowledgeDir;
|
|
68
|
+
const extraDirs = config.extraWritableDirs ?? [];
|
|
69
|
+
// With the store, the file tools are only for the originals (reading) and the extra writable
|
|
70
|
+
// folders; without either, none.
|
|
71
|
+
const fileTools = !includeFileTools
|
|
72
|
+
? []
|
|
73
|
+
: !knowledgeViaStore
|
|
74
|
+
? ["Read", "Write", "Edit", "Glob", "Grep"]
|
|
75
|
+
: [...(config.sourcesDir || extraDirs.length ? ["Read", "Glob", "Grep"] : []), ...(extraDirs.length ? ["Write", "Edit"] : [])];
|
|
57
76
|
// Where Write/Edit may act and Grep may search (see hooks/fileScopeGate.ts): the
|
|
58
77
|
// project's own folders, never the whole cwd — which would include whatever else the
|
|
59
78
|
// project directory holds (the user's config...). `sourcesDir` is searchable but
|
|
60
79
|
// deliberately NOT writable: originals stay as obtained, and the only way to add to it is
|
|
61
80
|
// the `save_to_sources` tool (which never overwrites).
|
|
62
|
-
const writableDirs = [
|
|
63
|
-
const searchableDirs = [
|
|
81
|
+
const writableDirs = [notesDir, ...extraDirs].filter((d) => Boolean(d));
|
|
82
|
+
const searchableDirs = [notesDir, config.sourcesDir, ...extraDirs].filter((d) => Boolean(d));
|
|
64
83
|
const readOnlyDirs = config.sourcesDir ? [config.sourcesDir] : [];
|
|
65
84
|
// Skills/commands/plugins are a separate concern from the file tools: an agent that
|
|
66
85
|
// wants a plugin-provided skill/command but has no notes or sources folder shouldn't have
|
|
67
86
|
// to invent one just to get `cwd`/`skills: "all"`/`plugins` wired up. Gated on either
|
|
68
87
|
// signal, not on `pluginRoots` alone, so an agent with file tools keeps getting the SDK's
|
|
69
88
|
// own project-level `.claude/skills`/`.claude/commands` discovery.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
const pluginRoots = [...spec.pluginRoots(config), ...(includeKnowledgeBase ? [knowledgePluginRoot(knowledgeViaStore ? "tools" : "files")] : [])];
|
|
90
|
+
const knowledgeStore = knowledgeViaStore && config.knowledgeDir
|
|
91
|
+
? (spec.knowledgeStore?.(config) ?? createFileKnowledgeStore(config.knowledgeDir, { pageTypes: spec.knowledgePageTypes, sourcesDir: config.sourcesDir }))
|
|
92
|
+
: undefined;
|
|
74
93
|
const includeSkillsAndPlugins = includeFileTools || pluginRoots.length > 0;
|
|
75
94
|
const skillTools = includeSkillsAndPlugins ? ["Skill"] : [];
|
|
76
95
|
// Whatever opt-in subagents `spec` wants for this config, or undefined if none apply.
|
|
@@ -97,7 +116,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
97
116
|
const transcriptLogger = createTranscriptLogger(transcriptPath, config.secrets ?? []);
|
|
98
117
|
const sdkOptions = {
|
|
99
118
|
systemPrompt: withReplyLine(includeKnowledgeBase && config.knowledgeDir
|
|
100
|
-
? `${spec.buildSystemPrompt(config)}\n\n${knowledgePromptSection(config.projectDir, config.knowledgeDir, config.sourcesDir)}`
|
|
119
|
+
? `${spec.buildSystemPrompt(config)}\n\n${knowledgePromptSection(config.projectDir, config.knowledgeDir, config.sourcesDir, { tools: knowledgeViaStore, pageTypes: knowledgeStore?.types() })}`
|
|
101
120
|
: spec.buildSystemPrompt(config)),
|
|
102
121
|
// Not the SDK's default (every source): "user" hands the agent the runner's own Claude
|
|
103
122
|
// Code configuration, and its `language` setting outranked the agent's prompt.
|
|
@@ -118,8 +137,10 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
118
137
|
// "Skill" has to be listed explicitly: with an explicit `tools` list the SDK loads the
|
|
119
138
|
// skills (they show up in the init message) but doesn't offer the tool to invoke them,
|
|
120
139
|
// so the model ends up looking for SKILL.md files by hand (confirmed empirically).
|
|
121
|
-
|
|
122
|
-
|
|
140
|
+
// TodoWrite: the SDK's task list for long jobs, in every session (the chats show the list
|
|
141
|
+
// instead of the calls; see todos.ts). Accepted in an explicit list (confirmed empirically).
|
|
142
|
+
tools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", TODO_TOOL, ...(includeSubagentTools ? ["Agent", "Bash"] : [])],
|
|
143
|
+
allowedTools: [...fileTools, ...skillTools, "WebFetch", "WebSearch", TODO_TOOL, ...(includeSubagentTools ? ["Agent", "Bash"] : [])],
|
|
123
144
|
// Every mcp__* tool (whatever spec.buildMcpServers() registers, approvals/manualLogin
|
|
124
145
|
// when enabled below, sourceFiles, and any server a project's own .mcp.json
|
|
125
146
|
// declares) is approved generically here rather than listed one by one — see
|
|
@@ -145,7 +166,7 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
145
166
|
// SDK's own official skills (pdf/docx), the project's own custom ones, and the
|
|
146
167
|
// plugin-provided built-ins below. A spec's own list gets the knowledge base's
|
|
147
168
|
// skills added, so it only names its own.
|
|
148
|
-
skills: skillList(spec.skills, includeKnowledgeBase),
|
|
169
|
+
skills: skillList(spec.skills, includeKnowledgeBase ? (knowledgeViaStore ? "tools" : "files") : null),
|
|
149
170
|
plugins: pluginRoots.map((pluginPath) => ({ type: "local", path: pluginPath, skipMcpDiscovery: true })),
|
|
150
171
|
}
|
|
151
172
|
: {}),
|
|
@@ -154,15 +175,47 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
154
175
|
...(subagentDefinitions ? { agents: subagentDefinitions } : {}),
|
|
155
176
|
mcpServers: {
|
|
156
177
|
...spec.buildMcpServers(config, runDir),
|
|
157
|
-
...(includeApprovalTool ? { approvals: createHumanApprovalServer(runDir, spec.humanApprovalTexts) } : {}),
|
|
178
|
+
...(includeApprovalTool ? { approvals: createHumanApprovalServer(runDir, spec.humanApprovalTexts, { modeControl }) } : {}),
|
|
158
179
|
...(manualInterventionTexts ? { manualLogin: createManualLoginServer(runDir, manualInterventionTexts) } : {}),
|
|
159
|
-
|
|
180
|
+
// The sources folder's tools (tools/saveToSources.ts); asking a person (request_file,
|
|
181
|
+
// retire_source) only where there is one.
|
|
182
|
+
...(config.sourcesDir
|
|
183
|
+
? {
|
|
184
|
+
sourceFiles: createSaveToSourcesServer(runDir, config.sourcesDir, spec.saveToSourcesDescription, {
|
|
185
|
+
knowledgeDir: config.knowledgeDir,
|
|
186
|
+
projectDir: config.projectDir,
|
|
187
|
+
interactive: mode !== "autonomous",
|
|
188
|
+
}),
|
|
189
|
+
}
|
|
190
|
+
: {}),
|
|
191
|
+
// The knowledge base's own tools, over its store (tools/knowledgeTools.ts, ADR-024).
|
|
192
|
+
...(knowledgeStore
|
|
193
|
+
? {
|
|
194
|
+
knowledge: createKnowledgeServer(knowledgeStore, {
|
|
195
|
+
runDir,
|
|
196
|
+
interactive: mode !== "autonomous",
|
|
197
|
+
sourcesDir: config.sourcesDir,
|
|
198
|
+
projectDir: config.projectDir,
|
|
199
|
+
knowledgeDir: config.knowledgeDir,
|
|
200
|
+
}),
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
// The date and date arithmetic, in every session and mode: only reads (tools/time.ts).
|
|
204
|
+
time: createTimeServer(config.timeZone),
|
|
160
205
|
},
|
|
161
206
|
hooks: {
|
|
162
207
|
PreToolUse: [
|
|
163
208
|
{ hooks: [transcriptLogger.preToolUse] },
|
|
164
209
|
...(includeFileTools
|
|
165
|
-
? [{ hooks: [createFileScopeGate({
|
|
210
|
+
? [{ hooks: [createFileScopeGate({
|
|
211
|
+
projectDir: config.projectDir,
|
|
212
|
+
writableDirs,
|
|
213
|
+
searchableDirs,
|
|
214
|
+
readOnlyDirs,
|
|
215
|
+
deniedPaths: config.deniedPaths ?? [],
|
|
216
|
+
...(knowledgeViaStore && config.knowledgeDir ? { toolOnlyDirs: [{ dir: config.knowledgeDir, instead: "the knowledge_* tools" }] } : {}),
|
|
217
|
+
}),
|
|
218
|
+
] }]
|
|
166
219
|
: []),
|
|
167
220
|
// Registered wherever the approval tool is, and asking only while the current mode is
|
|
168
221
|
// "interactive": a guided session can switch to interactive and back (ModeControl).
|
|
@@ -193,67 +246,19 @@ export async function buildSessionOptions(config, runDir, spec, options = {}) {
|
|
|
193
246
|
PostToolUse: [{ hooks: [transcriptLogger.postToolUse] }],
|
|
194
247
|
},
|
|
195
248
|
};
|
|
196
|
-
return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl, language };
|
|
249
|
+
return { options: sdkOptions, transcriptLogger, transcriptPath, modeControl, language, ...(knowledgeStore ? { knowledgeStore } : {}) };
|
|
197
250
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
export { createModeControl, togglePlanMode } from "./modeControl.js";
|
|
252
|
+
/** The knowledge plugin's skills, as the SDK names a plugin's skills ("plugin:skill"): the tools variant has no page templates skill (knowledge_create gives them). */
|
|
253
|
+
const KNOWLEDGE_SKILLS = {
|
|
254
|
+
tools: ["knowledge-ingest", "knowledge-query", "knowledge-lint"].map((skill) => `knowledge:${skill}`),
|
|
255
|
+
files: ["knowledge-pages", "knowledge-ingest", "knowledge-query", "knowledge-lint"].map((skill) => `knowledge:${skill}`),
|
|
256
|
+
};
|
|
257
|
+
function skillList(skills, knowledgeVariant) {
|
|
201
258
|
if (skills === undefined || skills === "all")
|
|
202
259
|
return "all";
|
|
203
|
-
return
|
|
204
|
-
}
|
|
205
|
-
/** A `ModeControl` starting at `initial` (`buildSessionOptions()` returns one for its session). */
|
|
206
|
-
export function createModeControl(initial) {
|
|
207
|
-
let current = initial;
|
|
208
|
-
// The mode the model was last told about: a session starting in plan mode tells it with
|
|
209
|
-
// the first message, since the system prompt doesn't say.
|
|
210
|
-
let told = initial === "plan" ? undefined : initial;
|
|
211
|
-
const switchable = initial === "autonomous" ? ["autonomous"] : ["guided", "interactive", "plan"];
|
|
212
|
-
return {
|
|
213
|
-
get mode() {
|
|
214
|
-
return current;
|
|
215
|
-
},
|
|
216
|
-
switchable,
|
|
217
|
-
set(next) {
|
|
218
|
-
if (!switchable.includes(next))
|
|
219
|
-
return false;
|
|
220
|
-
current = next;
|
|
221
|
-
return true;
|
|
222
|
-
},
|
|
223
|
-
takeNotice() {
|
|
224
|
-
const before = told;
|
|
225
|
-
told = current;
|
|
226
|
-
if (current === "plan" && before !== "plan")
|
|
227
|
-
return PLAN_MODE_ENTERED;
|
|
228
|
-
if (current !== "plan" && before === "plan")
|
|
229
|
-
return PLAN_MODE_LEFT;
|
|
230
|
-
return undefined;
|
|
231
|
-
},
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
// The mode each session was in when `/plan` took it into plan mode, to go back to.
|
|
235
|
-
const modeBeforePlan = new WeakMap();
|
|
236
|
-
/**
|
|
237
|
-
* What the chats' `/plan` does: switches `control` into plan mode, or, when it's already
|
|
238
|
-
* there, back to the mode it was entered from ("guided" if it started in plan mode). Returns
|
|
239
|
-
* the new mode, or `null` when this session can't be in plan mode (an autonomous one).
|
|
240
|
-
*/
|
|
241
|
-
export function togglePlanMode(control) {
|
|
242
|
-
if (!control.switchable.includes("plan"))
|
|
243
|
-
return null;
|
|
244
|
-
if (control.mode === "plan") {
|
|
245
|
-
const back = modeBeforePlan.get(control) ?? "guided";
|
|
246
|
-
control.set(back);
|
|
247
|
-
return back;
|
|
248
|
-
}
|
|
249
|
-
modeBeforePlan.set(control, control.mode);
|
|
250
|
-
control.set("plan");
|
|
251
|
-
return "plan";
|
|
260
|
+
return knowledgeVariant ? [...new Set([...skills, ...KNOWLEDGE_SKILLS[knowledgeVariant]])] : skills;
|
|
252
261
|
}
|
|
253
|
-
// What the model reads on a plan-mode switch, in English like the rest of what it reads
|
|
254
|
-
// (ADR-019); tagged so a resumed conversation doesn't show it as the human's words (runs.ts).
|
|
255
|
-
const PLAN_MODE_ENTERED = "<system-reminder>The user switched to plan mode. Only read, research and plan: nothing can be changed (files, tools that modify something) until they switch out of it. When the plan is ready, present it and wait for them.</system-reminder>";
|
|
256
|
-
const PLAN_MODE_LEFT = "<system-reminder>The user left plan mode: you can carry out the plan now.</system-reminder>";
|
|
257
262
|
/**
|
|
258
263
|
* User message queue backed by a single long-lived generator, for any multi-turn
|
|
259
264
|
* (chat-shaped) caller of `query()`.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sources folder's bookkeeping: originals stay files (ADR-024), and the kit keeps a
|
|
3
|
+
* manifest next to them (`.agent-kit/sources.json`, inside `sourcesDir`, where the agent's
|
|
4
|
+
* file tools can't write) with each original's hash, size and provenance, the version it
|
|
5
|
+
* replaces, and the hash it had when a summary page first pointed to it. From that it tells
|
|
6
|
+
* new, ingested, changed and missing originals apart, refuses duplicates, and retires an
|
|
7
|
+
* original into `.agent-kit/retired/` instead of deleting it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const KIT_DIR = ".agent-kit";
|
|
10
|
+
/** Where an original came from. */
|
|
11
|
+
export type SourceOrigin = {
|
|
12
|
+
kind: "run";
|
|
13
|
+
from: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "url";
|
|
16
|
+
from: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: "person";
|
|
19
|
+
from: string;
|
|
20
|
+
}
|
|
21
|
+
/** Copied in by hand: first seen by `listSources()`. */
|
|
22
|
+
| {
|
|
23
|
+
kind: "manual";
|
|
24
|
+
};
|
|
25
|
+
export interface SourceEntry {
|
|
26
|
+
hash: string;
|
|
27
|
+
size: number;
|
|
28
|
+
/** For hashing a file again only when it changed. */
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
addedAt: string;
|
|
31
|
+
origin: SourceOrigin;
|
|
32
|
+
/** The original this one is a new version of (a path relative to `sourcesDir`). */
|
|
33
|
+
replaces?: string;
|
|
34
|
+
/** The page this one was generated from (a web page's markdown, from its HTML). */
|
|
35
|
+
derivedFrom?: string;
|
|
36
|
+
/** The hash when a summary page first pointed to it: a different hash now means it changed after its ingest. */
|
|
37
|
+
ingestedHash?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RetiredSource {
|
|
40
|
+
path: string;
|
|
41
|
+
movedTo: string;
|
|
42
|
+
retiredAt: string;
|
|
43
|
+
why: "wrong" | "replaced";
|
|
44
|
+
reason: string;
|
|
45
|
+
replacedBy?: string;
|
|
46
|
+
entry?: SourceEntry;
|
|
47
|
+
}
|
|
48
|
+
export interface SourceManifest {
|
|
49
|
+
version: 1;
|
|
50
|
+
files: Record<string, SourceEntry>;
|
|
51
|
+
retired: RetiredSource[];
|
|
52
|
+
}
|
|
53
|
+
export type SourceStatus = "new" | "ingested" | "changed" | "missing" | "present";
|
|
54
|
+
export interface SourceListing {
|
|
55
|
+
path: string;
|
|
56
|
+
status: SourceStatus;
|
|
57
|
+
type: string;
|
|
58
|
+
size?: number;
|
|
59
|
+
pages?: number;
|
|
60
|
+
slides?: number;
|
|
61
|
+
origin?: SourceOrigin;
|
|
62
|
+
replaces?: string;
|
|
63
|
+
/** The summary pages that point to it (paths relative to the project). */
|
|
64
|
+
summaries: string[];
|
|
65
|
+
}
|
|
66
|
+
export declare function hashFile(file: string): Promise<string>;
|
|
67
|
+
/** Resolves `candidate` against `base`; the absolute path only if it stays inside it. */
|
|
68
|
+
export declare function resolveWithin(base: string, candidate: string): string | undefined;
|
|
69
|
+
export declare function loadManifest(sourcesDir: string): Promise<SourceManifest>;
|
|
70
|
+
export declare function saveManifest(sourcesDir: string, manifest: SourceManifest): Promise<void>;
|
|
71
|
+
/** Every original under `sourcesDir` (paths relative to it), leaving out hidden files and folders (the kit's own among them). */
|
|
72
|
+
export declare function scanSources(sourcesDir: string): Promise<string[]>;
|
|
73
|
+
/** The summary pages (knowledge base layout: `summaries/*.md`) by the absolute path of the original their frontmatter `file:` points to. */
|
|
74
|
+
export declare function summariesByOriginal(knowledgeDir: string): Promise<Map<string, string[]>>;
|
|
75
|
+
/** Pages of a PDF, read from its page tree without a parser; undefined when it can't tell (e.g. compressed object streams). */
|
|
76
|
+
export declare function pdfPageCount(bytes: Buffer): number | undefined;
|
|
77
|
+
/** Slides of a PPTX, from the file names in the zip (stored uncompressed). */
|
|
78
|
+
export declare function pptxSlideCount(bytes: Buffer): number | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Every original with its status, recording in the manifest the ones copied in by hand and,
|
|
81
|
+
* the first time a summary page points to one, its hash at ingest. Without a knowledge
|
|
82
|
+
* folder there's no ingest to compare with: originals are "present".
|
|
83
|
+
*/
|
|
84
|
+
export declare function listSources(sourcesDir: string, knowledgeDir: string | undefined, projectDir: string): Promise<SourceListing[]>;
|
|
85
|
+
export interface AddedSource {
|
|
86
|
+
/** Where it is, relative to `sourcesDir`. */
|
|
87
|
+
path: string;
|
|
88
|
+
/** An identical file was already there: nothing was copied. */
|
|
89
|
+
duplicateOf?: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Adds `file` (an absolute path) to `sourcesDir` at `destination` (a folder: under `extra.name`,
|
|
93
|
+
* or the file's own name): never overwriting, and
|
|
94
|
+
* not at all if an identical file is already there (its path is returned). `replaces`
|
|
95
|
+
* records it as a new version of an original already in the folder.
|
|
96
|
+
*/
|
|
97
|
+
export declare function addSource(sourcesDir: string, file: string, destination: string, origin: SourceOrigin, extra?: {
|
|
98
|
+
replaces?: string;
|
|
99
|
+
derivedFrom?: string;
|
|
100
|
+
name?: string;
|
|
101
|
+
}): Promise<AddedSource>;
|
|
102
|
+
/** What retiring an original did to its summary pages (paths relative to the project). */
|
|
103
|
+
export interface RetireResult {
|
|
104
|
+
movedTo: string;
|
|
105
|
+
summaries: {
|
|
106
|
+
page: string;
|
|
107
|
+
marked: "retired" | "superseded";
|
|
108
|
+
supersededBy?: string;
|
|
109
|
+
}[];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder)
|
|
113
|
+
* and records why. Its summary pages are marked: retired if the original was wrong (what it
|
|
114
|
+
* taught was wrong), superseded if it was replaced, pointing to the replacement's summary
|
|
115
|
+
* when there is one.
|
|
116
|
+
*/
|
|
117
|
+
export declare function retireSource(sourcesDir: string, knowledgeDir: string | undefined, projectDir: string, source: string, why: "wrong" | "replaced", reason: string, replacedBy?: string): Promise<RetireResult>;
|
|
118
|
+
/**
|
|
119
|
+
* Records `source` (relative to `sourcesDir`) as ingested now, with its current hash: a
|
|
120
|
+
* summary page was just written from it, so "changed" means changed after this.
|
|
121
|
+
*/
|
|
122
|
+
export declare function recordIngest(sourcesDir: string, source: string): Promise<void>;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { copyFile, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* The sources folder's bookkeeping: originals stay files (ADR-024), and the kit keeps a
|
|
7
|
+
* manifest next to them (`.agent-kit/sources.json`, inside `sourcesDir`, where the agent's
|
|
8
|
+
* file tools can't write) with each original's hash, size and provenance, the version it
|
|
9
|
+
* replaces, and the hash it had when a summary page first pointed to it. From that it tells
|
|
10
|
+
* new, ingested, changed and missing originals apart, refuses duplicates, and retires an
|
|
11
|
+
* original into `.agent-kit/retired/` instead of deleting it.
|
|
12
|
+
*/
|
|
13
|
+
export const KIT_DIR = ".agent-kit";
|
|
14
|
+
const MANIFEST = "sources.json";
|
|
15
|
+
const RETIRED_DIR = "retired";
|
|
16
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
17
|
+
// The model can call several tools at once: everything that reads and writes a folder's
|
|
18
|
+
// manifest runs one at a time per folder, or a parallel call would lose another's entry.
|
|
19
|
+
const locks = new Map();
|
|
20
|
+
function serialized(sourcesDir, work) {
|
|
21
|
+
const key = path.resolve(sourcesDir);
|
|
22
|
+
const result = (locks.get(key) ?? Promise.resolve()).then(work, work);
|
|
23
|
+
locks.set(key, result.catch(() => undefined));
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
const now = () => new Date().toISOString();
|
|
27
|
+
export async function hashFile(file) {
|
|
28
|
+
return createHash("sha256").update(await readFile(file)).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
/** Resolves `candidate` against `base`; the absolute path only if it stays inside it. */
|
|
31
|
+
export function resolveWithin(base, candidate) {
|
|
32
|
+
const resolved = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(base, candidate);
|
|
33
|
+
const relative = path.relative(base, resolved);
|
|
34
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved : undefined;
|
|
35
|
+
}
|
|
36
|
+
export async function loadManifest(sourcesDir) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(await readFile(path.join(sourcesDir, KIT_DIR, MANIFEST), "utf8"));
|
|
39
|
+
return { version: 1, files: parsed.files ?? {}, retired: parsed.retired ?? [] };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { version: 1, files: {}, retired: [] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function saveManifest(sourcesDir, manifest) {
|
|
46
|
+
await mkdir(path.join(sourcesDir, KIT_DIR), { recursive: true });
|
|
47
|
+
await writeFile(path.join(sourcesDir, KIT_DIR, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
48
|
+
}
|
|
49
|
+
/** Every original under `sourcesDir` (paths relative to it), leaving out hidden files and folders (the kit's own among them). */
|
|
50
|
+
export async function scanSources(sourcesDir) {
|
|
51
|
+
const found = [];
|
|
52
|
+
async function walk(dir) {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (entry.name.startsWith("."))
|
|
62
|
+
continue;
|
|
63
|
+
const full = path.join(dir, entry.name);
|
|
64
|
+
if (entry.isDirectory())
|
|
65
|
+
await walk(full);
|
|
66
|
+
else if (entry.isFile())
|
|
67
|
+
found.push(posix(path.relative(sourcesDir, full)));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await walk(sourcesDir);
|
|
71
|
+
return found.sort();
|
|
72
|
+
}
|
|
73
|
+
/** An entry for `file`, reusing the recorded hash when its size and modification time haven't changed. */
|
|
74
|
+
async function freshEntry(file, known, origin) {
|
|
75
|
+
const info = await stat(file);
|
|
76
|
+
if (known && known.size === info.size && known.mtimeMs === info.mtimeMs)
|
|
77
|
+
return known;
|
|
78
|
+
return { ...(known ?? { addedAt: now(), origin }), hash: await hashFile(file), size: info.size, mtimeMs: info.mtimeMs };
|
|
79
|
+
}
|
|
80
|
+
/** The original a frontmatter `file:` value points to (a relative path or a markdown link), resolved from the page's folder. */
|
|
81
|
+
function linkedFile(value, pageDir) {
|
|
82
|
+
const link = /\]\(([^)]+)\)/.exec(value)?.[1] ?? value.trim().replace(/^["']|["']$/g, "");
|
|
83
|
+
if (!link || /^[a-z]+:\/\//i.test(link))
|
|
84
|
+
return undefined;
|
|
85
|
+
return path.resolve(pageDir, decodeURI(link));
|
|
86
|
+
}
|
|
87
|
+
/** The summary pages (knowledge base layout: `summaries/*.md`) by the absolute path of the original their frontmatter `file:` points to. */
|
|
88
|
+
export async function summariesByOriginal(knowledgeDir) {
|
|
89
|
+
const byOriginal = new Map();
|
|
90
|
+
const dir = path.join(knowledgeDir, "summaries");
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(dir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return byOriginal;
|
|
97
|
+
}
|
|
98
|
+
for (const name of names.filter((n) => n.endsWith(".md"))) {
|
|
99
|
+
const page = path.join(dir, name);
|
|
100
|
+
const text = await readFile(page, "utf8");
|
|
101
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
|
|
102
|
+
const value = /^file:\s*(.+)$/m.exec(frontmatter)?.[1];
|
|
103
|
+
const original = value ? linkedFile(value, dir) : undefined;
|
|
104
|
+
if (original)
|
|
105
|
+
byOriginal.set(path.normalize(original), [...(byOriginal.get(path.normalize(original)) ?? []), page]);
|
|
106
|
+
}
|
|
107
|
+
return byOriginal;
|
|
108
|
+
}
|
|
109
|
+
/** Pages of a PDF, read from its page tree without a parser; undefined when it can't tell (e.g. compressed object streams). */
|
|
110
|
+
export function pdfPageCount(bytes) {
|
|
111
|
+
const text = bytes.toString("latin1");
|
|
112
|
+
const counts = [...text.matchAll(/\/Type\s*\/Pages\b[^>]*?\/Count\s+(\d+)/g)].map((m) => Number(m[1]));
|
|
113
|
+
const reversed = [...text.matchAll(/\/Count\s+(\d+)[^>]*?\/Type\s*\/Pages\b/g)].map((m) => Number(m[1]));
|
|
114
|
+
const fromTree = Math.max(0, ...counts, ...reversed);
|
|
115
|
+
if (fromTree > 0)
|
|
116
|
+
return fromTree;
|
|
117
|
+
const pages = text.match(/\/Type\s*\/Page(?![s\w])/g)?.length ?? 0;
|
|
118
|
+
return pages > 0 ? pages : undefined;
|
|
119
|
+
}
|
|
120
|
+
/** Slides of a PPTX, from the file names in the zip (stored uncompressed). */
|
|
121
|
+
export function pptxSlideCount(bytes) {
|
|
122
|
+
const names = new Set(bytes.toString("latin1").match(/ppt\/slides\/slide\d+\.xml/g) ?? []);
|
|
123
|
+
return names.size > 0 ? names.size : undefined;
|
|
124
|
+
}
|
|
125
|
+
const typeOf = (file) => path.extname(file).slice(1).toLowerCase() || "file";
|
|
126
|
+
/**
|
|
127
|
+
* Every original with its status, recording in the manifest the ones copied in by hand and,
|
|
128
|
+
* the first time a summary page points to one, its hash at ingest. Without a knowledge
|
|
129
|
+
* folder there's no ingest to compare with: originals are "present".
|
|
130
|
+
*/
|
|
131
|
+
export function listSources(sourcesDir, knowledgeDir, projectDir) {
|
|
132
|
+
return serialized(sourcesDir, () => listSourcesNow(sourcesDir, knowledgeDir, projectDir));
|
|
133
|
+
}
|
|
134
|
+
async function listSourcesNow(sourcesDir, knowledgeDir, projectDir) {
|
|
135
|
+
const manifest = await loadManifest(sourcesDir);
|
|
136
|
+
const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
|
|
137
|
+
const files = await scanSources(sourcesDir);
|
|
138
|
+
const listing = [];
|
|
139
|
+
for (const rel of files) {
|
|
140
|
+
const full = path.join(sourcesDir, rel);
|
|
141
|
+
const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
|
|
142
|
+
const pagesOf = summaries.get(path.normalize(full)) ?? [];
|
|
143
|
+
let status = "present";
|
|
144
|
+
if (knowledgeDir) {
|
|
145
|
+
if (pagesOf.length === 0)
|
|
146
|
+
status = "new";
|
|
147
|
+
else {
|
|
148
|
+
entry.ingestedHash ??= entry.hash;
|
|
149
|
+
status = entry.ingestedHash === entry.hash ? "ingested" : "changed";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
manifest.files[rel] = entry;
|
|
153
|
+
const type = typeOf(rel);
|
|
154
|
+
const counts = type === "pdf" || type === "pptx"
|
|
155
|
+
? await readFile(full).then((bytes) => (type === "pdf" ? { pages: pdfPageCount(bytes) } : { slides: pptxSlideCount(bytes) }))
|
|
156
|
+
: {};
|
|
157
|
+
listing.push({
|
|
158
|
+
path: rel,
|
|
159
|
+
status,
|
|
160
|
+
type,
|
|
161
|
+
size: entry.size,
|
|
162
|
+
...counts,
|
|
163
|
+
origin: entry.origin,
|
|
164
|
+
...(entry.replaces ? { replaces: entry.replaces } : {}),
|
|
165
|
+
summaries: pagesOf.map((page) => posix(path.relative(projectDir, page))),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const [rel, entry] of Object.entries(manifest.files)) {
|
|
169
|
+
if (!files.includes(rel))
|
|
170
|
+
listing.push({ path: rel, status: "missing", type: typeOf(rel), origin: entry.origin, summaries: [] });
|
|
171
|
+
}
|
|
172
|
+
await saveManifest(sourcesDir, manifest);
|
|
173
|
+
return listing;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Adds `file` (an absolute path) to `sourcesDir` at `destination` (a folder: under `extra.name`,
|
|
177
|
+
* or the file's own name): never overwriting, and
|
|
178
|
+
* not at all if an identical file is already there (its path is returned). `replaces`
|
|
179
|
+
* records it as a new version of an original already in the folder.
|
|
180
|
+
*/
|
|
181
|
+
export function addSource(sourcesDir, file, destination, origin, extra = {}) {
|
|
182
|
+
return serialized(sourcesDir, () => addSourceNow(sourcesDir, file, destination, origin, extra));
|
|
183
|
+
}
|
|
184
|
+
async function addSourceNow(sourcesDir, file, destination, origin, extra) {
|
|
185
|
+
// A folder ("books/", or one that exists) means: inside it, under the file's own name.
|
|
186
|
+
const isFolder = /[/\\]$/.test(destination) || (await stat(path.join(sourcesDir, destination)).catch(() => null))?.isDirectory();
|
|
187
|
+
if (isFolder)
|
|
188
|
+
destination = path.join(destination, extra.name ?? path.basename(file));
|
|
189
|
+
const target = resolveWithin(sourcesDir, destination);
|
|
190
|
+
if (!target || posix(path.relative(sourcesDir, target)).split("/").some((part) => part.startsWith("."))) {
|
|
191
|
+
throw new Error(`"${destination}" isn't a path inside sources/ (hidden folders are the kit's).`);
|
|
192
|
+
}
|
|
193
|
+
const manifest = await loadManifest(sourcesDir);
|
|
194
|
+
if (extra.replaces) {
|
|
195
|
+
const old = resolveWithin(sourcesDir, extra.replaces);
|
|
196
|
+
if (!old || !(await stat(old).catch(() => null)))
|
|
197
|
+
throw new Error(`"${extra.replaces}" isn't an original in sources/, so it can't be replaced.`);
|
|
198
|
+
}
|
|
199
|
+
const hash = await hashFile(file);
|
|
200
|
+
for (const rel of await scanSources(sourcesDir)) {
|
|
201
|
+
const entry = await freshEntry(path.join(sourcesDir, rel), manifest.files[rel], { kind: "manual" });
|
|
202
|
+
manifest.files[rel] = entry;
|
|
203
|
+
if (entry.hash === hash) {
|
|
204
|
+
await saveManifest(sourcesDir, manifest);
|
|
205
|
+
return { path: rel, duplicateOf: rel };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
209
|
+
try {
|
|
210
|
+
await copyFile(file, target, constants.COPYFILE_EXCL);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code === "EEXIST") {
|
|
214
|
+
throw new Error(`"${destination}" already exists in sources/: originals are never overwritten. Pick another name${extra.replaces ? "" : ", or pass `replaces` for a new version"}.`, {
|
|
215
|
+
cause: error,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
const rel = posix(path.relative(sourcesDir, target));
|
|
221
|
+
const info = await stat(target);
|
|
222
|
+
manifest.files[rel] = {
|
|
223
|
+
hash,
|
|
224
|
+
size: info.size,
|
|
225
|
+
mtimeMs: info.mtimeMs,
|
|
226
|
+
addedAt: now(),
|
|
227
|
+
origin,
|
|
228
|
+
...(extra.replaces ? { replaces: posix(path.relative(sourcesDir, resolveWithin(sourcesDir, extra.replaces))) } : {}),
|
|
229
|
+
...(extra.derivedFrom ? { derivedFrom: extra.derivedFrom } : {}),
|
|
230
|
+
};
|
|
231
|
+
await saveManifest(sourcesDir, manifest);
|
|
232
|
+
return { path: rel };
|
|
233
|
+
}
|
|
234
|
+
/** Adds frontmatter fields and a notice under the frontmatter of a markdown page. */
|
|
235
|
+
async function markPage(page, fields, notice) {
|
|
236
|
+
const text = await readFile(page, "utf8");
|
|
237
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
238
|
+
const lines = Object.entries(fields).map(([key, value]) => `${key}: ${value}`);
|
|
239
|
+
const updated = match
|
|
240
|
+
? `---\n${match[1].split(/\r?\n/).filter((line) => !Object.keys(fields).some((key) => line.startsWith(`${key}:`))).concat(lines).join("\n")}\n---\n\n> ${notice}\n\n${text.slice(match[0].length).replace(/^\s+/, "")}`
|
|
241
|
+
: `---\n${lines.join("\n")}\n---\n\n> ${notice}\n\n${text}`;
|
|
242
|
+
await writeFile(page, updated);
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Moves an original to `.agent-kit/retired/` (never deleted: the person empties that folder)
|
|
246
|
+
* and records why. Its summary pages are marked: retired if the original was wrong (what it
|
|
247
|
+
* taught was wrong), superseded if it was replaced, pointing to the replacement's summary
|
|
248
|
+
* when there is one.
|
|
249
|
+
*/
|
|
250
|
+
export function retireSource(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
|
|
251
|
+
return serialized(sourcesDir, () => retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy));
|
|
252
|
+
}
|
|
253
|
+
async function retireSourceNow(sourcesDir, knowledgeDir, projectDir, source, why, reason, replacedBy) {
|
|
254
|
+
const full = resolveWithin(sourcesDir, source);
|
|
255
|
+
if (!full || !(await stat(full).catch(() => null))?.isFile())
|
|
256
|
+
throw new Error(`"${source}" isn't an original in sources/.`);
|
|
257
|
+
const rel = posix(path.relative(sourcesDir, full));
|
|
258
|
+
if (rel.split("/").some((part) => part.startsWith(".")))
|
|
259
|
+
throw new Error(`"${source}" is one of the kit's own files.`);
|
|
260
|
+
const replacement = replacedBy ? resolveWithin(sourcesDir, replacedBy) : undefined;
|
|
261
|
+
if (replacedBy && (!replacement || !(await stat(replacement).catch(() => null))))
|
|
262
|
+
throw new Error(`"${replacedBy}" isn't an original in sources/.`);
|
|
263
|
+
const summaries = knowledgeDir ? await summariesByOriginal(knowledgeDir) : new Map();
|
|
264
|
+
const manifest = await loadManifest(sourcesDir);
|
|
265
|
+
const stamp = now();
|
|
266
|
+
const movedTo = posix(path.join(KIT_DIR, RETIRED_DIR, `${stamp.replace(/[:.]/g, "-")}-${path.basename(rel)}`));
|
|
267
|
+
await mkdir(path.join(sourcesDir, KIT_DIR, RETIRED_DIR), { recursive: true });
|
|
268
|
+
await rename(full, path.join(sourcesDir, movedTo));
|
|
269
|
+
manifest.retired.push({
|
|
270
|
+
path: rel,
|
|
271
|
+
movedTo,
|
|
272
|
+
retiredAt: stamp,
|
|
273
|
+
why,
|
|
274
|
+
reason,
|
|
275
|
+
...(replacement ? { replacedBy: posix(path.relative(sourcesDir, replacement)) } : {}),
|
|
276
|
+
...(manifest.files[rel] ? { entry: manifest.files[rel] } : {}),
|
|
277
|
+
});
|
|
278
|
+
delete manifest.files[rel];
|
|
279
|
+
await saveManifest(sourcesDir, manifest);
|
|
280
|
+
const date = stamp.slice(0, 10);
|
|
281
|
+
const replacementSummary = replacement ? summaries.get(path.normalize(replacement))?.[0] : undefined;
|
|
282
|
+
const result = { movedTo, summaries: [] };
|
|
283
|
+
for (const page of summaries.get(path.normalize(full)) ?? []) {
|
|
284
|
+
if (why === "wrong") {
|
|
285
|
+
await markPage(page, { status: "retired", retired: date }, `Retired on ${date}: its original was wrong (${reason}). Don't rely on what it says.`);
|
|
286
|
+
result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "retired" });
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const link = replacementSummary ? posix(path.relative(path.dirname(page), replacementSummary)) : undefined;
|
|
290
|
+
await markPage(page, { status: "superseded", superseded: date, ...(link ? { superseded_by: link } : {}) }, `Superseded on ${date}: its original was replaced (${reason})${link ? `; see [the new summary](${link})` : ""}.`);
|
|
291
|
+
result.summaries.push({ page: posix(path.relative(projectDir, page)), marked: "superseded", ...(replacementSummary ? { supersededBy: posix(path.relative(projectDir, replacementSummary)) } : {}) });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Records `source` (relative to `sourcesDir`) as ingested now, with its current hash: a
|
|
298
|
+
* summary page was just written from it, so "changed" means changed after this.
|
|
299
|
+
*/
|
|
300
|
+
export function recordIngest(sourcesDir, source) {
|
|
301
|
+
return serialized(sourcesDir, async () => {
|
|
302
|
+
const full = resolveWithin(sourcesDir, source);
|
|
303
|
+
if (!full || !(await stat(full).catch(() => null))?.isFile())
|
|
304
|
+
return;
|
|
305
|
+
const rel = posix(path.relative(sourcesDir, full));
|
|
306
|
+
const manifest = await loadManifest(sourcesDir);
|
|
307
|
+
const entry = await freshEntry(full, manifest.files[rel], { kind: "manual" });
|
|
308
|
+
entry.ingestedHash = entry.hash;
|
|
309
|
+
manifest.files[rel] = entry;
|
|
310
|
+
await saveManifest(sourcesDir, manifest);
|
|
311
|
+
});
|
|
312
|
+
}
|