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/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
|
+
}
|
package/dist/classify.js
ADDED
|
@@ -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
|
+
}
|