obsidian-second-brain 0.1.1 → 0.2.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.
@@ -1,30 +1,51 @@
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);
1
+ // Historical strings existing vaults already contain them; never reword.
2
+ export const INIT_MARKERS = {
3
+ begin: "<!-- BEGIN second-brain (managed by second-brain init — do not edit inside) -->",
4
+ end: "<!-- END second-brain -->"
5
+ };
6
+ export const CODE_MAPS_MARKERS = {
7
+ begin: "<!-- BEGIN second-brain code-maps (managed by second-brain — do not edit inside) -->",
8
+ end: "<!-- END second-brain code-maps -->"
9
+ };
10
+ export function hasManagedBlock(content, markers) {
11
+ return content.includes(markers.begin) && content.includes(markers.end);
5
12
  }
6
13
  // Idempotent: re-applying replaces the block in place, never duplicates
7
- // (design 7.3). The block argument must already contain the markers.
14
+ // (design 7.3). `body` must NOT contain the markers — they are added here.
15
+ export function applyManagedBlock(existing, markers, body) {
16
+ const block = [markers.begin, body.trim(), markers.end].join("\n");
17
+ return applyBlockString(existing, markers, block);
18
+ }
19
+ export function removeManagedBlock(content, markers) {
20
+ if (!hasManagedBlock(content, markers)) {
21
+ return content;
22
+ }
23
+ const beginIndex = content.indexOf(markers.begin);
24
+ const endIndex = content.indexOf(markers.end) + markers.end.length;
25
+ const before = content.slice(0, beginIndex).replace(/\n+$/u, "");
26
+ const after = content.slice(endIndex).replace(/^\n+/u, "");
27
+ const joined = [before, after].filter((part) => part.length > 0).join("\n");
28
+ return joined.length > 0 ? `${joined}\n` : "";
29
+ }
30
+ // Legacy init API — the block argument already contains the INIT markers.
31
+ export function hasManagedSection(content) {
32
+ return hasManagedBlock(content, INIT_MARKERS);
33
+ }
8
34
  export function applyManagedSection(existing, block) {
35
+ return applyBlockString(existing, INIT_MARKERS, block);
36
+ }
37
+ export function removeManagedSection(content) {
38
+ return removeManagedBlock(content, INIT_MARKERS);
39
+ }
40
+ function applyBlockString(existing, markers, block) {
9
41
  const trimmedBlock = block.trim();
10
42
  if (existing === undefined || existing.trim().length === 0) {
11
43
  return `${trimmedBlock}\n`;
12
44
  }
13
- if (hasManagedSection(existing)) {
14
- const beginIndex = existing.indexOf(BEGIN_MARKER);
15
- const endIndex = existing.indexOf(END_MARKER) + END_MARKER.length;
45
+ if (hasManagedBlock(existing, markers)) {
46
+ const beginIndex = existing.indexOf(markers.begin);
47
+ const endIndex = existing.indexOf(markers.end) + markers.end.length;
16
48
  return `${existing.slice(0, beginIndex)}${trimmedBlock}${existing.slice(endIndex)}`;
17
49
  }
18
50
  return `${existing.replace(/\n+$/u, "")}\n\n${trimmedBlock}\n`;
19
51
  }
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,191 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { captureRepo, defaultCaptureDependencies } from "./code-capture.js";
4
+ import { stripCodeManifestEntries } from "./code-classify.js";
5
+ import { runCodeMapIngest } from "./code-ingest.js";
6
+ import { createCliClaudeCodeMapBackend, resolveCodeMapBackend } from "./code-map-backend.js";
7
+ import { DEFAULT_MAX_MODULES } from "./code-map-config.js";
8
+ import { loadConfig } from "./config.js";
9
+ import { writeFileAtomic } from "./filesystem.js";
10
+ import { acquireLock } from "./lock.js";
11
+ import { loadManifest, saveManifest } from "./manifest.js";
12
+ import { createReportWriter } from "./report.js";
13
+ import { isSlug, slugify } from "./slug.js";
14
+ import { resolveVaultPaths } from "./vault-paths.js";
15
+ const USAGE = "Usage: second-brain map <repoPath> [--project <name>] [--path <subdir>] [--max-modules N] [--force]";
16
+ export function parseMapArgs(argv) {
17
+ let configPath;
18
+ let force = false;
19
+ let maxModules;
20
+ let project;
21
+ let repoPath;
22
+ let scope;
23
+ for (let index = 0; index < argv.length; index += 1) {
24
+ const currentArg = argv[index];
25
+ if (currentArg === "--force") {
26
+ force = true;
27
+ continue;
28
+ }
29
+ if (currentArg === "--config" ||
30
+ currentArg === "--max-modules" ||
31
+ currentArg === "--path" ||
32
+ currentArg === "--project") {
33
+ const nextArg = argv[index + 1];
34
+ if (!nextArg) {
35
+ throw new Error(`Missing value for ${currentArg}`);
36
+ }
37
+ if (currentArg === "--config") {
38
+ configPath = nextArg;
39
+ }
40
+ else if (currentArg === "--project") {
41
+ project = nextArg;
42
+ }
43
+ else if (currentArg === "--path") {
44
+ scope = nextArg.replace(/\/+$/u, "");
45
+ }
46
+ else {
47
+ const parsed = Number(nextArg);
48
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 64) {
49
+ throw new Error(`Invalid --max-modules: ${nextArg}`);
50
+ }
51
+ maxModules = parsed;
52
+ }
53
+ index += 1;
54
+ continue;
55
+ }
56
+ if (currentArg.startsWith("--")) {
57
+ throw new Error(`Unknown argument: ${currentArg}`);
58
+ }
59
+ if (repoPath !== undefined) {
60
+ throw new Error(`Unexpected argument: ${currentArg}`);
61
+ }
62
+ repoPath = currentArg;
63
+ }
64
+ if (repoPath === undefined) {
65
+ throw new Error(USAGE);
66
+ }
67
+ return {
68
+ ...(configPath === undefined ? {} : { configPath }),
69
+ force,
70
+ ...(maxModules === undefined ? {} : { maxModules }),
71
+ ...(project === undefined ? {} : { project }),
72
+ repoPath,
73
+ ...(scope === undefined ? {} : { scope })
74
+ };
75
+ }
76
+ export async function runMapCommand(argv, dependencies) {
77
+ const flags = parseMapArgs(argv);
78
+ const configPath = resolve(flags.configPath ?? dependencies.defaultConfigPath);
79
+ const config = await loadConfig(configPath);
80
+ const repoPath = resolve(flags.repoPath);
81
+ const project = flags.project ?? slugify(basename(repoPath));
82
+ if (!isSlug(project)) {
83
+ throw new Error(`Project name does not slugify cleanly: ${flags.project ?? basename(repoPath)} — pass --project`);
84
+ }
85
+ const flagScope = flags.scope === undefined ? "" : flags.scope;
86
+ const byProject = config.codeMap?.repos.find((repo) => repo.project === project);
87
+ const byTarget = config.codeMap?.repos.find((repo) => repo.path === repoPath && (repo.scope ?? "") === flagScope);
88
+ if (byProject !== undefined && (byProject.path !== repoPath || (byProject.scope ?? "") !== flagScope)) {
89
+ throw new Error(`Project ${project} is already registered for ${byProject.path} — pass a different --project`);
90
+ }
91
+ if (byTarget !== undefined && byTarget.project !== project) {
92
+ throw new Error(`Repo is already registered as project ${byTarget.project}`);
93
+ }
94
+ const registered = byProject ?? byTarget;
95
+ const repo = {
96
+ ...(flags.maxModules !== undefined
97
+ ? { maxModules: flags.maxModules }
98
+ : registered?.maxModules === undefined
99
+ ? {}
100
+ : { maxModules: registered.maxModules }),
101
+ ...(registered?.modules === undefined ? {} : { modules: registered.modules }),
102
+ path: repoPath,
103
+ project,
104
+ ...(flags.scope !== undefined
105
+ ? { scope: flags.scope }
106
+ : registered?.scope === undefined
107
+ ? {}
108
+ : { scope: registered.scope })
109
+ };
110
+ const vaultPaths = resolveVaultPaths(config.vaultRoot);
111
+ // Same lock as the hourly job — map must never interleave with it.
112
+ const lock = await acquireLock(vaultPaths.lockPath);
113
+ try {
114
+ // 1. Capture. Read-before-write: a git failure aborts before any write,
115
+ // so a typo'd path leaves the vault byte-identical (design 6.1).
116
+ const captureSummary = await captureRepo({
117
+ date: dependencies.now().toISOString().slice(0, 10),
118
+ globalMaxModules: config.codeMap?.maxModules ?? DEFAULT_MAX_MODULES,
119
+ rawCodeRoot: vaultPaths.rawCodeRoot,
120
+ repo
121
+ }, defaultCaptureDependencies(repoPath));
122
+ dependencies.log(`captured ${repo.project}: written=${captureSummary.evidenceWritten}, unchanged=${captureSummary.evidenceUnchanged}, modules=${captureSummary.moduleCount}`);
123
+ // 2. Registration after a successful capture (design 7).
124
+ if (registered === undefined) {
125
+ await registerRepo(configPath, {
126
+ ...(flags.maxModules === undefined ? {} : { maxModules: flags.maxModules }),
127
+ path: repoPath,
128
+ project,
129
+ ...(flags.scope === undefined ? {} : { scope: flags.scope })
130
+ });
131
+ dependencies.log(`registered ${project} in ${configPath}`);
132
+ }
133
+ // 3. --force: clear this project's code:* manifest entries so every page
134
+ // re-renders (decoded-segment matching, design 7).
135
+ if (flags.force) {
136
+ const manifest = await loadManifest(vaultPaths.manifestPath);
137
+ await saveManifest(vaultPaths.manifestPath, stripCodeManifestEntries(manifest, project));
138
+ dependencies.log(`cleared code manifest entries for ${project}`);
139
+ }
140
+ // 4. Immediate ingest so pages exist right away.
141
+ const models = config.codeMap?.models ?? { pages: "haiku", structure: "sonnet" };
142
+ const backend = resolveCodeMapBackend(config.synthesis.backend, () => createCliClaudeCodeMapBackend({
143
+ ...(config.synthesis.claudeBin === undefined
144
+ ? {}
145
+ : { claudeBin: config.synthesis.claudeBin }),
146
+ logsRoot: config.logsRoot,
147
+ models
148
+ }));
149
+ if (backend === undefined) {
150
+ dependencies.log("synthesis backend is noop — evidence captured; pages will render once a backend is configured");
151
+ return;
152
+ }
153
+ const reportWriter = createReportWriter({
154
+ now: dependencies.now,
155
+ reportsRoot: vaultPaths.reportsRoot,
156
+ runKind: "manual",
157
+ vaultRoot: vaultPaths.vaultDir
158
+ });
159
+ const summary = await runCodeMapIngest({
160
+ backend,
161
+ evidenceCaptured: captureSummary.evidenceWritten,
162
+ indexPath: vaultPaths.indexPath,
163
+ logPath: vaultPaths.wikiLogPath,
164
+ manifestPath: vaultPaths.manifestPath,
165
+ now: dependencies.now,
166
+ rawCodeRoot: vaultPaths.rawCodeRoot,
167
+ reportWriter,
168
+ wikiCodeRoot: vaultPaths.wikiCodeRoot
169
+ });
170
+ dependencies.log(`pages rendered: ${summary.pagesRendered.length}, pruned: ${summary.pagesPruned.length}, failures: ${summary.failures.length}`);
171
+ for (const failure of summary.failures) {
172
+ dependencies.log(`failed: ${failure.project} — ${failure.reason}`);
173
+ }
174
+ if (summary.failures.length > 0) {
175
+ process.exitCode = 1;
176
+ }
177
+ }
178
+ finally {
179
+ await lock.release();
180
+ }
181
+ }
182
+ // Registration edits the RAW config file (not the resolved view) so user
183
+ // formatting conventions like relative provider paths survive untouched.
184
+ async function registerRepo(configPath, entry) {
185
+ const raw = JSON.parse(await readFile(configPath, "utf8"));
186
+ const codeMap = typeof raw.codeMap === "object" && raw.codeMap !== null
187
+ ? raw.codeMap
188
+ : {};
189
+ const repos = Array.isArray(codeMap.repos) ? codeMap.repos : [];
190
+ await writeFileAtomic(configPath, `${JSON.stringify({ ...raw, codeMap: { ...codeMap, repos: [...repos, entry] } }, null, 2)}\n`);
191
+ }
package/dist/report.js CHANGED
@@ -21,6 +21,7 @@ export function createReportWriter(input) {
21
21
  state.reportPath ??
22
22
  (await claimReportPath(input.reportsRoot, createdAt, input.runKind));
23
23
  const content = await buildReportContent({
24
+ codeMap: state.codeMap,
24
25
  consolidation: state.consolidation,
25
26
  createdAt,
26
27
  ingest: state.ingest,
@@ -42,6 +43,10 @@ export function createReportWriter(input) {
42
43
  };
43
44
  };
44
45
  return {
46
+ addCodeMap: async (data) => {
47
+ state.codeMap = data;
48
+ return write();
49
+ },
45
50
  addConsolidation: async (data) => {
46
51
  state.consolidation = data;
47
52
  return write();
@@ -58,6 +63,9 @@ async function buildReportContent(input) {
58
63
  if (input.ingest) {
59
64
  lines.push(`transcripts_added: ${input.ingest.transcriptsAdded.length}`, `transcripts_changed: ${input.ingest.transcriptsChanged.length}`, `artifacts_ingested: ${input.ingest.artifacts.length}`, `episodes_created: ${input.ingest.episodes.length}`, `ingest_failures: ${input.ingest.failures.length}`, `artifacts_quarantined: ${input.ingest.quarantined.length}`);
60
65
  }
66
+ if (input.codeMap) {
67
+ lines.push(`code_evidence_captured: ${input.codeMap.evidenceCaptured}`, `code_pages_rendered: ${input.codeMap.pagesRendered.length}`, `code_pages_pruned: ${input.codeMap.pagesPruned.length}`, `code_failures: ${input.codeMap.failures.length}`);
68
+ }
61
69
  if (input.consolidation) {
62
70
  lines.push(`pages_created: ${input.consolidation.pagesCreated.length}`, `pages_updated: ${input.consolidation.pagesUpdated.length}`, `episodes_consolidated: ${input.consolidation.episodesConsolidated}`, `consolidation_failures: ${input.consolidation.failures.length}`, `episodes_quarantined: ${input.consolidation.quarantined.length}`);
63
71
  }
@@ -66,6 +74,9 @@ async function buildReportContent(input) {
66
74
  if (input.ingest) {
67
75
  lines.push("", ...(await buildIngestSection(input.ingest, input.vaultRoot)));
68
76
  }
77
+ if (input.codeMap) {
78
+ lines.push("", ...buildCodeMapSection(input.codeMap));
79
+ }
69
80
  if (input.consolidation) {
70
81
  lines.push("", ...buildConsolidationSection(input.consolidation));
71
82
  }
@@ -121,6 +132,29 @@ async function buildIngestSection(data, vaultRoot) {
121
132
  }
122
133
  return lines;
123
134
  }
135
+ function buildCodeMapSection(data) {
136
+ const lines = ["## Code map", ""];
137
+ lines.push(`Evidence files captured: ${data.evidenceCaptured}`);
138
+ if (data.pagesRendered.length > 0) {
139
+ lines.push(`Pages rendered: ${data.pagesRendered.map((page) => `[[code/${page}]]`).join(", ")}`);
140
+ }
141
+ if (data.pagesPruned.length > 0) {
142
+ lines.push(`Pages pruned: ${data.pagesPruned.map((page) => `\`code/${page}\``).join(", ")}`);
143
+ }
144
+ if (data.failures.length > 0) {
145
+ lines.push("", "### Failures", "");
146
+ for (const failure of data.failures) {
147
+ lines.push(`- ${failure.project} — ${failure.reason}`);
148
+ }
149
+ }
150
+ if (data.quarantined.length > 0) {
151
+ lines.push("", "### Quarantined evidence", "");
152
+ for (const entry of data.quarantined) {
153
+ lines.push(`- \`${entry.path}\` — ${entry.reason}`);
154
+ }
155
+ }
156
+ return lines;
157
+ }
124
158
  function buildConsolidationSection(data) {
125
159
  const lines = ["## Consolidation", ""];
126
160
  if (data.pagesCreated.length > 0) {
package/dist/shell.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
4
  export async function runCommand(command, args, cwd, env) {
@@ -40,6 +40,60 @@ export async function runCommandAllowFailure(command, args, cwd) {
40
40
  };
41
41
  }
42
42
  }
43
+ // execFile cannot feed stdin; the code-map backend pipes prompts that exceed
44
+ // OS argv limits, so this spawn-based variant exists alongside it.
45
+ export async function runCommandWithInput(command, args, cwd, input) {
46
+ return new Promise((resolvePromise, rejectPromise) => {
47
+ const child = spawn(command, [...args], { cwd });
48
+ let stdout = "";
49
+ let stderr = "";
50
+ let settled = false;
51
+ const settle = (result) => {
52
+ if (settled) {
53
+ return;
54
+ }
55
+ settled = true;
56
+ if (result instanceof Error) {
57
+ rejectPromise(result);
58
+ }
59
+ else {
60
+ resolvePromise(result);
61
+ }
62
+ };
63
+ child.stdout.setEncoding("utf8");
64
+ child.stdout.on("data", (chunk) => {
65
+ stdout += chunk;
66
+ });
67
+ child.stderr.setEncoding("utf8");
68
+ child.stderr.on("data", (chunk) => {
69
+ stderr += chunk;
70
+ });
71
+ child.on("error", (error) => {
72
+ settle(new Error(buildCommandError(command, args, {
73
+ code: error.code,
74
+ message: error.message,
75
+ stderr,
76
+ stdout
77
+ })));
78
+ });
79
+ child.on("close", (code, signal) => {
80
+ if (code === 0) {
81
+ settle({ stderr, stdout });
82
+ return;
83
+ }
84
+ settle(new Error(buildCommandError(command, args, {
85
+ ...(code === null ? {} : { code }),
86
+ ...(signal === null ? {} : { signal }),
87
+ stderr,
88
+ stdout
89
+ })));
90
+ });
91
+ // A child that exits before draining stdin raises EPIPE here; the close
92
+ // handler already carries the real outcome.
93
+ child.stdin.on("error", () => undefined);
94
+ child.stdin.end(input);
95
+ });
96
+ }
43
97
  // Long prompts are passed as argv: echoing them whole made failure reasons
44
98
  // unreadable, while the actual diagnostic (exit code, spawn error, stream
45
99
  // tails) was missing entirely.
@@ -0,0 +1,23 @@
1
+ import { join, resolve, sep } from "node:path";
2
+ export function resolveVaultPaths(vaultRoot) {
3
+ // Defense-in-depth (design 7.6): the prune scope must stay confined to
4
+ // raw/projects, so a hand-edited config cannot point ingest at a stray tree.
5
+ const normalized = resolve(vaultRoot);
6
+ if (!normalized.endsWith(join(`${sep}raw`, "projects"))) {
7
+ throw new Error(`vaultRoot must end in raw/projects to run ingest, got: ${vaultRoot}`);
8
+ }
9
+ const vault = resolve(normalized, "..", "..");
10
+ return {
11
+ artifactsRoot: join(vault, "raw", "artifacts"),
12
+ episodesRoot: join(vault, "wiki", "episodes"),
13
+ indexPath: join(vault, "wiki", "index.md"),
14
+ lockPath: join(vault, "wiki", ".ingest.lock"),
15
+ manifestPath: join(vault, "wiki", ".ingest-state.json"),
16
+ rawCodeRoot: join(vault, "raw", "code"),
17
+ reportsRoot: join(vault, "wiki", "reports"),
18
+ vaultDir: vault,
19
+ wikiCodeRoot: join(vault, "wiki", "code"),
20
+ wikiLogPath: join(vault, "wiki", "log.md"),
21
+ wikiRoot: join(vault, "wiki")
22
+ };
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-second-brain",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Turn an Obsidian vault into a self-maintaining second brain for AI coding sessions",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -28,7 +28,7 @@
28
28
  "sync": "tsx src/cli.ts",
29
29
  "test": "tsx --test tests/**/*.test.ts",
30
30
  "coverage": "tsx --test --experimental-test-coverage tests/**/*.test.ts",
31
- "e2e": "npm run build && tsx --test tests/e2e/cli.e2e.ts tests/e2e/lifecycle.e2e.ts tests/e2e/claude-smoke.e2e.ts"
31
+ "e2e": "npm run build && tsx --test tests/e2e/cli.e2e.ts tests/e2e/lifecycle.e2e.ts tests/e2e/code-map.e2e.ts tests/e2e/claude-smoke.e2e.ts"
32
32
  },
33
33
  "dependencies": {
34
34
  "zod": "^4.1.12"