obsidian-second-brain 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 +136 -0
- package/config/projects.example.json +13 -0
- package/dist/artifact-frontmatter.js +46 -0
- package/dist/changeset.js +18 -0
- package/dist/classify.js +50 -0
- package/dist/cli-claude-backend.js +40 -0
- package/dist/cli.js +337 -0
- package/dist/config.js +208 -0
- package/dist/consolidation-backend.js +86 -0
- package/dist/consolidation.js +321 -0
- package/dist/episode-file.js +61 -0
- package/dist/episode-patch.js +28 -0
- package/dist/episode-prompt.js +43 -0
- package/dist/filesystem.js +86 -0
- package/dist/git.js +61 -0
- package/dist/ingest-writer.js +86 -0
- package/dist/ingest.js +217 -0
- package/dist/init.js +343 -0
- package/dist/install-manifest.js +56 -0
- package/dist/lock.js +73 -0
- package/dist/logger.js +19 -0
- package/dist/managed-section.js +30 -0
- package/dist/manifest.js +64 -0
- package/dist/ollama-backend.js +49 -0
- package/dist/plist.js +23 -0
- package/dist/render-claude-jsonl.js +179 -0
- package/dist/render-jsonl-markdown.js +116 -0
- package/dist/report.js +244 -0
- package/dist/shell.js +84 -0
- package/dist/slug.js +16 -0
- package/dist/sync.js +103 -0
- package/dist/synthesis.js +14 -0
- package/dist/uninstall.js +80 -0
- package/package.json +44 -0
- package/templates/claude-md-section.md +12 -0
- package/templates/launchd-weekly.plist.template +35 -0
- package/templates/launchd.plist.template +28 -0
- package/templates/vault-agents.md +124 -0
- package/templates/vault-claude-md.md +1 -0
- package/templates/vault-gitignore +12 -0
- package/templates/wiki-index.md +7 -0
- package/templates/wiki-log.md +1 -0
- package/templates/wrap-command.md +99 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, normalize, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
const providerNameSchema = z.enum(["codex", "claude", "gemini"]);
|
|
5
|
+
const runKindSchema = z.enum(["hourly", "manual", "weekly"]);
|
|
6
|
+
const syncProviderSchema = z.object({
|
|
7
|
+
destinationPath: z.string().trim().min(1),
|
|
8
|
+
enabled: z.boolean(),
|
|
9
|
+
name: providerNameSchema,
|
|
10
|
+
sourceRoot: z.string().trim().min(1)
|
|
11
|
+
});
|
|
12
|
+
const synthesisBackendNameSchema = z.enum(["cli-claude", "noop", "ollama"]);
|
|
13
|
+
const synthesisConfigSchema = z.object({
|
|
14
|
+
backend: synthesisBackendNameSchema,
|
|
15
|
+
claudeBin: z.string().trim().min(1).optional(),
|
|
16
|
+
ollamaModel: z.string().trim().min(1).optional()
|
|
17
|
+
});
|
|
18
|
+
const syncConfigSchema = z.object({
|
|
19
|
+
logsRoot: z.string().trim().min(1).optional(),
|
|
20
|
+
providers: z.array(syncProviderSchema),
|
|
21
|
+
synthesis: synthesisConfigSchema.optional(),
|
|
22
|
+
vaultRoot: z.string().trim().min(1)
|
|
23
|
+
}).superRefine((config, context) => {
|
|
24
|
+
const seenNames = new Set();
|
|
25
|
+
for (const [index, provider] of config.providers.entries()) {
|
|
26
|
+
if (seenNames.has(provider.name)) {
|
|
27
|
+
context.addIssue({
|
|
28
|
+
code: z.ZodIssueCode.custom,
|
|
29
|
+
message: `Duplicate provider name: ${provider.name}`,
|
|
30
|
+
path: ["providers", index, "name"]
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
seenNames.add(provider.name);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const cliRequestSchema = z.object({
|
|
38
|
+
all: z.boolean(),
|
|
39
|
+
configPath: z.string().trim().min(1).optional(),
|
|
40
|
+
consolidate: z.boolean().optional(),
|
|
41
|
+
ingest: z.boolean().optional(),
|
|
42
|
+
providerName: providerNameSchema.optional(),
|
|
43
|
+
runKind: runKindSchema.optional()
|
|
44
|
+
});
|
|
45
|
+
const providerAliasSchema = z.union([
|
|
46
|
+
providerNameSchema,
|
|
47
|
+
z.literal("--claude"),
|
|
48
|
+
z.literal("--codex"),
|
|
49
|
+
z.literal("--gemini")
|
|
50
|
+
]);
|
|
51
|
+
const providerAliasToName = {
|
|
52
|
+
"--claude": "claude",
|
|
53
|
+
"--codex": "codex",
|
|
54
|
+
"--gemini": "gemini",
|
|
55
|
+
claude: "claude",
|
|
56
|
+
codex: "codex",
|
|
57
|
+
gemini: "gemini"
|
|
58
|
+
};
|
|
59
|
+
export async function loadConfig(configPath) {
|
|
60
|
+
const fileContent = await readFile(configPath, "utf8");
|
|
61
|
+
const rawConfig = JSON.parse(fileContent);
|
|
62
|
+
const parsedConfig = syncConfigSchema.parse(rawConfig);
|
|
63
|
+
const resolvedConfigPath = resolve(configPath);
|
|
64
|
+
const configDirectory = resolve(resolvedConfigPath, "..");
|
|
65
|
+
const vaultRoot = resolveConfigPath(parsedConfig.vaultRoot, configDirectory);
|
|
66
|
+
const logsRoot = resolveConfigPath(parsedConfig.logsRoot ?? "logs", configDirectory);
|
|
67
|
+
return {
|
|
68
|
+
logsRoot,
|
|
69
|
+
providers: parsedConfig.providers.map((provider) => ({
|
|
70
|
+
destinationPath: normalizeRelativePath(provider.destinationPath),
|
|
71
|
+
enabled: provider.enabled,
|
|
72
|
+
name: provider.name,
|
|
73
|
+
sourceRoot: resolveConfigPath(provider.sourceRoot, configDirectory)
|
|
74
|
+
})),
|
|
75
|
+
// Default stays noop until the cli-claude backend lands (phase 6).
|
|
76
|
+
synthesis: resolveSynthesisConfig(parsedConfig.synthesis),
|
|
77
|
+
vaultRoot
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function parseCliArgs(argv) {
|
|
81
|
+
let all = false;
|
|
82
|
+
let configPath;
|
|
83
|
+
let consolidate = false;
|
|
84
|
+
let ingest = false;
|
|
85
|
+
let providerName;
|
|
86
|
+
let runKind;
|
|
87
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
88
|
+
const currentArg = argv[index];
|
|
89
|
+
if (currentArg === "--all") {
|
|
90
|
+
all = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (currentArg === "--ingest") {
|
|
94
|
+
ingest = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (currentArg === "--consolidate") {
|
|
98
|
+
consolidate = true;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (currentArg === "--config") {
|
|
102
|
+
const nextArg = argv[index + 1];
|
|
103
|
+
if (!nextArg) {
|
|
104
|
+
throw new Error("Missing value for --config");
|
|
105
|
+
}
|
|
106
|
+
configPath = nextArg;
|
|
107
|
+
index += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (currentArg === "--provider") {
|
|
111
|
+
const nextArg = argv[index + 1];
|
|
112
|
+
if (!nextArg) {
|
|
113
|
+
throw new Error("Missing value for --provider");
|
|
114
|
+
}
|
|
115
|
+
providerName = parseProviderName(nextArg);
|
|
116
|
+
index += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (currentArg === "--run") {
|
|
120
|
+
const nextArg = argv[index + 1];
|
|
121
|
+
if (!nextArg) {
|
|
122
|
+
throw new Error("Missing value for --run");
|
|
123
|
+
}
|
|
124
|
+
const parsedRunKind = runKindSchema.safeParse(nextArg);
|
|
125
|
+
if (!parsedRunKind.success) {
|
|
126
|
+
throw new Error(`Unknown run kind: ${nextArg}`);
|
|
127
|
+
}
|
|
128
|
+
runKind = parsedRunKind.data;
|
|
129
|
+
index += 1;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (providerAliasSchema.safeParse(currentArg).success) {
|
|
133
|
+
providerName = parseProviderName(currentArg);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Unknown argument: ${currentArg}`);
|
|
137
|
+
}
|
|
138
|
+
const request = {
|
|
139
|
+
...(configPath ? { configPath } : {}),
|
|
140
|
+
...(consolidate ? { consolidate } : {}),
|
|
141
|
+
...(ingest ? { ingest } : {}),
|
|
142
|
+
...(providerName ? { providerName } : {}),
|
|
143
|
+
...(runKind ? { runKind } : {}),
|
|
144
|
+
all
|
|
145
|
+
};
|
|
146
|
+
return cliRequestSchema.parse(request);
|
|
147
|
+
}
|
|
148
|
+
export function selectProviders(configuredProviders, request) {
|
|
149
|
+
const selectedProviders = request.providerName
|
|
150
|
+
? selectSingleProvider(configuredProviders, request.providerName)
|
|
151
|
+
: request.all
|
|
152
|
+
? [...configuredProviders]
|
|
153
|
+
: configuredProviders.filter((provider) => provider.enabled);
|
|
154
|
+
if (selectedProviders.length === 0) {
|
|
155
|
+
throw new Error("No providers selected");
|
|
156
|
+
}
|
|
157
|
+
return selectedProviders;
|
|
158
|
+
}
|
|
159
|
+
function selectSingleProvider(configuredProviders, providerName) {
|
|
160
|
+
const selectedProvider = configuredProviders.find((provider) => provider.name === providerName);
|
|
161
|
+
if (!selectedProvider) {
|
|
162
|
+
throw new Error(`Unknown provider: ${providerName}`);
|
|
163
|
+
}
|
|
164
|
+
return [selectedProvider];
|
|
165
|
+
}
|
|
166
|
+
function parseProviderName(input) {
|
|
167
|
+
const parsedProvider = providerAliasSchema.safeParse(input);
|
|
168
|
+
if (!parsedProvider.success) {
|
|
169
|
+
throw new Error(`Unknown provider: ${input}`);
|
|
170
|
+
}
|
|
171
|
+
return providerAliasToName[parsedProvider.data];
|
|
172
|
+
}
|
|
173
|
+
function resolveConfigPath(inputPath, configDirectory) {
|
|
174
|
+
const expandedPath = expandEnvironmentVariables(inputPath);
|
|
175
|
+
return resolve(configDirectory, expandedPath);
|
|
176
|
+
}
|
|
177
|
+
function resolveSynthesisConfig(synthesis) {
|
|
178
|
+
if (synthesis === undefined) {
|
|
179
|
+
return { backend: "noop" };
|
|
180
|
+
}
|
|
181
|
+
// claudeBin supports ${ENV} expansion but is never resolved against the
|
|
182
|
+
// config directory: a bare command name ("claude") must stay a PATH lookup.
|
|
183
|
+
return {
|
|
184
|
+
...synthesis,
|
|
185
|
+
...(synthesis.claudeBin === undefined
|
|
186
|
+
? {}
|
|
187
|
+
: { claudeBin: expandEnvironmentVariables(synthesis.claudeBin) })
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function expandEnvironmentVariables(inputPath) {
|
|
191
|
+
return inputPath.replaceAll(/\$\{([A-Z0-9_]+)\}/gi, (_, variableName) => {
|
|
192
|
+
const variableValue = process.env[variableName];
|
|
193
|
+
if (!variableValue) {
|
|
194
|
+
throw new Error(`Missing environment variable: ${variableName}`);
|
|
195
|
+
}
|
|
196
|
+
return variableValue;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function normalizeRelativePath(inputPath) {
|
|
200
|
+
const expandedPath = expandEnvironmentVariables(inputPath);
|
|
201
|
+
const normalizedPath = normalize(expandedPath);
|
|
202
|
+
if (normalizedPath === "." ||
|
|
203
|
+
isAbsolute(normalizedPath) ||
|
|
204
|
+
normalizedPath.startsWith("..")) {
|
|
205
|
+
throw new Error(`Invalid destinationPath: ${inputPath}`);
|
|
206
|
+
}
|
|
207
|
+
return normalizedPath;
|
|
208
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { extractResultText } from "./cli-claude-backend.js";
|
|
3
|
+
import { runCommand } from "./shell.js";
|
|
4
|
+
const MAX_PAGE_CONTEXT_CHARS = 24_000;
|
|
5
|
+
const MAX_EPISODE_CHARS = 8_000;
|
|
6
|
+
const consolidationPatchSchema = z.object({
|
|
7
|
+
section: z.union([z.string().trim().min(1).max(20_000), z.null()])
|
|
8
|
+
});
|
|
9
|
+
export const noopConsolidationBackend = {
|
|
10
|
+
consolidate: async () => ({ section: null }),
|
|
11
|
+
name: "noop"
|
|
12
|
+
};
|
|
13
|
+
const defaultDependencies = {
|
|
14
|
+
run: runCommand
|
|
15
|
+
};
|
|
16
|
+
export function createCliClaudeConsolidationBackend(options = {}, dependencies = defaultDependencies) {
|
|
17
|
+
const claudeBin = options.claudeBin ?? "claude";
|
|
18
|
+
return {
|
|
19
|
+
consolidate: async (input) => {
|
|
20
|
+
const prompt = buildConsolidationPrompt(input);
|
|
21
|
+
// Weekly pass uses a frontier-class model on purpose (design 4.3):
|
|
22
|
+
// it edits trusted pages, so quality beats cost here.
|
|
23
|
+
const result = await dependencies.run(claudeBin, ["-p", "--model", "sonnet", "--output-format", "json", prompt], process.cwd());
|
|
24
|
+
const parsed = JSON.parse(stripFences(extractResultText(result.stdout)));
|
|
25
|
+
return consolidationPatchSchema.parse(parsed);
|
|
26
|
+
},
|
|
27
|
+
name: "cli-claude"
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function resolveConsolidationBackend(name, backends = {}) {
|
|
31
|
+
if (name === "noop") {
|
|
32
|
+
return noopConsolidationBackend;
|
|
33
|
+
}
|
|
34
|
+
// The weekly roll-up stays on the paid backend even in all-local mode
|
|
35
|
+
// (design section 6) — ollama maps to cli-claude here.
|
|
36
|
+
const effective = name === "ollama" ? "cli-claude" : name;
|
|
37
|
+
const backend = backends[effective];
|
|
38
|
+
if (backend !== undefined) {
|
|
39
|
+
return backend;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Unknown consolidation backend: ${name} (available: noop, cli-claude, ollama -> cli-claude)`);
|
|
42
|
+
}
|
|
43
|
+
function buildConsolidationPrompt(input) {
|
|
44
|
+
const episodes = input.episodes
|
|
45
|
+
.map((episode) => {
|
|
46
|
+
const truncated = episode.content.length > MAX_EPISODE_CHARS
|
|
47
|
+
? `${episode.content.slice(0, MAX_EPISODE_CHARS)}\n[truncated]`
|
|
48
|
+
: episode.content;
|
|
49
|
+
return [`### Episode (${episode.date}) ${episode.title}`, "", truncated].join("\n");
|
|
50
|
+
})
|
|
51
|
+
.join("\n\n---\n\n");
|
|
52
|
+
const existing = input.existingPage
|
|
53
|
+
? input.existingPage.length > MAX_PAGE_CONTEXT_CHARS
|
|
54
|
+
? `${input.existingPage.slice(0, MAX_PAGE_CONTEXT_CHARS)}\n[truncated]`
|
|
55
|
+
: input.existingPage
|
|
56
|
+
: "(no page exists yet)";
|
|
57
|
+
return [
|
|
58
|
+
"You are the weekly consolidation pass of an LLM-maintained wiki. Compress",
|
|
59
|
+
`the episodic notes below into ONE addition for the trusted entity page of`,
|
|
60
|
+
`the project "${input.project}".`,
|
|
61
|
+
"",
|
|
62
|
+
"Rules:",
|
|
63
|
+
'- Respond with ONLY strict JSON: {"section": "..."} — no prose, no fences.',
|
|
64
|
+
"- The section is markdown BODY text that will be APPENDED to the page",
|
|
65
|
+
" under a dated heading; never include frontmatter or a top-level # title.",
|
|
66
|
+
"- Additive and factual: summarize what happened, decisions made and why,",
|
|
67
|
+
" and open follow-ups. Cite episode dates.",
|
|
68
|
+
"- If the episodes contradict the existing page, state the contradiction",
|
|
69
|
+
" explicitly and cite both sides — do not silently overwrite.",
|
|
70
|
+
"- Use [[wikilinks]] only for project-specific entities. No emojis.",
|
|
71
|
+
'- If the episodes contain nothing wiki-worthy, respond {"section": null}.',
|
|
72
|
+
"",
|
|
73
|
+
"## Existing page",
|
|
74
|
+
"",
|
|
75
|
+
existing,
|
|
76
|
+
"",
|
|
77
|
+
"## Episodes",
|
|
78
|
+
"",
|
|
79
|
+
episodes
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
function stripFences(raw) {
|
|
83
|
+
const trimmed = raw.trim();
|
|
84
|
+
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/u.exec(trimmed);
|
|
85
|
+
return fenced ? fenced[1] : trimmed;
|
|
86
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import { hashContent } from "./changeset.js";
|
|
4
|
+
import { parseEpisodeFile } from "./episode-file.js";
|
|
5
|
+
import { collectFiles, writeFileAtomic } from "./filesystem.js";
|
|
6
|
+
import { appendLogEntry } from "./ingest-writer.js";
|
|
7
|
+
import { buildSourceKey, loadManifest, saveManifest, withManifestEntries } from "./manifest.js";
|
|
8
|
+
const CONSOLIDATION_PROVIDER = "consolidation";
|
|
9
|
+
// Structural wiki files a project page must never collide with (design 7.2).
|
|
10
|
+
const RESERVED_PAGES = new Set(["episodes", "index", "log", "reports"]);
|
|
11
|
+
export async function runConsolidation(input) {
|
|
12
|
+
const scan = await scanEpisodes(input.episodesRoot);
|
|
13
|
+
const manifest = await loadManifest(input.manifestPath);
|
|
14
|
+
const work = scan.sources.filter((source) => {
|
|
15
|
+
const entry = manifest.entries[source.key];
|
|
16
|
+
return entry === undefined || entry.contentHash !== source.contentHash;
|
|
17
|
+
});
|
|
18
|
+
const freshQuarantined = scan.quarantined.filter((artifact) => manifest.entries[`quarantine:episode:${artifact.contentHash}`] === undefined);
|
|
19
|
+
if (work.length === 0 && freshQuarantined.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
episodesConsolidated: 0,
|
|
22
|
+
failures: [],
|
|
23
|
+
pagesCreated: [],
|
|
24
|
+
pagesUpdated: [],
|
|
25
|
+
quarantined: [],
|
|
26
|
+
skipped: true,
|
|
27
|
+
skippedProjects: []
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const now = input.now();
|
|
31
|
+
const date = now.toISOString().slice(0, 10);
|
|
32
|
+
const groups = new Map();
|
|
33
|
+
for (const source of work) {
|
|
34
|
+
const group = groups.get(source.project);
|
|
35
|
+
if (group === undefined) {
|
|
36
|
+
groups.set(source.project, [source]);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
group.push(source);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const failures = [];
|
|
43
|
+
const skippedProjects = [];
|
|
44
|
+
const pagesCreated = [];
|
|
45
|
+
const pagesUpdated = [];
|
|
46
|
+
const consolidatedEntries = {};
|
|
47
|
+
const ingestedAt = now.toISOString();
|
|
48
|
+
const wikiRoot = resolve(input.wikiRoot);
|
|
49
|
+
let episodesConsolidated = 0;
|
|
50
|
+
for (const [project, sources] of groups) {
|
|
51
|
+
if (RESERVED_PAGES.has(project)) {
|
|
52
|
+
failures.push({
|
|
53
|
+
project,
|
|
54
|
+
reason: "Project name collides with a reserved wiki file"
|
|
55
|
+
});
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const pagePath = join(wikiRoot, `${project}.md`);
|
|
59
|
+
if (!pagePath.startsWith(`${wikiRoot}${sep}`)) {
|
|
60
|
+
failures.push({ project, reason: "Page path escapes the wiki root" });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
let existingPage;
|
|
64
|
+
try {
|
|
65
|
+
existingPage = await readFile(pagePath, "utf8");
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (!isMissingFileError(error)) {
|
|
69
|
+
failures.push({
|
|
70
|
+
project,
|
|
71
|
+
reason: error instanceof Error ? error.message : "Unknown error"
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
let section;
|
|
77
|
+
try {
|
|
78
|
+
const output = await input.backend.consolidate({
|
|
79
|
+
episodes: sources.map((source) => source.episode),
|
|
80
|
+
existingPage,
|
|
81
|
+
project
|
|
82
|
+
});
|
|
83
|
+
section = output.section;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
failures.push({
|
|
87
|
+
project,
|
|
88
|
+
reason: error instanceof Error ? error.message : "Unknown error"
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (section === null) {
|
|
93
|
+
skippedProjects.push(project);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// A write failure must only fail THIS project: earlier projects keep
|
|
97
|
+
// their manifest advance and the run still reaches log + manifest.
|
|
98
|
+
try {
|
|
99
|
+
// The backend call takes seconds; re-read the page right before
|
|
100
|
+
// writing so a concurrent manual edit is appended to, not clobbered.
|
|
101
|
+
let pageNow;
|
|
102
|
+
try {
|
|
103
|
+
pageNow = await readFile(pagePath, "utf8");
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!isMissingFileError(error)) {
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Additive edit only (vault AGENTS.md): a dated section is appended;
|
|
111
|
+
// the existing page body is never rewritten.
|
|
112
|
+
const nextPage = pageNow === undefined
|
|
113
|
+
? buildNewPage(project, date, section)
|
|
114
|
+
: `${pageNow.replace(/\n+$/u, "")}\n\n## Consolidation ${date}\n\n${section.trim()}\n`;
|
|
115
|
+
await writeFileAtomic(pagePath, nextPage);
|
|
116
|
+
if (pageNow === undefined) {
|
|
117
|
+
pagesCreated.push(project);
|
|
118
|
+
await addToIndex(input.indexPath, project, date);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
pagesUpdated.push(project);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
failures.push({
|
|
126
|
+
project,
|
|
127
|
+
reason: error instanceof Error ? error.message : "Unknown error"
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const source of sources) {
|
|
132
|
+
consolidatedEntries[source.key] = {
|
|
133
|
+
contentHash: source.contentHash,
|
|
134
|
+
ingestedAt
|
|
135
|
+
};
|
|
136
|
+
episodesConsolidated += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let report;
|
|
140
|
+
// The report is a wiki page: it belongs to the pages step, before log and
|
|
141
|
+
// manifest, so a crashed run redoes it instead of losing it forever.
|
|
142
|
+
if (input.reportWriter) {
|
|
143
|
+
report = await input.reportWriter.addConsolidation({
|
|
144
|
+
episodesConsolidated,
|
|
145
|
+
failures,
|
|
146
|
+
pagesCreated,
|
|
147
|
+
pagesUpdated,
|
|
148
|
+
quarantined: freshQuarantined,
|
|
149
|
+
skippedProjects
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (pagesCreated.length > 0 ||
|
|
153
|
+
pagesUpdated.length > 0 ||
|
|
154
|
+
failures.length > 0 ||
|
|
155
|
+
freshQuarantined.length > 0 ||
|
|
156
|
+
report !== undefined) {
|
|
157
|
+
await appendLogEntry(input.logPath, buildLogEntry({
|
|
158
|
+
date,
|
|
159
|
+
failures,
|
|
160
|
+
freshQuarantined,
|
|
161
|
+
pagesCreated,
|
|
162
|
+
pagesUpdated,
|
|
163
|
+
reportLink: report?.wikiLink
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
const quarantineEntries = Object.fromEntries(freshQuarantined.map((artifact) => [
|
|
167
|
+
`quarantine:episode:${artifact.contentHash}`,
|
|
168
|
+
{ contentHash: artifact.contentHash, ingestedAt }
|
|
169
|
+
]));
|
|
170
|
+
await saveManifest(input.manifestPath, withManifestEntries(manifest, { ...consolidatedEntries, ...quarantineEntries }));
|
|
171
|
+
return {
|
|
172
|
+
episodesConsolidated,
|
|
173
|
+
failures,
|
|
174
|
+
pagesCreated,
|
|
175
|
+
pagesUpdated,
|
|
176
|
+
quarantined: freshQuarantined,
|
|
177
|
+
...(report ? { reportPath: report.path } : {}),
|
|
178
|
+
skipped: false,
|
|
179
|
+
skippedProjects
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async function scanEpisodes(episodesRoot) {
|
|
183
|
+
let files;
|
|
184
|
+
try {
|
|
185
|
+
files = await collectFiles(episodesRoot);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (isMissingFileError(error)) {
|
|
189
|
+
return { quarantined: [], sources: [] };
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
const quarantined = [];
|
|
194
|
+
const sources = [];
|
|
195
|
+
const resolvedRoot = await realpath(episodesRoot);
|
|
196
|
+
for (const file of files) {
|
|
197
|
+
if (!file.relativePath.endsWith(".md")) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const stat = await lstat(file.absolutePath);
|
|
201
|
+
if (stat.isSymbolicLink()) {
|
|
202
|
+
quarantined.push({
|
|
203
|
+
contentHash: hashContent(`symlink:${file.absolutePath}`),
|
|
204
|
+
path: file.absolutePath,
|
|
205
|
+
reason: "Refused symlinked episode"
|
|
206
|
+
});
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const resolved = await realpath(file.absolutePath);
|
|
210
|
+
if (!resolved.startsWith(`${resolvedRoot}${sep}`)) {
|
|
211
|
+
quarantined.push({
|
|
212
|
+
contentHash: hashContent(`escape:${file.absolutePath}`),
|
|
213
|
+
path: file.absolutePath,
|
|
214
|
+
reason: "Episode resolves outside the episodes root"
|
|
215
|
+
});
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let content = "";
|
|
219
|
+
try {
|
|
220
|
+
content = await readFile(file.absolutePath, "utf8");
|
|
221
|
+
const parsed = parseEpisodeFile(content);
|
|
222
|
+
sources.push({
|
|
223
|
+
contentHash: hashContent(content),
|
|
224
|
+
episode: {
|
|
225
|
+
content: parsed.body,
|
|
226
|
+
date: parsed.frontmatter.date,
|
|
227
|
+
title: parsed.frontmatter.title
|
|
228
|
+
},
|
|
229
|
+
key: buildSourceKey({
|
|
230
|
+
provider: CONSOLIDATION_PROVIDER,
|
|
231
|
+
sessionId: file.relativePath.replace(/\.md$/u, ""),
|
|
232
|
+
type: "episode"
|
|
233
|
+
}),
|
|
234
|
+
project: parsed.frontmatter.project
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
quarantined.push({
|
|
239
|
+
contentHash: hashContent(`${file.absolutePath}\n${content}`),
|
|
240
|
+
path: file.absolutePath,
|
|
241
|
+
reason: error instanceof Error ? error.message : "Unknown error"
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { quarantined, sources };
|
|
246
|
+
}
|
|
247
|
+
function buildNewPage(project, date, section) {
|
|
248
|
+
return [
|
|
249
|
+
"---",
|
|
250
|
+
`title: ${project}`,
|
|
251
|
+
"type: entity",
|
|
252
|
+
`tags: [${project}]`,
|
|
253
|
+
"sources: []",
|
|
254
|
+
`created: ${date}`,
|
|
255
|
+
`updated: ${date}`,
|
|
256
|
+
"---",
|
|
257
|
+
"",
|
|
258
|
+
`# ${project}`,
|
|
259
|
+
"",
|
|
260
|
+
`## Consolidation ${date}`,
|
|
261
|
+
"",
|
|
262
|
+
section.trim(),
|
|
263
|
+
""
|
|
264
|
+
].join("\n");
|
|
265
|
+
}
|
|
266
|
+
async function addToIndex(indexPath, project, date) {
|
|
267
|
+
let index = "";
|
|
268
|
+
try {
|
|
269
|
+
index = await readFile(indexPath, "utf8");
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (!isMissingFileError(error)) {
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const entry = `- [[${project}]] — Project page created by weekly consolidation on ${date}.`;
|
|
277
|
+
const lines = index.length > 0 ? index.split("\n") : ["# Wiki Index", ""];
|
|
278
|
+
const headingIndex = lines.findIndex((line) => line.trim() === "## Entities");
|
|
279
|
+
if (headingIndex === -1) {
|
|
280
|
+
lines.push("", "## Entities", "", entry);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
let insertAt = lines.length;
|
|
284
|
+
for (let cursor = headingIndex + 1; cursor < lines.length; cursor += 1) {
|
|
285
|
+
if (lines[cursor].startsWith("## ")) {
|
|
286
|
+
insertAt = cursor;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
while (insertAt > headingIndex + 1 && lines[insertAt - 1].trim().length === 0) {
|
|
291
|
+
insertAt -= 1;
|
|
292
|
+
}
|
|
293
|
+
lines.splice(insertAt, 0, entry);
|
|
294
|
+
}
|
|
295
|
+
await writeFileAtomic(indexPath, `${lines.join("\n").replace(/\n+$/u, "")}\n`);
|
|
296
|
+
}
|
|
297
|
+
function buildLogEntry(input) {
|
|
298
|
+
const lines = [`## [${input.date}] consolidation | weekly roll-up`];
|
|
299
|
+
if (input.reportLink !== undefined) {
|
|
300
|
+
lines.push(`Run report: [[${input.reportLink}]]`);
|
|
301
|
+
}
|
|
302
|
+
if (input.pagesCreated.length > 0) {
|
|
303
|
+
lines.push(`Pages created: ${input.pagesCreated.map((page) => `[[${page}]]`).join(", ")}`);
|
|
304
|
+
}
|
|
305
|
+
if (input.pagesUpdated.length > 0) {
|
|
306
|
+
lines.push(`Pages updated: ${input.pagesUpdated.map((page) => `[[${page}]]`).join(", ")}`);
|
|
307
|
+
}
|
|
308
|
+
for (const failure of input.failures) {
|
|
309
|
+
lines.push(`Failed project (manifest not advanced): ${failure.project} — ${failure.reason}`);
|
|
310
|
+
}
|
|
311
|
+
for (const artifact of input.freshQuarantined) {
|
|
312
|
+
lines.push(`Quarantined episode: ${artifact.path} — ${artifact.reason}`);
|
|
313
|
+
}
|
|
314
|
+
return lines.join("\n");
|
|
315
|
+
}
|
|
316
|
+
function isMissingFileError(error) {
|
|
317
|
+
return (error instanceof Error &&
|
|
318
|
+
"code" in error &&
|
|
319
|
+
typeof error.code === "string" &&
|
|
320
|
+
error.code === "ENOENT");
|
|
321
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isProjectSegment } from "./slug.js";
|
|
3
|
+
const episodeFrontmatterSchema = z.object({
|
|
4
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u),
|
|
5
|
+
project: z
|
|
6
|
+
.string()
|
|
7
|
+
.refine(isProjectSegment, {
|
|
8
|
+
message: "project must be a kebab-case slug or the reserved _inbox"
|
|
9
|
+
}),
|
|
10
|
+
title: z.string().min(1),
|
|
11
|
+
type: z.literal("episode")
|
|
12
|
+
});
|
|
13
|
+
export function parseEpisodeFile(content) {
|
|
14
|
+
// Editors on other machines may save episodes with a BOM or CRLF endings;
|
|
15
|
+
// both would otherwise quarantine a perfectly valid file.
|
|
16
|
+
const lines = content.replace(/^\uFEFF/u, "").split(/\r?\n/u);
|
|
17
|
+
if (lines[0] !== "---") {
|
|
18
|
+
throw new Error("Episode is missing its opening frontmatter fence");
|
|
19
|
+
}
|
|
20
|
+
const closingIndex = lines.indexOf("---", 1);
|
|
21
|
+
if (closingIndex === -1) {
|
|
22
|
+
throw new Error("Episode is missing its closing frontmatter fence");
|
|
23
|
+
}
|
|
24
|
+
const fields = {};
|
|
25
|
+
for (const line of lines.slice(1, closingIndex)) {
|
|
26
|
+
if (line.trim().length === 0) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const separatorIndex = line.indexOf(":");
|
|
30
|
+
if (separatorIndex === -1) {
|
|
31
|
+
throw new Error(`Malformed frontmatter line: ${line}`);
|
|
32
|
+
}
|
|
33
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
34
|
+
// Last-wins merging would let a later line silently shadow an already
|
|
35
|
+
// validated value; duplicates are author mistakes worth surfacing.
|
|
36
|
+
if (Object.hasOwn(fields, key)) {
|
|
37
|
+
throw new Error(`Duplicate frontmatter key: ${key}`);
|
|
38
|
+
}
|
|
39
|
+
fields[key] = decodeScalar(line.slice(separatorIndex + 1).trim());
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
body: lines.slice(closingIndex + 1).join("\n").replace(/^\n+/u, ""),
|
|
43
|
+
frontmatter: episodeFrontmatterSchema.parse(fields)
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// The episode writer JSON-quotes backend-controlled scalars (titles); decode
|
|
47
|
+
// them back, falling back to the raw value for plain scalars.
|
|
48
|
+
function decodeScalar(value) {
|
|
49
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
50
|
+
try {
|
|
51
|
+
const decoded = JSON.parse(value);
|
|
52
|
+
if (typeof decoded === "string") {
|
|
53
|
+
return decoded;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|