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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Minh Nhat Hoang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # second-brain
2
+
3
+ **Give your AI coding sessions a long-term memory — an Obsidian vault that maintains itself.**
4
+
5
+ Every Claude Code session ends the same way: the context dies with it. The transcript lands in a folder nobody reads back, and the next session starts cold. second-brain closes that loop:
6
+
7
+ - **Capture** — session transcripts mirror into your vault automatically; ending a task with `/wrap` distills it into a small, curated plan + output artifact.
8
+ - **Synthesize** — an hourly job (Claude Haiku, a fast model) turns new artifacts into dated episode notes; a weekly job (Claude Sonnet, a stronger one) consolidates episodes into stable per-project wiki pages — what the project is, what was decided and why, how projects relate.
9
+ - **Recall** — init adds a section to your `CLAUDE.md` instructing sessions to consult `wiki/index.md` whenever the repository alone can't answer. Sessions start light and *find* context instead of being fed giant dumps.
10
+
11
+ No manual curation. Idle hours cost $0 — a content-hash manifest skips runs with nothing new. And every run that did work leaves a human-readable report in `wiki/reports/`, so the system is never a black box.
12
+
13
+ ```
14
+ You work in Claude Code
15
+ ├─ transcripts mirror into the vault (archive, $0)
16
+ └─ /wrap on finishing a task → curated artifact
17
+
18
+ ▼ hourly · Haiku
19
+ wiki/episodes/ — dated episode notes per project
20
+
21
+ ▼ weekly · Sonnet
22
+ wiki/<project>.md — per-project wiki pages: decisions, rationale, relations
23
+
24
+
25
+ your next session reads wiki/index.md when it lacks context — and just knows
26
+ ```
27
+
28
+ ## Requirements
29
+
30
+ | Requirement | Notes |
31
+ |---|---|
32
+ | **macOS** | scheduling uses launchd (Windows/Linux not yet supported) |
33
+ | **Node >= 20** | the build runs automatically on install |
34
+ | **[Obsidian](https://obsidian.md)** | the vault folder must contain `.obsidian/` — open it in Obsidian once, or let init create a fresh vault with `--new-vault` |
35
+ | **[Claude Code](https://claude.com/claude-code)** | used at least once, so `~/.claude/projects/` exists; its `claude` CLI also powers synthesis — without it everything still works except episode/wiki generation |
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ npm install -g obsidian-second-brain
41
+ second-brain init
42
+ ```
43
+
44
+ > After upgrading (`npm update -g obsidian-second-brain`), run `second-brain init` again: npm replaces the package folder, which holds the generated config. Re-running is idempotent and never touches your vault content.
45
+
46
+ <details>
47
+ <summary>From source instead</summary>
48
+
49
+ ```bash
50
+ git clone https://github.com/minhnhat08/second-brain.git ~/tools/second-brain
51
+ cd ~/tools/second-brain
52
+ npm install # installs dependencies and builds (via the prepare hook — if it fails, run `npm run build`)
53
+ npm link # puts the `second-brain` command on your PATH
54
+ second-brain init
55
+ ```
56
+
57
+ The clone is the install: the scheduled jobs run out of this folder, so don't park it somewhere temporary. If you move it later, run `second-brain init` again from the new location. Prefer not to touch your global npm? Skip `npm link` and use `node dist/cli.js` wherever you see `second-brain` below.
58
+
59
+ </details>
60
+
61
+ `init` asks two questions — Enter accepts the defaults shown in brackets:
62
+
63
+ ```
64
+ Claude folder [/Users/you/.claude]
65
+ Obsidian vault [/Users/you/Documents/Obsidian Vault]
66
+ ```
67
+
68
+ It refuses to continue if the Claude folder has no `projects/` (run Claude Code once first) or the vault has no `.obsidian/` (open it in Obsidian once, or pass `--new-vault`). Then it sets up the whole loop:
69
+
70
+ - scaffolds the vault as an LLM wiki (`raw/`, `wiki/`, `AGENTS.md`, its own git repo for history)
71
+ - installs the `/wrap` command and adds a marker-fenced section to your `~/.claude/CLAUDE.md`: the `/wrap` habit plus the retrieval policy
72
+ - generates `config/projects.json` and loads the hourly + weekly launchd jobs
73
+
74
+ **Your files are safe.** Nothing is silently overwritten: conflicts prompt (or use `--force` / `--keep-existing`), every override is backed up next to the original, a symlinked `CLAUDE.md` is followed to its target and edited *additively*, and init prints a `review:` line whenever it touches an instruction file — read what it added.
75
+
76
+ Unattended install: `second-brain init --claude <dir> --vault <dir> --new-vault --keep-existing [--skip-launchd]`. Without a terminal attached, unspecified paths take the defaults and any file conflict aborts unless `--force` or `--keep-existing` is given.
77
+
78
+ ## Daily use
79
+
80
+ There is none — that's the point. Two habits make the wiki good:
81
+
82
+ - End meaningful tasks with **`/wrap`**. Curated artifacts are far better synthesis input than raw transcripts, and the installed CLAUDE.md section reminds the agent to offer it.
83
+ - Browse `wiki/index.md` in Obsidian now and then; `wiki/reports/` shows exactly what any run did.
84
+
85
+ ## Is it working?
86
+
87
+ ```bash
88
+ launchctl list | grep second-brain # hourly + weekly jobs loaded
89
+ tail -5 /tmp/second-brain.stdout.log # last run summary
90
+ ls -t "<vault>/wiki/reports/" | head -3 # recent run reports — <vault> is the path you chose in init
91
+ ```
92
+
93
+ If init warned `claude CLI preflight failed`, synthesis fell back to `noop` (mirror + reports only). Confirm `claude --version` works in a plain terminal, then set `synthesis.backend` to `cli-claude` in `config/projects.json`. A second concurrent run exiting with `Another instance holds the lock` is by design.
94
+
95
+ ## Configuration
96
+
97
+ `config/projects.json` — in the install folder, not the vault:
98
+
99
+ | Field | Meaning |
100
+ |---|---|
101
+ | `vaultRoot` | `<vault>/raw/projects` — where transcripts mirror to (must end in `raw/projects`) |
102
+ | `providers[]` | one entry per source; all four fields required: `name` (`claude` / `codex` / `gemini`), `sourceRoot`, `destinationPath`, `enabled` |
103
+ | `synthesis.backend` | `noop` ($0: mirror + reports only) · `cli-claude` (Haiku hourly, Sonnet weekly) · `ollama` (local hourly; weekly stays on cli-claude) |
104
+ | `synthesis.claudeBin` | absolute path to the `claude` binary — init records the one it verified; scheduled jobs never see your shell PATH |
105
+ | `logsRoot` | run logs directory |
106
+
107
+ Paths support `${ENV_VAR}` expansion; the file is validated with clear errors. Edit it and the next run picks it up — no reload step.
108
+
109
+ ## Uninstall
110
+
111
+ ```bash
112
+ second-brain uninstall
113
+ npm uninstall -g obsidian-second-brain
114
+ ```
115
+
116
+ Removes the launchd jobs, the managed CLAUDE.md section, `/wrap`, and generated files it installed — skipping any you modified. **Your vault content is never touched**: it is your data and your audit trail.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ npm run typecheck
122
+ npm test # 172 unit/integration tests
123
+ npm run coverage # per-file line/branch coverage
124
+ npm run e2e # drives the real built binary against temp worlds ($0)
125
+ SB_E2E_CLAUDE=1 npx tsx --test tests/e2e/claude-smoke.e2e.ts # paid smoke (one haiku call)
126
+ ```
127
+
128
+ The E2E suites spawn `dist/cli.js` as a child process against disposable worlds (vault paths deliberately contain spaces): ingest/consolidation with reports and idle skips, init/uninstall lifecycle (PATH shims keep your real launchd untouched), lock contention, crash redo, timezone pinning, and backend degradation. The release bar lives in [docs/publish-readiness.md](docs/publish-readiness.md); the architecture in [docs/design.md](docs/design.md).
129
+
130
+ ## Status
131
+
132
+ Running live against a real vault — 900+ sessions, idempotent re-runs, a report per run. Public release is pending a short observation window. Next on the roadmap: local semantic search over the mirrored transcripts.
133
+
134
+ ## License
135
+
136
+ [MIT](LICENSE)
@@ -0,0 +1,13 @@
1
+ {
2
+ "vaultRoot": "${HOME}/Documents/Obsidian Vault/raw/projects",
3
+ "logsRoot": "../logs",
4
+ "synthesis": { "backend": "noop" },
5
+ "providers": [
6
+ {
7
+ "name": "claude",
8
+ "sourceRoot": "${HOME}/.claude/projects",
9
+ "destinationPath": "sessions/claude",
10
+ "enabled": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ const slugPattern = /^[a-z0-9][a-z0-9-]*$/u;
3
+ const artifactFrontmatterSchema = z.object({
4
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/u),
5
+ project: z
6
+ .string()
7
+ .refine((value) => value === "_inbox" || slugPattern.test(value), {
8
+ message: "project must be a kebab-case slug or the reserved _inbox"
9
+ }),
10
+ session_id: z.string().regex(/^[A-Za-z0-9_-]+$/u),
11
+ task: z.string().min(1),
12
+ type: z.enum(["output", "plan"])
13
+ });
14
+ export function parseArtifact(content) {
15
+ // Editors on other machines may save artifacts with a BOM or CRLF endings;
16
+ // both would otherwise quarantine a perfectly valid file.
17
+ const lines = content.replace(/^\uFEFF/u, "").split(/\r?\n/u);
18
+ if (lines[0] !== "---") {
19
+ throw new Error("Artifact is missing its opening frontmatter fence");
20
+ }
21
+ const closingIndex = lines.indexOf("---", 1);
22
+ if (closingIndex === -1) {
23
+ throw new Error("Artifact is missing its closing frontmatter fence");
24
+ }
25
+ const fields = {};
26
+ for (const line of lines.slice(1, closingIndex)) {
27
+ if (line.trim().length === 0) {
28
+ continue;
29
+ }
30
+ const separatorIndex = line.indexOf(":");
31
+ if (separatorIndex === -1) {
32
+ throw new Error(`Malformed frontmatter line: ${line}`);
33
+ }
34
+ const key = line.slice(0, separatorIndex).trim();
35
+ // Last-wins merging would let a later line silently shadow an already
36
+ // validated value; duplicates are author mistakes worth surfacing.
37
+ if (Object.hasOwn(fields, key)) {
38
+ throw new Error(`Duplicate frontmatter key: ${key}`);
39
+ }
40
+ fields[key] = line.slice(separatorIndex + 1).trim();
41
+ }
42
+ return {
43
+ body: lines.slice(closingIndex + 1).join("\n").replace(/^\n+/u, ""),
44
+ frontmatter: artifactFrontmatterSchema.parse(fields)
45
+ };
46
+ }
@@ -0,0 +1,18 @@
1
+ import { createHash } from "node:crypto";
2
+ const GENERATED_AT_LINE = /^- generated_at: /u;
3
+ export function normalizeRenderedBody(markdown) {
4
+ const lines = markdown.split("\n");
5
+ // Both renderers open the body with their first "## " heading ("## Conversation"
6
+ // or "## Event N"); the volatile generated_at stamp only exists above it.
7
+ const headerEnd = lines.findIndex((line) => line.startsWith("## "));
8
+ const boundary = headerEnd === -1 ? lines.length : headerEnd;
9
+ return lines
10
+ .filter((line, index) => index >= boundary || !GENERATED_AT_LINE.test(line))
11
+ .join("\n");
12
+ }
13
+ export function hashContent(content) {
14
+ return createHash("sha256").update(content, "utf8").digest("hex");
15
+ }
16
+ export function hashRenderedBody(markdown) {
17
+ return hashContent(normalizeRenderedBody(markdown));
18
+ }
@@ -0,0 +1,50 @@
1
+ import { buildSourceKey } from "./manifest.js";
2
+ export function classifySources(manifest, sources) {
3
+ const added = [];
4
+ const changed = [];
5
+ const unchanged = [];
6
+ for (const source of sources) {
7
+ const entry = manifest.entries[buildSourceKey(source)];
8
+ if (entry === undefined) {
9
+ added.push(source);
10
+ continue;
11
+ }
12
+ if (entry.contentHash === source.contentHash) {
13
+ unchanged.push(source);
14
+ continue;
15
+ }
16
+ changed.push(source);
17
+ }
18
+ return { added, changed, unchanged };
19
+ }
20
+ export function hasWork(classified) {
21
+ return classified.added.length > 0 || classified.changed.length > 0;
22
+ }
23
+ export function groupByProject(sources) {
24
+ const groups = new Map();
25
+ for (const source of sources) {
26
+ const group = groups.get(source.project);
27
+ if (group === undefined) {
28
+ groups.set(source.project, [source]);
29
+ continue;
30
+ }
31
+ group.push(source);
32
+ }
33
+ return groups;
34
+ }
35
+ export function renderedFileToIngestSource(file) {
36
+ return {
37
+ contentHash: file.contentHash,
38
+ path: file.destinationPath,
39
+ project: file.project,
40
+ provider: file.providerName,
41
+ sessionId: file.sessionId,
42
+ type: "transcript"
43
+ };
44
+ }
45
+ export function toManifestEntries(sources, ingestedAt) {
46
+ return Object.fromEntries(sources.map((source) => [
47
+ buildSourceKey(source),
48
+ { contentHash: source.contentHash, ingestedAt }
49
+ ]));
50
+ }
@@ -0,0 +1,40 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parseEpisodePatch } from "./episode-patch.js";
3
+ import { assertEpisodesMatchProject, buildEpisodePrompt } from "./episode-prompt.js";
4
+ import { runCommand } from "./shell.js";
5
+ const defaultDependencies = {
6
+ readSource: (path) => readFile(path, "utf8"),
7
+ run: runCommand
8
+ };
9
+ export function createCliClaudeBackend(options = {}, dependencies = defaultDependencies) {
10
+ const claudeBin = options.claudeBin ?? "claude";
11
+ return {
12
+ name: "cli-claude",
13
+ synthesize: async (input) => {
14
+ const prompt = await buildEpisodePrompt(input, dependencies.readSource);
15
+ const result = await dependencies.run(claudeBin, ["-p", "--model", "haiku", "--output-format", "json", prompt], process.cwd());
16
+ const text = extractResultText(result.stdout);
17
+ const patch = parseEpisodePatch(text);
18
+ assertEpisodesMatchProject(patch.episodes, input.project);
19
+ return { episodes: patch.episodes };
20
+ }
21
+ };
22
+ }
23
+ export function extractResultText(stdout) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(stdout);
27
+ }
28
+ catch {
29
+ // The CLI can print a banner, login prompt, or usage-limit notice instead
30
+ // of the JSON envelope; a bare SyntaxError would be useless in failures[].
31
+ throw new Error(`claude CLI emitted non-JSON output: ${stdout.slice(0, 120).trim()}`);
32
+ }
33
+ if (typeof parsed === "object" &&
34
+ parsed !== null &&
35
+ "result" in parsed &&
36
+ typeof parsed.result === "string") {
37
+ return parsed.result;
38
+ }
39
+ throw new Error("Unexpected claude CLI output envelope: missing result text");
40
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { join, resolve, sep } from "node:path";
5
+ import { createCliClaudeBackend } from "./cli-claude-backend.js";
6
+ import { loadConfig, parseCliArgs, selectProviders } from "./config.js";
7
+ import { createCliClaudeConsolidationBackend, resolveConsolidationBackend } from "./consolidation-backend.js";
8
+ import { runConsolidation } from "./consolidation.js";
9
+ import { runIngest } from "./ingest.js";
10
+ import { acquireLock } from "./lock.js";
11
+ import { createLogger } from "./logger.js";
12
+ import { createOllamaBackend } from "./ollama-backend.js";
13
+ import { createReportWriter } from "./report.js";
14
+ import { resolveBackend } from "./synthesis.js";
15
+ import { syncProviderTree } from "./sync.js";
16
+ const defaultDependencies = {
17
+ createLogger,
18
+ loadConfig,
19
+ now: () => new Date(),
20
+ stdout: (message) => {
21
+ process.stdout.write(message);
22
+ },
23
+ syncProviderTree
24
+ };
25
+ export async function runCli(argv, dependencies = defaultDependencies) {
26
+ const args = parseCliArgs(argv);
27
+ const configPath = resolve(args.configPath ?? join(import.meta.dirname, "..", "config", "projects.json"));
28
+ const config = await dependencies.loadConfig(configPath);
29
+ const logger = dependencies.createLogger(config.logsRoot);
30
+ const selectedProviders = selectProviders(config.providers, args);
31
+ const results = [];
32
+ const generatedAt = dependencies.now().toISOString();
33
+ const vaultPaths = args.ingest || args.consolidate ? resolveVaultPaths(config.vaultRoot) : undefined;
34
+ const lock = vaultPaths ? await acquireLock(vaultPaths.lockPath) : undefined;
35
+ try {
36
+ logger.info(`Using config: ${configPath}`);
37
+ logger.info(`Selected providers: ${selectedProviders.map((provider) => provider.name).join(", ")}`);
38
+ for (const provider of selectedProviders) {
39
+ const result = await syncProvider(provider, config.vaultRoot, generatedAt, logger, dependencies);
40
+ results.push(result);
41
+ }
42
+ const syncedCount = results.filter((result) => result.status === "synced").length;
43
+ const errorCount = results.filter((result) => result.status === "error").length;
44
+ const renderedFiles = results.flatMap((result) => result.status === "synced" ? result.renderedFiles : []);
45
+ let ingestSummary;
46
+ let consolidationSummary;
47
+ // One writer per run: a combined --ingest --consolidate run accumulates
48
+ // both stages into a single report file.
49
+ const reportWriter = vaultPaths
50
+ ? createReportWriter({
51
+ now: dependencies.now,
52
+ reportsRoot: vaultPaths.reportsRoot,
53
+ runKind: args.runKind ?? "manual",
54
+ vaultRoot: vaultPaths.vaultDir
55
+ })
56
+ : undefined;
57
+ if (vaultPaths && args.ingest) {
58
+ const backend = resolveBackend(config.synthesis.backend, {
59
+ "cli-claude": createCliClaudeBackend({
60
+ claudeBin: config.synthesis.claudeBin
61
+ }),
62
+ ollama: createOllamaBackend({ model: config.synthesis.ollamaModel })
63
+ });
64
+ ingestSummary = await runIngest({
65
+ artifactsRoot: vaultPaths.artifactsRoot,
66
+ backend,
67
+ episodesRoot: vaultPaths.episodesRoot,
68
+ logPath: vaultPaths.wikiLogPath,
69
+ manifestPath: vaultPaths.manifestPath,
70
+ now: dependencies.now,
71
+ renderedFiles,
72
+ reportWriter
73
+ });
74
+ logger.info(ingestSummary.skipped
75
+ ? "Ingest: skipped (no changes)"
76
+ : `Ingest: episodes=${ingestSummary.episodePaths.length}, transcripts_tracked=${ingestSummary.trackedTranscripts}, failures=${ingestSummary.failures.length}, quarantined=${ingestSummary.quarantined.length}`);
77
+ }
78
+ if (vaultPaths && args.consolidate) {
79
+ const backend = resolveConsolidationBackend(config.synthesis.backend, {
80
+ "cli-claude": createCliClaudeConsolidationBackend({
81
+ claudeBin: config.synthesis.claudeBin
82
+ })
83
+ });
84
+ consolidationSummary = await runConsolidation({
85
+ backend,
86
+ episodesRoot: vaultPaths.episodesRoot,
87
+ indexPath: vaultPaths.indexPath,
88
+ logPath: vaultPaths.wikiLogPath,
89
+ manifestPath: vaultPaths.manifestPath,
90
+ now: dependencies.now,
91
+ reportWriter,
92
+ wikiRoot: vaultPaths.wikiRoot
93
+ });
94
+ logger.info(consolidationSummary.skipped
95
+ ? "Consolidation: skipped (no new episodes)"
96
+ : `Consolidation: pages_created=${consolidationSummary.pagesCreated.length}, pages_updated=${consolidationSummary.pagesUpdated.length}, episodes=${consolidationSummary.episodesConsolidated}, failures=${consolidationSummary.failures.length}`);
97
+ }
98
+ // Both stages write into the same report file, so either path works.
99
+ const reportPath = ingestSummary?.reportPath ?? consolidationSummary?.reportPath;
100
+ if (reportPath !== undefined) {
101
+ logger.info(`Run report: ${reportPath}`);
102
+ }
103
+ logger.info(`Summary: synced=${syncedCount}, error=${errorCount}`);
104
+ const logPath = await logger.flush();
105
+ dependencies.stdout(`Log written to ${logPath}\n`);
106
+ return {
107
+ ...(consolidationSummary ? { consolidation: consolidationSummary } : {}),
108
+ errorCount,
109
+ ...(ingestSummary ? { ingest: ingestSummary } : {}),
110
+ logPath,
111
+ renderedFiles,
112
+ ...(reportPath !== undefined ? { reportPath } : {}),
113
+ syncedCount
114
+ };
115
+ }
116
+ finally {
117
+ await lock?.release();
118
+ }
119
+ }
120
+ function resolveVaultPaths(vaultRoot) {
121
+ // Defense-in-depth (design 7.6): the prune scope must stay confined to
122
+ // raw/projects, so a hand-edited config cannot point ingest at a stray tree.
123
+ const normalized = resolve(vaultRoot);
124
+ if (!normalized.endsWith(join(`${sep}raw`, "projects"))) {
125
+ throw new Error(`vaultRoot must end in raw/projects to run ingest, got: ${vaultRoot}`);
126
+ }
127
+ const vault = resolve(normalized, "..", "..");
128
+ return {
129
+ artifactsRoot: join(vault, "raw", "artifacts"),
130
+ episodesRoot: join(vault, "wiki", "episodes"),
131
+ indexPath: join(vault, "wiki", "index.md"),
132
+ lockPath: join(vault, "wiki", ".ingest.lock"),
133
+ manifestPath: join(vault, "wiki", ".ingest-state.json"),
134
+ reportsRoot: join(vault, "wiki", "reports"),
135
+ vaultDir: vault,
136
+ wikiLogPath: join(vault, "wiki", "log.md"),
137
+ wikiRoot: join(vault, "wiki")
138
+ };
139
+ }
140
+ async function syncProvider(provider, vaultRoot, generatedAt, logger, dependencies) {
141
+ const destinationRoot = join(vaultRoot, provider.destinationPath);
142
+ try {
143
+ const summary = await dependencies.syncProviderTree({
144
+ destinationRoot,
145
+ generatedAt,
146
+ providerName: provider.name,
147
+ sourceRoot: provider.sourceRoot
148
+ });
149
+ logger.info(`[${provider.name}] synced: rendered_markdown=${summary.renderedMarkdownFiles}, copied=${summary.copiedFiles}, deleted=${summary.deletedFiles}`);
150
+ return {
151
+ copiedFiles: summary.copiedFiles,
152
+ deletedFiles: summary.deletedFiles,
153
+ providerName: provider.name,
154
+ renderedFiles: summary.renderedFiles,
155
+ renderedMarkdownFiles: summary.renderedMarkdownFiles,
156
+ status: "synced"
157
+ };
158
+ }
159
+ catch (error) {
160
+ const message = error instanceof Error ? error.message : "Unknown error";
161
+ logger.info(`[${provider.name}] error: ${message}`);
162
+ return {
163
+ message,
164
+ providerName: provider.name,
165
+ status: "error"
166
+ };
167
+ }
168
+ }
169
+ export function parseInitArgs(argv) {
170
+ let claudeDir;
171
+ let vaultDir;
172
+ let force = false;
173
+ let keepExisting = false;
174
+ let newVault = false;
175
+ let skipLaunchd = false;
176
+ for (let index = 0; index < argv.length; index += 1) {
177
+ const currentArg = argv[index];
178
+ if (currentArg === "--claude" || currentArg === "--vault") {
179
+ const nextArg = argv[index + 1];
180
+ if (!nextArg) {
181
+ throw new Error(`Missing value for ${currentArg}`);
182
+ }
183
+ if (currentArg === "--claude") {
184
+ claudeDir = nextArg;
185
+ }
186
+ else {
187
+ vaultDir = nextArg;
188
+ }
189
+ index += 1;
190
+ continue;
191
+ }
192
+ if (currentArg === "--force") {
193
+ force = true;
194
+ continue;
195
+ }
196
+ if (currentArg === "--keep-existing") {
197
+ keepExisting = true;
198
+ continue;
199
+ }
200
+ if (currentArg === "--new-vault") {
201
+ newVault = true;
202
+ continue;
203
+ }
204
+ if (currentArg === "--skip-launchd") {
205
+ skipLaunchd = true;
206
+ continue;
207
+ }
208
+ throw new Error(`Unknown argument: ${currentArg}`);
209
+ }
210
+ return { claudeDir, force, keepExisting, newVault, skipLaunchd, vaultDir };
211
+ }
212
+ function appDir() {
213
+ return resolve(import.meta.dirname, "..");
214
+ }
215
+ function stdout(message) {
216
+ process.stdout.write(message);
217
+ }
218
+ async function runInitCommand(argv) {
219
+ const { homedir } = await import("node:os");
220
+ const { runCommand } = await import("./shell.js");
221
+ const { runInit } = await import("./init.js");
222
+ const home = homedir();
223
+ const isTTY = process.stdin.isTTY === true && process.stdout.isTTY === true;
224
+ const flags = parseInitArgs(argv);
225
+ const defaults = {
226
+ claudeDir: join(home, ".claude"),
227
+ vaultDir: join(home, "Documents", "Obsidian Vault")
228
+ };
229
+ let askText;
230
+ let askYesNo;
231
+ let closePrompts = () => undefined;
232
+ if (isTTY) {
233
+ const { createInterface } = await import("node:readline/promises");
234
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
235
+ askText = async (question, fallback) => {
236
+ const answer = (await readline.question(`${question} [${fallback}] `)).trim();
237
+ return answer.length > 0 ? answer : fallback;
238
+ };
239
+ askYesNo = async (question) => {
240
+ const answer = (await readline.question(`${question} `)).trim().toLowerCase();
241
+ return answer === "y" || answer === "yes";
242
+ };
243
+ closePrompts = () => readline.close();
244
+ }
245
+ else {
246
+ askText = async (_question, fallback) => fallback;
247
+ askYesNo = async () => false;
248
+ }
249
+ try {
250
+ const claudeDir = flags.claudeDir ?? (await askText("Claude folder", defaults.claudeDir));
251
+ const vaultDir = flags.vaultDir ?? (await askText("Obsidian vault", defaults.vaultDir));
252
+ const summary = await runInit({
253
+ claudeDir,
254
+ force: flags.force,
255
+ keepExisting: flags.keepExisting,
256
+ newVault: flags.newVault,
257
+ skipLaunchd: flags.skipLaunchd,
258
+ vaultDir
259
+ }, {
260
+ appDir: appDir(),
261
+ askYesNo,
262
+ env: process.env,
263
+ exec: runCommand,
264
+ home,
265
+ isTTY,
266
+ launchAgentsDir: join(home, "Library", "LaunchAgents"),
267
+ log: (message) => stdout(`${message}\n`),
268
+ nodePath: process.execPath,
269
+ now: () => new Date(),
270
+ uid: String(process.getuid?.() ?? 501)
271
+ });
272
+ stdout(`created: ${summary.created.length}, kept: ${summary.kept.length}, overridden: ${summary.overridden.length}\n`);
273
+ stdout(`synthesis backend: ${summary.backend}\n`);
274
+ for (const plist of summary.plists) {
275
+ stdout(`launchd job loaded: ${plist}\n`);
276
+ }
277
+ }
278
+ finally {
279
+ closePrompts();
280
+ }
281
+ }
282
+ async function runUninstallCommand(argv) {
283
+ const { homedir } = await import("node:os");
284
+ const { runCommand } = await import("./shell.js");
285
+ const { runUninstall } = await import("./uninstall.js");
286
+ const home = homedir();
287
+ const flags = parseInitArgs(argv);
288
+ const summary = await runUninstall({ claudeDir: flags.claudeDir ?? join(home, ".claude") }, {
289
+ appDir: appDir(),
290
+ exec: runCommand,
291
+ launchAgentsDir: join(home, "Library", "LaunchAgents"),
292
+ log: (message) => stdout(`${message}\n`),
293
+ uid: String(process.getuid?.() ?? 501)
294
+ });
295
+ for (const removedPath of summary.removed) {
296
+ stdout(`removed: ${removedPath}\n`);
297
+ }
298
+ }
299
+ async function main() {
300
+ const argv = process.argv.slice(2);
301
+ const command = argv[0];
302
+ if (command === "init") {
303
+ await runInitCommand(argv.slice(1));
304
+ return;
305
+ }
306
+ if (command === "uninstall") {
307
+ await runUninstallCommand(argv.slice(1));
308
+ return;
309
+ }
310
+ const syncArgv = command === "sync" ? argv.slice(1) : argv;
311
+ const result = await runCli(syncArgv);
312
+ if (result.errorCount > 0) {
313
+ process.exitCode = 1;
314
+ }
315
+ }
316
+ function isDirectInvocation() {
317
+ const entry = process.argv[1];
318
+ if (entry === undefined) {
319
+ return false;
320
+ }
321
+ // npm bin stubs and symlinked paths (e.g. /tmp on macOS) make argv[1]
322
+ // differ textually from import.meta.url; compare real paths or the CLI
323
+ // silently no-ops when invoked through a symlink.
324
+ try {
325
+ return realpathSync(entry) === fileURLToPath(import.meta.url);
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ }
331
+ if (isDirectInvocation()) {
332
+ main().catch((error) => {
333
+ // A clean one-line failure beats an unhandled-rejection stack trace.
334
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
335
+ process.exitCode = 1;
336
+ });
337
+ }