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.
@@ -0,0 +1,150 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { hashContent } from "./changeset.js";
4
+ import { compareBytewise } from "./code-boundaries.js";
5
+ import { hashEvidenceBody, parseCodeEvidence } from "./code-frontmatter.js";
6
+ import { buildSourceKey } from "./manifest.js";
7
+ import { isSlug } from "./slug.js";
8
+ export const CODE_PROVIDER = "code";
9
+ export function codeSourceKey(kind, project, slug) {
10
+ return buildSourceKey({
11
+ provider: CODE_PROVIDER,
12
+ sessionId: kind === "module" ? `${project}/${slug ?? ""}` : project,
13
+ type: kind
14
+ });
15
+ }
16
+ export async function scanCodeEvidence(rawCodeRoot) {
17
+ let projectEntries;
18
+ try {
19
+ projectEntries = (await readdir(rawCodeRoot)).sort(compareBytewise);
20
+ }
21
+ catch (error) {
22
+ if (isMissingFileError(error)) {
23
+ return { quarantined: [], sources: [] };
24
+ }
25
+ throw error;
26
+ }
27
+ const quarantined = [];
28
+ const sources = [];
29
+ for (const project of projectEntries) {
30
+ if (!isSlug(project)) {
31
+ continue;
32
+ }
33
+ const projectDir = join(rawCodeRoot, project);
34
+ if (!(await isRealDirectory(projectDir))) {
35
+ continue;
36
+ }
37
+ await readEvidenceFile(join(projectDir, "skeleton.md"), project, "skeleton", undefined, sources, quarantined);
38
+ let moduleFiles = [];
39
+ try {
40
+ moduleFiles = (await readdir(join(projectDir, "modules"))).sort(compareBytewise);
41
+ }
42
+ catch (error) {
43
+ if (!isMissingFileError(error)) {
44
+ throw error;
45
+ }
46
+ }
47
+ for (const entry of moduleFiles) {
48
+ if (!entry.endsWith(".md")) {
49
+ continue;
50
+ }
51
+ const slug = entry.slice(0, -3);
52
+ if (!isSlug(slug) || slug === "overview") {
53
+ quarantined.push({
54
+ contentHash: hashContent(`badslug:${projectDir}/${entry}`),
55
+ path: join(projectDir, "modules", entry),
56
+ reason: `Invalid module slug in evidence filename: ${entry}`
57
+ });
58
+ continue;
59
+ }
60
+ await readEvidenceFile(join(projectDir, "modules", entry), project, "module", slug, sources, quarantined);
61
+ }
62
+ }
63
+ return { quarantined, sources };
64
+ }
65
+ // --force support (design 7, codex F6): select entries by DECODED segments,
66
+ // never by raw string prefix on encoded keys — no other provider's state can
67
+ // match provider === "code".
68
+ export function stripCodeManifestEntries(manifest, project) {
69
+ const entries = Object.fromEntries(Object.entries(manifest.entries).filter(([key]) => !isCodeEntryForProject(key, project)));
70
+ return { entries, version: manifest.version };
71
+ }
72
+ function isCodeEntryForProject(key, project) {
73
+ const segments = key.split(":");
74
+ if (segments.length !== 3) {
75
+ return false;
76
+ }
77
+ let decoded;
78
+ try {
79
+ decoded = segments.map((segment) => decodeURIComponent(segment));
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ const [provider, , sessionId] = decoded;
85
+ return (provider === CODE_PROVIDER &&
86
+ (sessionId === project || sessionId.startsWith(`${project}/`)));
87
+ }
88
+ async function readEvidenceFile(path, project, kind, slug, sources, quarantined) {
89
+ let stat;
90
+ try {
91
+ stat = await lstat(path);
92
+ }
93
+ catch (error) {
94
+ if (isMissingFileError(error)) {
95
+ return;
96
+ }
97
+ throw error;
98
+ }
99
+ if (stat.isSymbolicLink()) {
100
+ quarantined.push({
101
+ contentHash: hashContent(`symlink:${path}`),
102
+ path,
103
+ reason: "Refused symlinked evidence"
104
+ });
105
+ return;
106
+ }
107
+ let content = "";
108
+ try {
109
+ content = await readFile(path, "utf8");
110
+ const parsed = parseCodeEvidence(content);
111
+ if (parsed.frontmatter.project !== project) {
112
+ throw new Error(`Evidence project "${parsed.frontmatter.project}" does not match directory "${project}"`);
113
+ }
114
+ if (kind === "module" && parsed.frontmatter.module !== slug) {
115
+ throw new Error(`Evidence module "${parsed.frontmatter.module ?? ""}" does not match filename "${slug ?? ""}"`);
116
+ }
117
+ sources.push({
118
+ body: parsed.body,
119
+ bodyHash: hashEvidenceBody(content),
120
+ frontmatter: parsed.frontmatter,
121
+ key: codeSourceKey(kind, project, slug),
122
+ kind,
123
+ path,
124
+ project,
125
+ ...(slug === undefined ? {} : { slug })
126
+ });
127
+ }
128
+ catch (error) {
129
+ quarantined.push({
130
+ contentHash: hashContent(`${path}\n${content}`),
131
+ path,
132
+ reason: error instanceof Error ? error.message : "Unknown error"
133
+ });
134
+ }
135
+ }
136
+ async function isRealDirectory(path) {
137
+ try {
138
+ const stat = await lstat(path);
139
+ return stat.isDirectory();
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ function isMissingFileError(error) {
146
+ return (error instanceof Error &&
147
+ "code" in error &&
148
+ typeof error.code === "string" &&
149
+ error.code === "ENOENT");
150
+ }
@@ -0,0 +1,113 @@
1
+ import { compareBytewise } from "./code-boundaries.js";
2
+ export const DIGEST_BUDGET_BYTES = 150_000;
3
+ // Files at or under this embed whole; larger ones contribute their head.
4
+ const FULL_TEXT_BYTES = 8192;
5
+ const HEAD_BYTES = 4096;
6
+ const TOP_ACTIVITY_LIMIT = 20;
7
+ const EXTENSION_LIMIT = 15;
8
+ export function buildSkeletonBody(input) {
9
+ const { modules, scan } = input;
10
+ const lines = [];
11
+ lines.push("## Layout", "");
12
+ if (input.scope !== undefined) {
13
+ lines.push(`Scope: \`${input.scope}\``, "");
14
+ }
15
+ lines.push(`Tracked files (post-exclusion): ${scan.files.length} (${scan.excludedCount} excluded)`, `Last commit: ${scan.lastCommitDate}`, "");
16
+ const topLevel = new Map();
17
+ for (const file of scan.files) {
18
+ const slashIndex = file.path.indexOf("/");
19
+ const key = slashIndex === -1 ? "(root)" : file.path.slice(0, slashIndex);
20
+ const entry = topLevel.get(key) ?? { bytes: 0, count: 0 };
21
+ topLevel.set(key, { bytes: entry.bytes + file.size, count: entry.count + 1 });
22
+ }
23
+ lines.push("| directory | files | bytes |", "|---|---|---|");
24
+ for (const [name, entry] of [...topLevel.entries()].sort((a, b) => compareBytewise(a[0], b[0]))) {
25
+ lines.push(`| ${name} | ${entry.count} | ${entry.bytes} |`);
26
+ }
27
+ lines.push("", "## Extensions", "");
28
+ const extensions = new Map();
29
+ for (const file of scan.files) {
30
+ const filename = file.path.split("/").at(-1) ?? "";
31
+ const dotIndex = filename.lastIndexOf(".");
32
+ const extension = dotIndex > 0 ? filename.slice(dotIndex + 1) : "(none)";
33
+ extensions.set(extension, (extensions.get(extension) ?? 0) + 1);
34
+ }
35
+ const topExtensions = [...extensions.entries()]
36
+ .sort((a, b) => b[1] - a[1] || compareBytewise(a[0], b[0]))
37
+ .slice(0, EXTENSION_LIMIT);
38
+ for (const [extension, count] of topExtensions) {
39
+ lines.push(`- ${extension}: ${count}`);
40
+ }
41
+ lines.push("", "## Modules", "");
42
+ for (const module of modules) {
43
+ lines.push(`- ${module.slug} — ${module.files.length} files (${module.name})`);
44
+ }
45
+ if (scan.manifests.length > 0) {
46
+ lines.push("", "## Manifests");
47
+ for (const manifest of scan.manifests) {
48
+ lines.push("", `### ${manifest.path}`, "", "````", manifest.text.trim(), "````");
49
+ }
50
+ }
51
+ if (scan.docs.length > 0) {
52
+ lines.push("", "## Docs");
53
+ for (const doc of scan.docs) {
54
+ lines.push("", `### ${doc.path}`, "", "````", doc.text.trim(), "````");
55
+ }
56
+ }
57
+ lines.push("", "## Activity", "");
58
+ const recent = [...scan.activity.entries()]
59
+ .sort((a, b) => compareBytewise(b[1], a[1]) || compareBytewise(a[0], b[0]))
60
+ .slice(0, TOP_ACTIVITY_LIMIT);
61
+ if (recent.length === 0) {
62
+ lines.push("No commits in the last 90 days.");
63
+ }
64
+ else {
65
+ for (const [path, date] of recent) {
66
+ lines.push(`- ${path} — ${date}`);
67
+ }
68
+ }
69
+ return lines.join("\n");
70
+ }
71
+ export async function buildModuleDigestBody(input) {
72
+ const files = [...input.files].sort((a, b) => compareBytewise(a.path, b.path));
73
+ const lines = ["## Files", "", "| path | bytes | last-touched |", "|---|---|---|"];
74
+ for (const file of files) {
75
+ lines.push(`| ${file.path} | ${file.size} | ${input.activity.get(file.path) ?? "-"} |`);
76
+ }
77
+ lines.push("", "## Contents");
78
+ let spent = Buffer.byteLength(lines.join("\n"), "utf8");
79
+ let omitted = 0;
80
+ for (const file of files) {
81
+ if (omitted > 0) {
82
+ // Once the budget is hit, stop adding texts entirely — a deterministic
83
+ // cut, not a best-fit packing.
84
+ omitted += 1;
85
+ continue;
86
+ }
87
+ const text = await input.readText(file.path);
88
+ const section = text === undefined
89
+ ? ["", `### ${file.path} [unreadable]`]
90
+ : [
91
+ "",
92
+ `### ${file.path}`,
93
+ "",
94
+ "````",
95
+ file.size <= FULL_TEXT_BYTES
96
+ ? text.trimEnd()
97
+ : `${text.slice(0, HEAD_BYTES).trimEnd()}\n[truncated]`,
98
+ "````"
99
+ ];
100
+ const sectionBytes = Buffer.byteLength(section.join("\n"), "utf8");
101
+ if (spent + sectionBytes > input.budgetBytes) {
102
+ omitted += 1;
103
+ continue;
104
+ }
105
+ lines.push(...section);
106
+ spent += sectionBytes;
107
+ }
108
+ if (omitted > 0) {
109
+ lines.push("", `[budget exhausted: ${omitted} file texts omitted]`);
110
+ return { body: lines.join("\n"), coverage: "partial" };
111
+ }
112
+ return { body: lines.join("\n") };
113
+ }
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import { hashContent } from "./changeset.js";
3
+ import { isSlug } from "./slug.js";
4
+ const shaPattern = /^[0-9a-f]{40}$/u;
5
+ const datePattern = /^\d{4}-\d{2}-\d{2}$/u;
6
+ const codeEvidenceSchema = z
7
+ .object({
8
+ captured: z.string().regex(datePattern),
9
+ commit: z.string().regex(shaPattern),
10
+ coverage: z.literal("partial").optional(),
11
+ module: z.string().refine(isSlug).optional(),
12
+ paths: z.array(z.string().min(1)).optional(),
13
+ project: z.string().refine(isSlug),
14
+ repo: z.string().min(1)
15
+ })
16
+ .superRefine((value, context) => {
17
+ if ((value.module === undefined) !== (value.paths === undefined)) {
18
+ context.addIssue({
19
+ code: z.ZodIssueCode.custom,
20
+ message: "module and paths must both be present (digest) or both absent (skeleton)"
21
+ });
22
+ }
23
+ });
24
+ const codePageSchema = z.object({
25
+ coverage: z.literal("partial").optional(),
26
+ generated: z.string().regex(datePattern),
27
+ module: z.string().refine(isSlug).optional(),
28
+ project: z.string().refine(isSlug),
29
+ source_commit: z.string().regex(shaPattern),
30
+ type: z.literal("code-map")
31
+ });
32
+ export const GENERATED_PAGE_COMMENT = "<!-- Generated by second-brain map — manual edits will be overwritten on the next render -->";
33
+ export function parseCodeEvidence(content) {
34
+ const { body, fields } = splitFrontmatter(content);
35
+ return {
36
+ body,
37
+ frontmatter: codeEvidenceSchema.parse({
38
+ ...fields,
39
+ ...(fields.paths === undefined ? {} : { paths: parseJsonArray(fields.paths) })
40
+ })
41
+ };
42
+ }
43
+ export function parseCodePage(content) {
44
+ const { body, fields } = splitFrontmatter(content);
45
+ return { body, frontmatter: codePageSchema.parse(fields) };
46
+ }
47
+ // Self-healing reads (design 6.3): a page that does not parse is simply not
48
+ // ours / not healthy — the caller decides between re-render and hands-off.
49
+ export function tryParseCodePage(content) {
50
+ try {
51
+ return parseCodePage(content);
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ // The hash covers ONLY the body below the fence: provenance churn in
58
+ // commit/captured must never re-trigger renders (design 6.2).
59
+ export function hashEvidenceBody(content) {
60
+ return hashContent(splitFrontmatter(content).body);
61
+ }
62
+ export function renderCodeEvidence(frontmatter, body) {
63
+ const lines = [
64
+ "---",
65
+ `project: ${frontmatter.project}`,
66
+ `repo: ${JSON.stringify(frontmatter.repo)}`,
67
+ `commit: ${frontmatter.commit}`,
68
+ `captured: ${frontmatter.captured}`
69
+ ];
70
+ if (frontmatter.module !== undefined) {
71
+ lines.push(`module: ${frontmatter.module}`);
72
+ }
73
+ if (frontmatter.paths !== undefined) {
74
+ lines.push(`paths: ${JSON.stringify(frontmatter.paths)}`);
75
+ }
76
+ if (frontmatter.coverage !== undefined) {
77
+ lines.push(`coverage: ${frontmatter.coverage}`);
78
+ }
79
+ lines.push("---", "", body.trim(), "");
80
+ return lines.join("\n");
81
+ }
82
+ export function renderCodePage(frontmatter, body) {
83
+ const lines = ["---", `project: ${frontmatter.project}`, "type: code-map"];
84
+ if (frontmatter.module !== undefined) {
85
+ lines.push(`module: ${frontmatter.module}`);
86
+ }
87
+ lines.push(`source_commit: ${frontmatter.source_commit}`, `generated: ${frontmatter.generated}`);
88
+ if (frontmatter.coverage !== undefined) {
89
+ lines.push(`coverage: ${frontmatter.coverage}`);
90
+ }
91
+ lines.push("---", "", GENERATED_PAGE_COMMENT, "", body.trim(), "");
92
+ return lines.join("\n");
93
+ }
94
+ // Same dialect as artifact-frontmatter.ts: flat "key: value" lines, BOM and
95
+ // CRLF tolerant, duplicate keys rejected. Values that need structure (paths,
96
+ // repo) are JSON — valid YAML, trivially parseable.
97
+ function splitFrontmatter(content) {
98
+ const lines = content.replace(/^/u, "").split(/\r?\n/u);
99
+ if (lines[0] !== "---") {
100
+ throw new Error("Missing opening frontmatter fence");
101
+ }
102
+ const closingIndex = lines.indexOf("---", 1);
103
+ if (closingIndex === -1) {
104
+ throw new Error("Missing closing frontmatter fence");
105
+ }
106
+ const fields = {};
107
+ for (const line of lines.slice(1, closingIndex)) {
108
+ if (line.trim().length === 0) {
109
+ continue;
110
+ }
111
+ const separatorIndex = line.indexOf(":");
112
+ if (separatorIndex === -1) {
113
+ throw new Error(`Malformed frontmatter line: ${line}`);
114
+ }
115
+ const key = line.slice(0, separatorIndex).trim();
116
+ if (Object.hasOwn(fields, key)) {
117
+ throw new Error(`Duplicate frontmatter key: ${key}`);
118
+ }
119
+ fields[key] = unquote(line.slice(separatorIndex + 1).trim());
120
+ }
121
+ return {
122
+ body: lines.slice(closingIndex + 1).join("\n").replace(/^\n+/u, "").replace(/\n+$/u, ""),
123
+ fields
124
+ };
125
+ }
126
+ function unquote(value) {
127
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
128
+ try {
129
+ const parsed = JSON.parse(value);
130
+ if (typeof parsed === "string") {
131
+ return parsed;
132
+ }
133
+ }
134
+ catch {
135
+ // Not JSON after all — keep the raw value.
136
+ }
137
+ }
138
+ return value;
139
+ }
140
+ function parseJsonArray(value) {
141
+ try {
142
+ return JSON.parse(value);
143
+ }
144
+ catch {
145
+ throw new Error(`paths is not a JSON array: ${value.slice(0, 80)}`);
146
+ }
147
+ }