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/init.js ADDED
@@ -0,0 +1,343 @@
1
+ import { constants as fsConstants } from "node:fs";
2
+ import { access, lstat, mkdir, readFile, realpath } from "node:fs/promises";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { writeFileAtomic } from "./filesystem.js";
5
+ import { emptyInstallManifest, recordInstalledFile, saveInstallManifest } from "./install-manifest.js";
6
+ import { applyManagedSection } from "./managed-section.js";
7
+ import { installLaunchdJob, renderTemplate } from "./plist.js";
8
+ export const HOURLY_LABEL = "com.second-brain.hourly";
9
+ export const WEEKLY_LABEL = "com.second-brain.weekly";
10
+ export async function runInit(options, dependencies) {
11
+ if (options.force && options.keepExisting) {
12
+ throw new Error("--force and --keep-existing are mutually exclusive");
13
+ }
14
+ const claudeDir = resolve(options.claudeDir);
15
+ const vaultDir = resolve(options.vaultDir);
16
+ const warnings = [];
17
+ await validateClaudeDir(claudeDir);
18
+ await validateVaultDir(vaultDir, options.newVault, dependencies.home);
19
+ detectSyncedVault(vaultDir, warnings);
20
+ const binPath = join(dependencies.appDir, "dist", "cli.js");
21
+ try {
22
+ await access(binPath);
23
+ }
24
+ catch {
25
+ throw new Error(`Built bin not found at ${binPath} — run \`npm run build\` first`);
26
+ }
27
+ const created = [];
28
+ const kept = [];
29
+ const overridden = [];
30
+ let manifest = emptyInstallManifest();
31
+ const date = dependencies.now().toISOString().slice(0, 10);
32
+ const placeFile = async (filePath, content, record) => {
33
+ const outcome = await placeFileWithPolicy(filePath, content, options, dependencies, date);
34
+ if (outcome === "created") {
35
+ created.push(filePath);
36
+ }
37
+ else if (outcome === "overridden") {
38
+ overridden.push(filePath);
39
+ }
40
+ else {
41
+ kept.push(filePath);
42
+ }
43
+ // A kept file that already matches the template is ours too — record it
44
+ // so uninstall can clean it up.
45
+ if (record && (outcome !== "kept" || (await fileMatches(filePath, content)))) {
46
+ manifest = recordInstalledFile(manifest, filePath, content);
47
+ }
48
+ };
49
+ // 1. Vault scaffold (retained by uninstall — never recorded in the manifest).
50
+ await mkdir(join(vaultDir, "raw", "artifacts"), { recursive: true });
51
+ await mkdir(join(vaultDir, "raw", "projects"), { recursive: true });
52
+ await mkdir(join(vaultDir, "wiki", "episodes"), { recursive: true });
53
+ const template = (name) => readFile(join(dependencies.appDir, "templates", name), "utf8");
54
+ await placeFile(join(vaultDir, "AGENTS.md"), await template("vault-agents.md"), false);
55
+ // Claude Code loads CLAUDE.md, not AGENTS.md: the include makes a session
56
+ // opened inside the vault follow the same LLM-wiki spec.
57
+ await placeFile(join(vaultDir, "CLAUDE.md"), await template("vault-claude-md.md"), false);
58
+ await placeFile(join(vaultDir, ".gitignore"), await template("vault-gitignore"), false);
59
+ await placeFile(join(vaultDir, "wiki", "index.md"), await template("wiki-index.md"), false);
60
+ await placeFile(join(vaultDir, "wiki", "log.md"), await template("wiki-log.md"), false);
61
+ await createIfAbsent(join(vaultDir, "wiki", ".ingest-state.json"), "{}\n", created);
62
+ await createIfAbsent(join(vaultDir, "raw", "artifacts", ".gitkeep"), "", created);
63
+ await createIfAbsent(join(vaultDir, "wiki", "episodes", ".gitkeep"), "", created);
64
+ await initVaultGit(vaultDir, dependencies, warnings);
65
+ // 2. Synthesis preflight (design 7.6): the backend must work in a
66
+ // launchd-equivalent environment, otherwise fall back to noop.
67
+ const { backend, claudeBin } = await preflightClaude(dependencies, warnings);
68
+ // 3. Generated config — recorded so uninstall can remove it if unchanged.
69
+ const configContent = `${JSON.stringify({
70
+ logsRoot: "../logs",
71
+ providers: [
72
+ {
73
+ destinationPath: "sessions/claude",
74
+ enabled: true,
75
+ name: "claude",
76
+ sourceRoot: join(claudeDir, "projects")
77
+ }
78
+ ],
79
+ synthesis: claudeBin === undefined ? { backend } : { backend, claudeBin },
80
+ vaultRoot: join(vaultDir, "raw", "projects")
81
+ }, null, 2)}\n`;
82
+ await placeFile(join(dependencies.appDir, "config", "projects.json"), configContent, true);
83
+ // 4. Capture layer: /wrap command + managed CLAUDE.md section.
84
+ await mkdir(join(claudeDir, "commands"), { recursive: true });
85
+ const wrapContent = renderTemplate(await template("wrap-command.md"), {
86
+ VAULT: vaultDir
87
+ });
88
+ await placeFile(join(claudeDir, "commands", "wrap.md"), wrapContent, true);
89
+ await installManagedSection(join(claudeDir, "CLAUDE.md"), renderTemplate(await template("claude-md-section.md"), { VAULT: vaultDir }), date, created, overridden, warnings, dependencies.log);
90
+ // 5. launchd jobs from templates.
91
+ const plists = [];
92
+ if (!options.skipLaunchd) {
93
+ const variables = {
94
+ APP_DIR: dependencies.appDir,
95
+ BIN_PATH: binPath,
96
+ NODE_PATH: dependencies.nodePath
97
+ };
98
+ for (const [label, templateName] of [
99
+ [HOURLY_LABEL, "launchd.plist.template"],
100
+ [WEEKLY_LABEL, "launchd-weekly.plist.template"]
101
+ ]) {
102
+ const content = renderTemplate(await template(templateName), variables);
103
+ const plistPath = await installLaunchdJob({
104
+ exec: dependencies.exec,
105
+ label,
106
+ launchAgentsDir: dependencies.launchAgentsDir,
107
+ plistContent: content,
108
+ uid: dependencies.uid
109
+ });
110
+ manifest = recordInstalledFile(manifest, plistPath, content);
111
+ plists.push(plistPath);
112
+ }
113
+ }
114
+ await saveInstallManifest(installManifestPath(dependencies.appDir), manifest);
115
+ for (const warning of warnings) {
116
+ dependencies.log(`warning: ${warning}`);
117
+ }
118
+ return { backend, created, kept, overridden, plists, warnings };
119
+ }
120
+ export function installManifestPath(appDir) {
121
+ return join(appDir, "config", "install-manifest.json");
122
+ }
123
+ async function validateClaudeDir(claudeDir) {
124
+ try {
125
+ await access(join(claudeDir, "projects"));
126
+ }
127
+ catch {
128
+ throw new Error(`Claude folder has no projects/ directory: ${claudeDir} — is this really the Claude folder?`);
129
+ }
130
+ }
131
+ async function validateVaultDir(vaultDir, newVault, home) {
132
+ const resolvedHome = resolve(home);
133
+ if (vaultDir === resolvedHome ||
134
+ vaultDir === "/" ||
135
+ resolvedHome.startsWith(`${vaultDir}${sep}`)) {
136
+ throw new Error(`Refusing suspicious vault root: ${vaultDir}`);
137
+ }
138
+ if (newVault) {
139
+ await mkdir(vaultDir, { recursive: true });
140
+ return;
141
+ }
142
+ try {
143
+ await access(join(vaultDir, ".obsidian"));
144
+ }
145
+ catch {
146
+ throw new Error(`${vaultDir} has no .obsidian/ — open this folder in Obsidian once and re-run, or pass --new-vault`);
147
+ }
148
+ }
149
+ function detectSyncedVault(vaultDir, warnings) {
150
+ if (vaultDir.includes(`${sep}Library${sep}Mobile Documents${sep}`) ||
151
+ vaultDir.includes(`${sep}Dropbox${sep}`) ||
152
+ vaultDir.toLowerCase().includes("icloud")) {
153
+ warnings.push("vault lives in a synced folder (iCloud/Dropbox): concurrent sync engines can produce conflicted copies; the vault git history is the recovery path");
154
+ }
155
+ }
156
+ async function placeFileWithPolicy(filePath, content, options, dependencies, date) {
157
+ let existing;
158
+ try {
159
+ existing = await readFile(filePath, "utf8");
160
+ }
161
+ catch (error) {
162
+ if (!isMissingFileError(error)) {
163
+ throw error;
164
+ }
165
+ }
166
+ if (existing === undefined) {
167
+ await writeFileAtomic(filePath, content);
168
+ return "created";
169
+ }
170
+ if (existing === content) {
171
+ return "kept";
172
+ }
173
+ let override;
174
+ if (options.force) {
175
+ override = true;
176
+ }
177
+ else if (options.keepExisting) {
178
+ override = false;
179
+ }
180
+ else if (dependencies.isTTY) {
181
+ override = await dependencies.askYesNo(`${filePath} already exists — override with the template? [y/N]`);
182
+ }
183
+ else {
184
+ throw new Error(`Conflicting file in non-interactive mode: ${filePath} — re-run with --force or --keep-existing`);
185
+ }
186
+ if (!override) {
187
+ return "kept";
188
+ }
189
+ await writeBackup(filePath, existing, date);
190
+ await writeFileAtomic(filePath, content);
191
+ return "overridden";
192
+ }
193
+ async function writeBackup(filePath, content, date) {
194
+ // A same-day re-init must never clobber an earlier backup — that file can
195
+ // be the only copy of what the user had before the first override.
196
+ for (let suffix = 1; suffix <= 50; suffix += 1) {
197
+ const candidate = suffix === 1
198
+ ? `${filePath}.bak-${date}`
199
+ : `${filePath}.bak-${date}-${suffix}`;
200
+ try {
201
+ await lstat(candidate);
202
+ }
203
+ catch {
204
+ await writeFileAtomic(candidate, content);
205
+ return candidate;
206
+ }
207
+ }
208
+ throw new Error(`Could not claim a free backup path for ${filePath}`);
209
+ }
210
+ async function createIfAbsent(filePath, content, created) {
211
+ try {
212
+ await access(filePath);
213
+ }
214
+ catch {
215
+ await writeFileAtomic(filePath, content);
216
+ created.push(filePath);
217
+ }
218
+ }
219
+ async function initVaultGit(vaultDir, dependencies, warnings) {
220
+ try {
221
+ await dependencies.exec("git", ["rev-parse", "--git-dir"], vaultDir);
222
+ return;
223
+ }
224
+ catch {
225
+ // Not a repo yet — initialize it below.
226
+ }
227
+ try {
228
+ await dependencies.exec("git", ["init", "-b", "main"], vaultDir);
229
+ await dependencies.exec("git", ["add", "-A"], vaultDir);
230
+ await dependencies.exec("git", ["commit", "-m", "chore: vault baseline (second-brain init)"], vaultDir);
231
+ }
232
+ catch (error) {
233
+ warnings.push(`vault git setup incomplete: ${error instanceof Error ? error.message : "unknown error"}`);
234
+ }
235
+ }
236
+ // launchd starts jobs with this PATH — not the user's shell PATH. The
237
+ // preflight must reproduce it, or it passes interactively and the scheduled
238
+ // runs fail with ENOENT (or hit a wrapper that itself needs the shell PATH).
239
+ const LAUNCHD_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
240
+ async function preflightClaude(dependencies, warnings) {
241
+ for (const candidate of await claudeCandidates(dependencies)) {
242
+ try {
243
+ await dependencies.exec(candidate, ["--version"], dependencies.home, {
244
+ ...dependencies.env,
245
+ PATH: LAUNCHD_PATH
246
+ });
247
+ return { backend: "cli-claude", claudeBin: candidate };
248
+ }
249
+ catch {
250
+ // IDE/terminal wrappers named "claude" often just re-exec the real CLI
251
+ // from the shell PATH — they fail here by design; try the next one.
252
+ }
253
+ }
254
+ warnings.push("claude CLI preflight failed under a launchd-equivalent PATH — synthesis.backend set to noop; set synthesis.claudeBin to your claude binary's absolute path and backend to cli-claude in config/projects.json");
255
+ return { backend: "noop" };
256
+ }
257
+ async function claudeCandidates(dependencies) {
258
+ const directories = [
259
+ ...(dependencies.env.PATH ?? "").split(":").filter((entry) => entry !== ""),
260
+ // Installer defaults, in case the current shell's PATH misses them.
261
+ join(dependencies.home, ".local", "bin"),
262
+ "/opt/homebrew/bin",
263
+ "/usr/local/bin"
264
+ ];
265
+ const candidates = [];
266
+ for (const directory of directories) {
267
+ const candidate = join(directory, "claude");
268
+ if (candidates.includes(candidate)) {
269
+ continue;
270
+ }
271
+ try {
272
+ await access(candidate, fsConstants.X_OK);
273
+ candidates.push(candidate);
274
+ }
275
+ catch {
276
+ // Not present or not executable here — keep scanning.
277
+ }
278
+ }
279
+ return candidates;
280
+ }
281
+ async function installManagedSection(claudeMdPath, block, date, created, overridden, warnings, log) {
282
+ let targetPath = claudeMdPath;
283
+ let isSymlink = false;
284
+ try {
285
+ isSymlink = (await lstat(claudeMdPath)).isSymbolicLink();
286
+ }
287
+ catch (error) {
288
+ if (!isMissingFileError(error)) {
289
+ throw error;
290
+ }
291
+ }
292
+ if (isSymlink) {
293
+ // CLAUDE.md often symlinks into a dotfiles repo: edit the real file so
294
+ // the link survives — replacing the symlink would orphan their setup.
295
+ try {
296
+ targetPath = await realpath(claudeMdPath);
297
+ }
298
+ catch {
299
+ warnings.push(`${claudeMdPath} is a dangling symlink — managed section not installed; fix the link target and re-run`);
300
+ return;
301
+ }
302
+ }
303
+ let existing;
304
+ try {
305
+ existing = await readFile(targetPath, "utf8");
306
+ }
307
+ catch (error) {
308
+ if (!isMissingFileError(error)) {
309
+ throw error;
310
+ }
311
+ }
312
+ const next = applyManagedSection(existing, block);
313
+ if (existing === next) {
314
+ return;
315
+ }
316
+ // The section is strictly additive: content outside the markers is never
317
+ // touched. Still, this is the user's instruction file — back it up, tell
318
+ // them exactly what happened, and ask them to review.
319
+ if (existing === undefined) {
320
+ created.push(targetPath);
321
+ await writeFileAtomic(targetPath, next);
322
+ log(`review: created ${targetPath} with the second-brain section — please review it`);
323
+ return;
324
+ }
325
+ const backupPath = await writeBackup(targetPath, existing, date);
326
+ overridden.push(targetPath);
327
+ await writeFileAtomic(targetPath, next);
328
+ log(`review: added/updated the second-brain section in ${targetPath} — your content outside the markers is untouched (backup: ${backupPath}); please review`);
329
+ }
330
+ async function fileMatches(filePath, content) {
331
+ try {
332
+ return (await readFile(filePath, "utf8")) === content;
333
+ }
334
+ catch {
335
+ return false;
336
+ }
337
+ }
338
+ function isMissingFileError(error) {
339
+ return (error instanceof Error &&
340
+ "code" in error &&
341
+ typeof error.code === "string" &&
342
+ error.code === "ENOENT");
343
+ }
@@ -0,0 +1,56 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { z } from "zod";
3
+ import { hashContent } from "./changeset.js";
4
+ import { writeFileAtomic } from "./filesystem.js";
5
+ const installManifestSchema = z.object({
6
+ files: z.record(z.string(), z.object({ hash: z.string() })).default({}),
7
+ version: z.literal(1).default(1)
8
+ });
9
+ export function emptyInstallManifest() {
10
+ return { files: {}, version: 1 };
11
+ }
12
+ export async function loadInstallManifest(manifestPath) {
13
+ let raw;
14
+ try {
15
+ raw = await readFile(manifestPath, "utf8");
16
+ }
17
+ catch (error) {
18
+ if (isMissingFileError(error)) {
19
+ return emptyInstallManifest();
20
+ }
21
+ throw error;
22
+ }
23
+ const trimmed = raw.trim();
24
+ const parsed = trimmed.length === 0 ? {} : JSON.parse(trimmed);
25
+ return installManifestSchema.parse(parsed);
26
+ }
27
+ export async function saveInstallManifest(manifestPath, manifest) {
28
+ await writeFileAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
29
+ }
30
+ export function recordInstalledFile(manifest, filePath, content) {
31
+ return {
32
+ files: { ...manifest.files, [filePath]: { hash: hashContent(content) } },
33
+ version: manifest.version
34
+ };
35
+ }
36
+ // True when the file on disk still matches what init wrote — only then may
37
+ // uninstall delete it (design 7.6).
38
+ export async function isUnchangedInstalledFile(manifest, filePath) {
39
+ const entry = manifest.files[filePath];
40
+ if (entry === undefined) {
41
+ return false;
42
+ }
43
+ try {
44
+ const content = await readFile(filePath, "utf8");
45
+ return hashContent(content) === entry.hash;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ function isMissingFileError(error) {
52
+ return (error instanceof Error &&
53
+ "code" in error &&
54
+ typeof error.code === "string" &&
55
+ error.code === "ENOENT");
56
+ }
package/dist/lock.js ADDED
@@ -0,0 +1,73 @@
1
+ import { readFile, rm, writeFile } from "node:fs/promises";
2
+ const STALE_LOCK_MS = 2 * 60 * 60 * 1000;
3
+ const defaultDependencies = {
4
+ isProcessAlive: (pid) => {
5
+ try {
6
+ process.kill(pid, 0);
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ },
13
+ now: () => new Date(),
14
+ pid: process.pid
15
+ };
16
+ export async function acquireLock(lockPath, dependencies = defaultDependencies) {
17
+ const payload = `${JSON.stringify({
18
+ acquiredAt: dependencies.now().toISOString(),
19
+ pid: dependencies.pid
20
+ })}\n`;
21
+ try {
22
+ await writeFile(lockPath, payload, { flag: "wx" });
23
+ }
24
+ catch (error) {
25
+ if (!isExistsError(error)) {
26
+ throw error;
27
+ }
28
+ await takeOverStaleLock(lockPath, dependencies);
29
+ // If a competitor re-created the lock in this window, the second wx write
30
+ // intentionally surfaces the lost takeover as a hard failure.
31
+ await writeFile(lockPath, payload, { flag: "wx" });
32
+ }
33
+ return {
34
+ release: async () => {
35
+ await rm(lockPath, { force: true });
36
+ }
37
+ };
38
+ }
39
+ async function takeOverStaleLock(lockPath, dependencies) {
40
+ const holder = await readLockHolder(lockPath);
41
+ const isStale = holder === undefined ||
42
+ !dependencies.isProcessAlive(holder.pid) ||
43
+ dependencies.now().getTime() - Date.parse(holder.acquiredAt) > STALE_LOCK_MS;
44
+ if (!isStale) {
45
+ throw new Error(`Another instance holds the lock: ${lockPath} (pid ${holder.pid}, since ${holder.acquiredAt})`);
46
+ }
47
+ await rm(lockPath, { force: true });
48
+ }
49
+ async function readLockHolder(lockPath) {
50
+ try {
51
+ const raw = await readFile(lockPath, "utf8");
52
+ const parsed = JSON.parse(raw);
53
+ if (typeof parsed === "object" &&
54
+ parsed !== null &&
55
+ "pid" in parsed &&
56
+ typeof parsed.pid === "number" &&
57
+ "acquiredAt" in parsed &&
58
+ typeof parsed.acquiredAt === "string" &&
59
+ !Number.isNaN(Date.parse(parsed.acquiredAt))) {
60
+ return { acquiredAt: parsed.acquiredAt, pid: parsed.pid };
61
+ }
62
+ return undefined;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ function isExistsError(error) {
69
+ return (error instanceof Error &&
70
+ "code" in error &&
71
+ typeof error.code === "string" &&
72
+ error.code === "EEXIST");
73
+ }
package/dist/logger.js ADDED
@@ -0,0 +1,19 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export function createLogger(logsRoot) {
4
+ const lines = [];
5
+ return {
6
+ async flush() {
7
+ await mkdir(logsRoot, { recursive: true });
8
+ const timestamp = new Date().toISOString().replaceAll(":", "-");
9
+ const logPath = join(logsRoot, `${timestamp}.log`);
10
+ const payload = `${lines.join("\n")}\n`;
11
+ await appendFile(logPath, payload, "utf8");
12
+ return logPath;
13
+ },
14
+ info(message) {
15
+ lines.push(`[${new Date().toISOString()}] ${message}`);
16
+ process.stdout.write(`${message}\n`);
17
+ }
18
+ };
19
+ }
@@ -0,0 +1,30 @@
1
+ const BEGIN_MARKER = "<!-- BEGIN second-brain (managed by second-brain init — do not edit inside) -->";
2
+ const END_MARKER = "<!-- END second-brain -->";
3
+ export function hasManagedSection(content) {
4
+ return content.includes(BEGIN_MARKER) && content.includes(END_MARKER);
5
+ }
6
+ // Idempotent: re-applying replaces the block in place, never duplicates
7
+ // (design 7.3). The block argument must already contain the markers.
8
+ export function applyManagedSection(existing, block) {
9
+ const trimmedBlock = block.trim();
10
+ if (existing === undefined || existing.trim().length === 0) {
11
+ return `${trimmedBlock}\n`;
12
+ }
13
+ if (hasManagedSection(existing)) {
14
+ const beginIndex = existing.indexOf(BEGIN_MARKER);
15
+ const endIndex = existing.indexOf(END_MARKER) + END_MARKER.length;
16
+ return `${existing.slice(0, beginIndex)}${trimmedBlock}${existing.slice(endIndex)}`;
17
+ }
18
+ return `${existing.replace(/\n+$/u, "")}\n\n${trimmedBlock}\n`;
19
+ }
20
+ export function removeManagedSection(content) {
21
+ if (!hasManagedSection(content)) {
22
+ return content;
23
+ }
24
+ const beginIndex = content.indexOf(BEGIN_MARKER);
25
+ const endIndex = content.indexOf(END_MARKER) + END_MARKER.length;
26
+ const before = content.slice(0, beginIndex).replace(/\n+$/u, "");
27
+ const after = content.slice(endIndex).replace(/^\n+/u, "");
28
+ const joined = [before, after].filter((part) => part.length > 0).join("\n");
29
+ return joined.length > 0 ? `${joined}\n` : "";
30
+ }
@@ -0,0 +1,64 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "./filesystem.js";
5
+ const manifestEntrySchema = z.object({
6
+ contentHash: z.string().regex(/^[0-9a-f]{64}$/u),
7
+ ingestedAt: z.iso.datetime()
8
+ });
9
+ const manifestSchema = z.object({
10
+ entries: z.record(z.string(), manifestEntrySchema).default({}),
11
+ version: z.literal(1).default(1)
12
+ });
13
+ export function buildSourceKey(input) {
14
+ // Segments are escaped so a ':' inside any field (e.g. a sessionId that fell
15
+ // back to a filename) cannot collide with the delimiter.
16
+ return [input.provider, input.type, input.sessionId]
17
+ .map((segment) => encodeURIComponent(segment))
18
+ .join(":");
19
+ }
20
+ export function emptyManifest() {
21
+ return { entries: {}, version: 1 };
22
+ }
23
+ export async function loadManifest(manifestPath) {
24
+ let raw;
25
+ try {
26
+ raw = await readFile(manifestPath, "utf8");
27
+ }
28
+ catch (error) {
29
+ if (isMissingFileError(error)) {
30
+ // A missing file in an existing wiki/ is a genuinely fresh manifest; a
31
+ // missing parent directory means the path is misconfigured, and silently
32
+ // starting empty would re-synthesize the corpus against the wrong vault.
33
+ await assertParentDirectoryExists(manifestPath);
34
+ return emptyManifest();
35
+ }
36
+ throw error;
37
+ }
38
+ const trimmed = raw.trim();
39
+ const parsed = trimmed.length === 0 ? {} : JSON.parse(trimmed);
40
+ return manifestSchema.parse(parsed);
41
+ }
42
+ export async function saveManifest(manifestPath, manifest) {
43
+ await writeFileAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
44
+ }
45
+ export function withManifestEntries(manifest, entries) {
46
+ return {
47
+ entries: { ...manifest.entries, ...entries },
48
+ version: manifest.version
49
+ };
50
+ }
51
+ async function assertParentDirectoryExists(manifestPath) {
52
+ try {
53
+ await access(dirname(manifestPath));
54
+ }
55
+ catch {
56
+ throw new Error(`Manifest directory does not exist: ${dirname(manifestPath)} — check the vault path or run init`);
57
+ }
58
+ }
59
+ function isMissingFileError(error) {
60
+ return (error instanceof Error &&
61
+ "code" in error &&
62
+ typeof error.code === "string" &&
63
+ error.code === "ENOENT");
64
+ }
@@ -0,0 +1,49 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseEpisodePatch } from "./episode-patch.js";
3
+ import { assertEpisodesMatchProject, buildEpisodePrompt } from "./episode-prompt.js";
4
+ const DEFAULT_BASE_URL = "http://127.0.0.1:11434";
5
+ const DEFAULT_MODEL = "llama3.1";
6
+ const defaultDependencies = {
7
+ fetchFn: fetch,
8
+ readSource: (path) => readFile(path, "utf8")
9
+ };
10
+ export function createOllamaBackend(options = {}, dependencies = defaultDependencies) {
11
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
12
+ const model = options.model ?? DEFAULT_MODEL;
13
+ return {
14
+ name: "ollama",
15
+ synthesize: async (input) => {
16
+ const prompt = await buildEpisodePrompt(input, dependencies.readSource);
17
+ const response = await dependencies.fetchFn(`${baseUrl}/api/chat`, {
18
+ body: JSON.stringify({
19
+ format: "json",
20
+ messages: [{ content: prompt, role: "user" }],
21
+ model,
22
+ stream: false
23
+ }),
24
+ headers: { "content-type": "application/json" },
25
+ method: "POST"
26
+ });
27
+ if (!response.ok) {
28
+ throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
29
+ }
30
+ const payload = await response.json();
31
+ const text = extractMessageContent(payload);
32
+ const patch = parseEpisodePatch(text);
33
+ assertEpisodesMatchProject(patch.episodes, input.project);
34
+ return { episodes: patch.episodes };
35
+ }
36
+ };
37
+ }
38
+ function extractMessageContent(payload) {
39
+ if (typeof payload === "object" &&
40
+ payload !== null &&
41
+ "message" in payload &&
42
+ typeof payload.message === "object" &&
43
+ payload.message !== null &&
44
+ "content" in payload.message &&
45
+ typeof payload.message.content === "string") {
46
+ return payload.message.content;
47
+ }
48
+ throw new Error("Unexpected Ollama response: missing message content");
49
+ }
package/dist/plist.js ADDED
@@ -0,0 +1,23 @@
1
+ import { join } from "node:path";
2
+ import { writeFileAtomic } from "./filesystem.js";
3
+ export function renderTemplate(template, variables) {
4
+ return template.replace(/\{\{([A-Z_]+)\}\}/gu, (match, key) => {
5
+ const value = variables[key];
6
+ if (value === undefined) {
7
+ throw new Error(`Template variable without a value: ${match}`);
8
+ }
9
+ return value;
10
+ });
11
+ }
12
+ export async function installLaunchdJob(input) {
13
+ const plistPath = join(input.launchAgentsDir, `${input.label}.plist`);
14
+ await writeFileAtomic(plistPath, input.plistContent);
15
+ // bootout is allowed to fail (job not loaded yet); bootstrap is not.
16
+ await input.exec("launchctl", ["bootout", `gui/${input.uid}/${input.label}`], "/").catch(() => undefined);
17
+ await input.exec("launchctl", ["bootstrap", `gui/${input.uid}`, plistPath], "/");
18
+ return plistPath;
19
+ }
20
+ export async function removeLaunchdJob(input) {
21
+ await input.exec("launchctl", ["bootout", `gui/${input.uid}/${input.label}`], "/").catch(() => undefined);
22
+ await input.removeFile(join(input.launchAgentsDir, `${input.label}.plist`));
23
+ }