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
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ import { isProjectSegment } from "./slug.js";
3
+ const episodeSchema = z.object({
4
+ content: z.string().trim().min(1),
5
+ project: z
6
+ .string()
7
+ .refine(isProjectSegment, {
8
+ message: "project must be a kebab-case slug or the reserved _inbox"
9
+ }),
10
+ title: z
11
+ .string()
12
+ .trim()
13
+ .min(1)
14
+ .max(120)
15
+ .regex(/^[^\n\r]+$/u, "title must be a single line")
16
+ });
17
+ export const episodePatchSchema = z.object({
18
+ episodes: z.array(episodeSchema)
19
+ });
20
+ export function parseEpisodePatch(raw) {
21
+ const parsed = JSON.parse(stripJsonFences(raw));
22
+ return episodePatchSchema.parse(parsed);
23
+ }
24
+ function stripJsonFences(raw) {
25
+ const trimmed = raw.trim();
26
+ const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```$/u.exec(trimmed);
27
+ return fenced ? fenced[1] : trimmed;
28
+ }
@@ -0,0 +1,43 @@
1
+ const MAX_SOURCE_CHARS = 16_000;
2
+ export async function buildEpisodePrompt(input, readSource) {
3
+ const sections = await Promise.all(input.sources.map(async (source) => {
4
+ const content = await readSource(source.path);
5
+ const truncated = content.length > MAX_SOURCE_CHARS
6
+ ? `${content.slice(0, MAX_SOURCE_CHARS)}\n[truncated]`
7
+ : content;
8
+ return [
9
+ `### Source (${source.type}, session ${source.sessionId})`,
10
+ "",
11
+ truncated
12
+ ].join("\n");
13
+ }));
14
+ return [
15
+ "You are the ingest step of an LLM-maintained wiki. Distill the curated",
16
+ `task artifacts below from the project "${input.project}" into episodic`,
17
+ "notes for the wiki's quarantined episodes folder.",
18
+ "",
19
+ "Rules:",
20
+ `- Respond with ONLY strict JSON: {"episodes": [{"project": "${input.project}",`,
21
+ ' "title": "...", "content": "..."}]} — no prose, no markdown fences.',
22
+ `- Every episode's project MUST be exactly "${input.project}".`,
23
+ "- One episode per coherent task; merge a plan/output pair for the same",
24
+ " session into one episode.",
25
+ "- title: short, specific, factual (max 120 chars, single line).",
26
+ "- content: Obsidian-flavored markdown. Capture what was done, decisions",
27
+ " made and why, and follow-ups left open. Cite session ids. Use",
28
+ " [[wikilinks]] only for project-specific entities. No emojis.",
29
+ "- Do not invent facts that are not in the sources.",
30
+ "",
31
+ "## Sources",
32
+ "",
33
+ sections.join("\n\n---\n\n")
34
+ ].join("\n");
35
+ }
36
+ export function assertEpisodesMatchProject(episodes, project) {
37
+ const foreign = episodes.filter((episode) => episode.project !== project);
38
+ if (foreign.length > 0) {
39
+ throw new Error(`Backend returned episodes for the wrong project: ${foreign
40
+ .map((episode) => episode.project)
41
+ .join(", ")} (expected ${project})`);
42
+ }
43
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ export async function ensureDirectory(directoryPath) {
5
+ await mkdir(directoryPath, { recursive: true });
6
+ }
7
+ export async function writeFileAtomic(filePath, content) {
8
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
9
+ try {
10
+ await writeFile(tempPath, content);
11
+ await rename(tempPath, filePath);
12
+ }
13
+ catch (error) {
14
+ await rm(tempPath, { force: true });
15
+ throw error;
16
+ }
17
+ }
18
+ export async function ensureWritableFilePath(filePath) {
19
+ try {
20
+ await removePathEntry(filePath);
21
+ }
22
+ catch (error) {
23
+ if (!isMissingPathError(error)) {
24
+ throw error;
25
+ }
26
+ }
27
+ }
28
+ export async function removePathEntry(path) {
29
+ const stat = await lstat(path);
30
+ if (stat.isSymbolicLink()) {
31
+ await unlink(path);
32
+ return;
33
+ }
34
+ if (stat.isDirectory()) {
35
+ await rm(path, { force: true, recursive: true });
36
+ return;
37
+ }
38
+ await rm(path, { force: true });
39
+ }
40
+ export async function collectFiles(root) {
41
+ const files = [];
42
+ await walkDirectory(root, async (absolutePath) => {
43
+ files.push({
44
+ absolutePath,
45
+ relativePath: relative(root, absolutePath)
46
+ });
47
+ });
48
+ return files;
49
+ }
50
+ export async function cleanupEmptyDirectories(root) {
51
+ await removeEmptyDirectories(root, false);
52
+ }
53
+ async function walkDirectory(directoryPath, visitFile) {
54
+ const entries = await readdir(directoryPath, { withFileTypes: true });
55
+ for (const entry of entries) {
56
+ const absolutePath = join(directoryPath, entry.name);
57
+ if (entry.isDirectory()) {
58
+ await walkDirectory(absolutePath, visitFile);
59
+ continue;
60
+ }
61
+ if (entry.isFile() || entry.isSymbolicLink()) {
62
+ await visitFile(absolutePath);
63
+ }
64
+ }
65
+ }
66
+ async function removeEmptyDirectories(directoryPath, removeCurrentDirectory) {
67
+ const entries = await readdir(directoryPath, { withFileTypes: true });
68
+ for (const entry of entries) {
69
+ if (!entry.isDirectory()) {
70
+ continue;
71
+ }
72
+ await removeEmptyDirectories(join(directoryPath, entry.name), true);
73
+ }
74
+ const remainingEntries = await readdir(directoryPath);
75
+ const isEmpty = remainingEntries.length === 0;
76
+ if (isEmpty && removeCurrentDirectory) {
77
+ await rm(directoryPath, { recursive: true });
78
+ }
79
+ return isEmpty;
80
+ }
81
+ function isMissingPathError(error) {
82
+ return (error instanceof Error &&
83
+ "code" in error &&
84
+ typeof error.code === "string" &&
85
+ error.code === "ENOENT");
86
+ }
package/dist/git.js ADDED
@@ -0,0 +1,61 @@
1
+ import { runCommand, runCommandAllowFailure } from "./shell.js";
2
+ export async function ensureGitRepository(projectRoot) {
3
+ const result = await runCommandAllowFailure("git", ["rev-parse", "--is-inside-work-tree"], projectRoot);
4
+ if (result.exitCode !== 0 || result.stdout.trim() !== "true") {
5
+ throw new Error(`Not a git repository: ${projectRoot}`);
6
+ }
7
+ }
8
+ export async function resolveDefaultBranch(projectRoot) {
9
+ const mainExists = await branchExists(projectRoot, "main");
10
+ if (mainExists) {
11
+ return "main";
12
+ }
13
+ const masterExists = await branchExists(projectRoot, "master");
14
+ if (masterExists) {
15
+ return "master";
16
+ }
17
+ throw new Error("Could not find local main or master branch");
18
+ }
19
+ export async function getCurrentBranch(projectRoot) {
20
+ const result = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectRoot);
21
+ return result.stdout.trim();
22
+ }
23
+ export async function checkoutBranch(projectRoot, branchName) {
24
+ const currentBranch = await getCurrentBranch(projectRoot);
25
+ if (currentBranch === branchName) {
26
+ return;
27
+ }
28
+ await runCommand("git", ["checkout", branchName], projectRoot);
29
+ }
30
+ export async function pullFastForward(projectRoot) {
31
+ await runCommand("git", ["pull", "--ff-only"], projectRoot);
32
+ }
33
+ export async function getDirtyStatus(projectRoot) {
34
+ const result = await runCommand("git", ["status", "--porcelain=v1", "-z"], projectRoot);
35
+ const entries = result.stdout.split("\0").filter((entry) => entry.length > 0);
36
+ const dirtyEntries = [];
37
+ for (let index = 0; index < entries.length; index += 1) {
38
+ const entry = entries[index];
39
+ const code = entry.slice(0, 2);
40
+ const path = entry.slice(3);
41
+ let originalPath;
42
+ if (code.includes("R") || code.includes("C")) {
43
+ originalPath = entries[index + 1];
44
+ index += 1;
45
+ }
46
+ dirtyEntries.push({
47
+ code,
48
+ originalPath,
49
+ path
50
+ });
51
+ }
52
+ return dirtyEntries;
53
+ }
54
+ export async function listSyncFiles(projectRoot) {
55
+ const result = await runCommand("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], projectRoot);
56
+ return result.stdout.split("\0").filter((entry) => entry.length > 0);
57
+ }
58
+ async function branchExists(projectRoot, branchName) {
59
+ const result = await runCommandAllowFailure("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], projectRoot);
60
+ return result.exitCode === 0;
61
+ }
@@ -0,0 +1,86 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import { resolve, sep } from "node:path";
3
+ import { ensureDirectory, writeFileAtomic } from "./filesystem.js";
4
+ import { isProjectSegment, slugify } from "./slug.js";
5
+ export async function writeEpisodes(input) {
6
+ if (input.episodes.length === 0) {
7
+ return [];
8
+ }
9
+ await ensureDirectory(input.episodesRoot);
10
+ const root = resolve(input.episodesRoot);
11
+ const written = [];
12
+ const claimed = new Set();
13
+ for (const episode of input.episodes) {
14
+ if (!isProjectSegment(episode.project)) {
15
+ throw new Error(`Episode project is not a valid segment: ${episode.project}`);
16
+ }
17
+ const titleSlug = slugify(episode.title);
18
+ if (titleSlug.length === 0) {
19
+ throw new Error(`Episode title does not slugify: ${episode.title}`);
20
+ }
21
+ const baseName = `${input.date}-${episode.project}-${titleSlug}`;
22
+ const episodePath = await claimEpisodePath(root, baseName, claimed);
23
+ if (!episodePath.startsWith(`${root}${sep}`)) {
24
+ throw new Error(`Episode path escapes the episodes root: ${episodePath}`);
25
+ }
26
+ // JSON-quoting is valid YAML and neutralizes newlines, colons, and quotes
27
+ // in backend-controlled values — frontmatter keys cannot be injected.
28
+ const content = [
29
+ "---",
30
+ `title: ${JSON.stringify(episode.title)}`,
31
+ `project: ${episode.project}`,
32
+ `date: ${input.date}`,
33
+ "type: episode",
34
+ "---",
35
+ "",
36
+ episode.content.trim(),
37
+ ""
38
+ ].join("\n");
39
+ await writeFileAtomic(episodePath, content);
40
+ written.push(episodePath);
41
+ }
42
+ return written;
43
+ }
44
+ export async function appendLogEntry(logPath, entry) {
45
+ let existing = "";
46
+ try {
47
+ existing = await readFile(logPath, "utf8");
48
+ }
49
+ catch (error) {
50
+ if (!isMissingFileError(error)) {
51
+ throw error;
52
+ }
53
+ }
54
+ const base = existing.length > 0 ? `${existing.replace(/\n+$/u, "")}\n\n` : "";
55
+ await writeFileAtomic(logPath, `${base}${entry.trim()}\n`);
56
+ }
57
+ async function claimEpisodePath(root, baseName, claimed) {
58
+ for (let suffix = 1; suffix <= 50; suffix += 1) {
59
+ const name = suffix === 1 ? `${baseName}.md` : `${baseName}-${suffix}.md`;
60
+ const candidate = resolve(root, name);
61
+ if (claimed.has(candidate) || (await pathExists(candidate))) {
62
+ continue;
63
+ }
64
+ claimed.add(candidate);
65
+ return candidate;
66
+ }
67
+ throw new Error(`Could not find a free episode filename for ${baseName}`);
68
+ }
69
+ async function pathExists(path) {
70
+ try {
71
+ await lstat(path);
72
+ return true;
73
+ }
74
+ catch (error) {
75
+ if (isMissingFileError(error)) {
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+ }
81
+ function isMissingFileError(error) {
82
+ return (error instanceof Error &&
83
+ "code" in error &&
84
+ typeof error.code === "string" &&
85
+ error.code === "ENOENT");
86
+ }
package/dist/ingest.js ADDED
@@ -0,0 +1,217 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import { sep } from "node:path";
3
+ import { parseArtifact } from "./artifact-frontmatter.js";
4
+ import { hashContent } from "./changeset.js";
5
+ import { classifySources, groupByProject, hasWork, renderedFileToIngestSource, toManifestEntries } from "./classify.js";
6
+ import { collectFiles } from "./filesystem.js";
7
+ import { appendLogEntry, writeEpisodes } from "./ingest-writer.js";
8
+ import { loadManifest, saveManifest, withManifestEntries } from "./manifest.js";
9
+ // Artifacts are provider-agnostic captures; they live in their own key
10
+ // namespace so they can never collide with provider transcript entries.
11
+ const ARTIFACT_PROVIDER = "artifact";
12
+ // Per-call source cap keeps the synthesized prompt within argv limits; a
13
+ // project with more work is processed in batches within the same run.
14
+ const SYNTHESIS_BATCH_SIZE = 8;
15
+ export async function runIngest(input) {
16
+ const { quarantined, sources: artifactSources } = await scanArtifacts(input.artifactsRoot);
17
+ const transcriptSources = input.renderedFiles.map(renderedFileToIngestSource);
18
+ const manifest = await loadManifest(input.manifestPath);
19
+ const classified = classifySources(manifest, [
20
+ ...artifactSources,
21
+ ...transcriptSources
22
+ ]);
23
+ // A broken artifact stays on disk forever; report it once per content
24
+ // version instead of spamming the log every hour.
25
+ const freshQuarantined = quarantined.filter((artifact) => manifest.entries[quarantineKey(artifact)] === undefined);
26
+ if (!hasWork(classified) && freshQuarantined.length === 0) {
27
+ return {
28
+ artifactsIngested: [],
29
+ episodePaths: [],
30
+ failures: [],
31
+ quarantined: [],
32
+ skipped: true,
33
+ trackedTranscripts: 0,
34
+ transcripts: { added: [], changed: [] }
35
+ };
36
+ }
37
+ const workSources = [...classified.added, ...classified.changed];
38
+ const artifactWork = workSources.filter((source) => source.type !== "transcript");
39
+ const transcriptWork = workSources.filter((source) => source.type === "transcript");
40
+ const failures = [];
41
+ const synthesized = [];
42
+ for (const [project, sources] of groupByProject(artifactWork)) {
43
+ for (const batch of chunk(sources, SYNTHESIS_BATCH_SIZE)) {
44
+ try {
45
+ const output = await input.backend.synthesize({ project, sources: batch });
46
+ synthesized.push({ episodes: output.episodes, sources: batch });
47
+ }
48
+ catch (error) {
49
+ failures.push({
50
+ project,
51
+ reason: error instanceof Error ? error.message : "Unknown error"
52
+ });
53
+ }
54
+ }
55
+ }
56
+ const now = input.now();
57
+ const date = now.toISOString().slice(0, 10);
58
+ const episodes = synthesized.flatMap((group) => group.episodes);
59
+ // Transactional order (design 4.2): pages -> log -> manifest last.
60
+ const episodePaths = await writeEpisodes({
61
+ date,
62
+ episodes,
63
+ episodesRoot: input.episodesRoot
64
+ });
65
+ const transcriptsAdded = classified.added.filter((source) => source.type === "transcript");
66
+ const transcriptsChanged = classified.changed.filter((source) => source.type === "transcript");
67
+ const artifactsIngested = synthesized.flatMap((group) => group.sources);
68
+ let report;
69
+ // The report is a wiki page: it belongs to the pages step, before log and
70
+ // manifest, so a crashed run redoes it instead of losing it forever.
71
+ if (input.reportWriter) {
72
+ report = await input.reportWriter.addIngest({
73
+ artifacts: artifactsIngested,
74
+ // writeEpisodes preserves input order, so paths zip with drafts.
75
+ episodes: episodePaths.map((path, index) => ({
76
+ path,
77
+ project: episodes[index].project
78
+ })),
79
+ failures,
80
+ quarantined: freshQuarantined,
81
+ transcriptsAdded,
82
+ transcriptsChanged
83
+ });
84
+ }
85
+ if (episodePaths.length > 0 ||
86
+ failures.length > 0 ||
87
+ freshQuarantined.length > 0 ||
88
+ report !== undefined) {
89
+ await appendLogEntry(input.logPath, buildLogEntry({
90
+ date,
91
+ episodePaths,
92
+ failures,
93
+ quarantined: freshQuarantined,
94
+ reportLink: report?.wikiLink
95
+ }));
96
+ }
97
+ const ingestedAt = now.toISOString();
98
+ const ingestedSources = [...artifactsIngested, ...transcriptWork];
99
+ const quarantineEntries = Object.fromEntries(freshQuarantined.map((artifact) => [
100
+ quarantineKey(artifact),
101
+ { contentHash: artifact.contentHash, ingestedAt }
102
+ ]));
103
+ const nextManifest = withManifestEntries(manifest, {
104
+ ...toManifestEntries(ingestedSources, ingestedAt),
105
+ ...quarantineEntries
106
+ });
107
+ await saveManifest(input.manifestPath, nextManifest);
108
+ return {
109
+ artifactsIngested,
110
+ episodePaths,
111
+ failures,
112
+ quarantined: freshQuarantined,
113
+ ...(report ? { reportPath: report.path } : {}),
114
+ skipped: false,
115
+ trackedTranscripts: transcriptWork.length,
116
+ transcripts: { added: transcriptsAdded, changed: transcriptsChanged }
117
+ };
118
+ }
119
+ function quarantineKey(artifact) {
120
+ return `quarantine:artifact:${artifact.contentHash}`;
121
+ }
122
+ function chunk(items, size) {
123
+ const batches = [];
124
+ for (let index = 0; index < items.length; index += size) {
125
+ batches.push(items.slice(index, index + size));
126
+ }
127
+ return batches;
128
+ }
129
+ async function scanArtifacts(artifactsRoot) {
130
+ let files;
131
+ try {
132
+ files = await collectFiles(artifactsRoot);
133
+ }
134
+ catch (error) {
135
+ if (isMissingFileError(error)) {
136
+ return { quarantined: [], sources: [] };
137
+ }
138
+ throw error;
139
+ }
140
+ const quarantined = [];
141
+ const sources = [];
142
+ const resolvedRoot = await realpath(artifactsRoot);
143
+ for (const file of files) {
144
+ if (!file.relativePath.endsWith(".md")) {
145
+ continue;
146
+ }
147
+ // Ingest-side re-validation (design 7.6): refuse symlinks and anything
148
+ // that resolves outside the artifacts root before reading a byte.
149
+ const stat = await lstat(file.absolutePath);
150
+ if (stat.isSymbolicLink()) {
151
+ quarantined.push({
152
+ contentHash: hashContent(`symlink:${file.absolutePath}`),
153
+ path: file.absolutePath,
154
+ reason: "Refused symlinked artifact"
155
+ });
156
+ continue;
157
+ }
158
+ const resolved = await realpath(file.absolutePath);
159
+ if (!resolved.startsWith(`${resolvedRoot}${sep}`)) {
160
+ quarantined.push({
161
+ contentHash: hashContent(`escape:${file.absolutePath}`),
162
+ path: file.absolutePath,
163
+ reason: "Artifact resolves outside the artifacts root"
164
+ });
165
+ continue;
166
+ }
167
+ let content = "";
168
+ try {
169
+ content = await readFile(file.absolutePath, "utf8");
170
+ const parsed = parseArtifact(content);
171
+ sources.push({
172
+ contentHash: hashContent(content),
173
+ path: file.absolutePath,
174
+ project: parsed.frontmatter.project,
175
+ provider: ARTIFACT_PROVIDER,
176
+ sessionId: parsed.frontmatter.session_id,
177
+ type: parsed.frontmatter.type
178
+ });
179
+ }
180
+ catch (error) {
181
+ quarantined.push({
182
+ contentHash: hashContent(`${file.absolutePath}\n${content}`),
183
+ path: file.absolutePath,
184
+ reason: error instanceof Error ? error.message : "Unknown error"
185
+ });
186
+ }
187
+ }
188
+ return { quarantined, sources };
189
+ }
190
+ function buildLogEntry(input) {
191
+ const lines = [`## [${input.date}] ingest | hourly episodes`];
192
+ if (input.reportLink !== undefined) {
193
+ lines.push(`Run report: [[${input.reportLink}]]`);
194
+ }
195
+ if (input.episodePaths.length > 0) {
196
+ const links = input.episodePaths
197
+ .map((path) => {
198
+ const name = path.split("/").at(-1)?.replace(/\.md$/u, "") ?? path;
199
+ return `[[episodes/${name}]]`;
200
+ })
201
+ .join(", ");
202
+ lines.push(`Episodes created: ${links}`);
203
+ }
204
+ for (const failure of input.failures) {
205
+ lines.push(`Failed project (manifest not advanced): ${failure.project} — ${failure.reason}`);
206
+ }
207
+ for (const artifact of input.quarantined) {
208
+ lines.push(`Quarantined artifact: ${artifact.path} — ${artifact.reason}`);
209
+ }
210
+ return lines.join("\n");
211
+ }
212
+ function isMissingFileError(error) {
213
+ return (error instanceof Error &&
214
+ "code" in error &&
215
+ typeof error.code === "string" &&
216
+ error.code === "ENOENT");
217
+ }