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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/config/projects.example.json +13 -0
  4. package/dist/artifact-frontmatter.js +46 -0
  5. package/dist/changeset.js +18 -0
  6. package/dist/classify.js +50 -0
  7. package/dist/cli-claude-backend.js +40 -0
  8. package/dist/cli.js +337 -0
  9. package/dist/config.js +208 -0
  10. package/dist/consolidation-backend.js +86 -0
  11. package/dist/consolidation.js +321 -0
  12. package/dist/episode-file.js +61 -0
  13. package/dist/episode-patch.js +28 -0
  14. package/dist/episode-prompt.js +43 -0
  15. package/dist/filesystem.js +86 -0
  16. package/dist/git.js +61 -0
  17. package/dist/ingest-writer.js +86 -0
  18. package/dist/ingest.js +217 -0
  19. package/dist/init.js +343 -0
  20. package/dist/install-manifest.js +56 -0
  21. package/dist/lock.js +73 -0
  22. package/dist/logger.js +19 -0
  23. package/dist/managed-section.js +30 -0
  24. package/dist/manifest.js +64 -0
  25. package/dist/ollama-backend.js +49 -0
  26. package/dist/plist.js +23 -0
  27. package/dist/render-claude-jsonl.js +179 -0
  28. package/dist/render-jsonl-markdown.js +116 -0
  29. package/dist/report.js +244 -0
  30. package/dist/shell.js +84 -0
  31. package/dist/slug.js +16 -0
  32. package/dist/sync.js +103 -0
  33. package/dist/synthesis.js +14 -0
  34. package/dist/uninstall.js +80 -0
  35. package/package.json +44 -0
  36. package/templates/claude-md-section.md +12 -0
  37. package/templates/launchd-weekly.plist.template +35 -0
  38. package/templates/launchd.plist.template +28 -0
  39. package/templates/vault-agents.md +124 -0
  40. package/templates/vault-claude-md.md +1 -0
  41. package/templates/vault-gitignore +12 -0
  42. package/templates/wiki-index.md +7 -0
  43. package/templates/wiki-log.md +1 -0
  44. 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
+ }