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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
export function renderClaudeJsonlToMarkdown(input) {
|
|
3
|
+
const events = parseEvents(input.sourceContent);
|
|
4
|
+
const title = extractTitle(events, input.relativePath);
|
|
5
|
+
const sessionId = extractSessionId(events);
|
|
6
|
+
const cwd = extractCwd(events);
|
|
7
|
+
const sessionStartedAt = extractSessionStartedAt(events);
|
|
8
|
+
const turns = extractConversationTurns(events);
|
|
9
|
+
const header = buildHeader({ cwd, generatedAt: input.generatedAt, relativePath: input.relativePath, sessionId, title });
|
|
10
|
+
const body = turns.length > 0 ? buildConversation(turns) : "_No conversation turns found._";
|
|
11
|
+
return {
|
|
12
|
+
markdown: [header, "", "## Conversation", "", body].join("\n"),
|
|
13
|
+
metadata: { cwd, sessionId, sessionStartedAt }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function parseEvents(sourceContent) {
|
|
17
|
+
return sourceContent
|
|
18
|
+
.split("\n")
|
|
19
|
+
.flatMap((line) => {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
if (trimmed.length === 0) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(trimmed);
|
|
26
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
27
|
+
return [value];
|
|
28
|
+
}
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function extractTitle(events, relativePath) {
|
|
37
|
+
const titleEvent = events.find((e) => e.type === "ai-title");
|
|
38
|
+
if (titleEvent && typeof titleEvent.aiTitle === "string" && titleEvent.aiTitle.trim().length > 0) {
|
|
39
|
+
// The title is model-generated free text: collapse whitespace so an
|
|
40
|
+
// embedded newline cannot break the H1 and inject fake header lines.
|
|
41
|
+
return titleEvent.aiTitle.replaceAll(/\s+/gu, " ").trim();
|
|
42
|
+
}
|
|
43
|
+
return basename(relativePath).replace(/\.jsonl$/u, "");
|
|
44
|
+
}
|
|
45
|
+
function extractSessionId(events) {
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (typeof event.sessionId === "string") {
|
|
48
|
+
return event.sessionId;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
function extractSessionStartedAt(events) {
|
|
54
|
+
for (const event of events) {
|
|
55
|
+
if (typeof event.timestamp === "string") {
|
|
56
|
+
return event.timestamp;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
function extractCwd(events) {
|
|
62
|
+
for (const event of events) {
|
|
63
|
+
if (event.type === "user" && typeof event.cwd === "string") {
|
|
64
|
+
return event.cwd;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
function extractConversationTurns(events) {
|
|
70
|
+
const turns = [];
|
|
71
|
+
for (const event of events) {
|
|
72
|
+
const type = event.type;
|
|
73
|
+
if (type !== "user" && type !== "assistant") {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const msg = event.message;
|
|
77
|
+
if (typeof msg !== "object" || msg === null || Array.isArray(msg)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const message = msg;
|
|
81
|
+
const role = message.role;
|
|
82
|
+
if (role !== "user" && role !== "assistant") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const timestamp = typeof event.timestamp === "string" ? event.timestamp : "unknown";
|
|
86
|
+
const content = message.content;
|
|
87
|
+
const blocks = extractContentBlocks(content);
|
|
88
|
+
if (blocks.length > 0) {
|
|
89
|
+
turns.push({ blocks, cwd: undefined, role, timestamp });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return turns;
|
|
93
|
+
}
|
|
94
|
+
function extractContentBlocks(content) {
|
|
95
|
+
if (typeof content === "string") {
|
|
96
|
+
return [{ text: content, type: "text" }];
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(content)) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
return content.flatMap((item) => {
|
|
102
|
+
if (typeof item !== "object" || item === null) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const block = item;
|
|
106
|
+
const blockType = typeof block.type === "string" ? block.type : "unknown";
|
|
107
|
+
if (blockType === "thinking") {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
if (blockType === "text" && typeof block.text === "string") {
|
|
111
|
+
return [{ text: block.text, type: "text" }];
|
|
112
|
+
}
|
|
113
|
+
if (blockType === "tool_use") {
|
|
114
|
+
return [{
|
|
115
|
+
input: block.input,
|
|
116
|
+
name: typeof block.name === "string" ? block.name : "unknown",
|
|
117
|
+
type: "tool_use"
|
|
118
|
+
}];
|
|
119
|
+
}
|
|
120
|
+
if (blockType === "tool_result") {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
return [];
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function buildHeader(input) {
|
|
127
|
+
const lines = [`# ${input.title}`, ""];
|
|
128
|
+
lines.push(`- source: ${input.relativePath}`);
|
|
129
|
+
if (input.sessionId) {
|
|
130
|
+
lines.push(`- session_id: ${input.sessionId}`);
|
|
131
|
+
}
|
|
132
|
+
if (input.cwd) {
|
|
133
|
+
lines.push(`- cwd: ${input.cwd}`);
|
|
134
|
+
}
|
|
135
|
+
lines.push(`- generated_at: ${input.generatedAt}`);
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
function buildConversation(turns) {
|
|
139
|
+
return turns
|
|
140
|
+
.map((turn) => renderTurn(turn))
|
|
141
|
+
.join("\n\n---\n\n");
|
|
142
|
+
}
|
|
143
|
+
function renderTurn(turn) {
|
|
144
|
+
const label = turn.role === "user" ? "**User**" : "**Assistant**";
|
|
145
|
+
const heading = `${label} · ${turn.timestamp}`;
|
|
146
|
+
const body = turn.blocks.map((block) => renderBlock(block)).join("\n\n");
|
|
147
|
+
return [heading, "", body].join("\n");
|
|
148
|
+
}
|
|
149
|
+
function renderBlock(block) {
|
|
150
|
+
if (block.type === "text" && block.text !== undefined) {
|
|
151
|
+
return block.text;
|
|
152
|
+
}
|
|
153
|
+
if (block.type === "tool_use") {
|
|
154
|
+
const name = block.name ?? "unknown";
|
|
155
|
+
const inputSummary = summarizeInput(block.input);
|
|
156
|
+
return `> \`${name}\` → ${inputSummary}`;
|
|
157
|
+
}
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
function summarizeInput(input) {
|
|
161
|
+
if (input === null || input === undefined) {
|
|
162
|
+
return "(no input)";
|
|
163
|
+
}
|
|
164
|
+
if (typeof input === "string") {
|
|
165
|
+
return input.length > 120 ? `${input.slice(0, 120)}…` : input;
|
|
166
|
+
}
|
|
167
|
+
if (typeof input === "object" && !Array.isArray(input)) {
|
|
168
|
+
const entries = Object.entries(input);
|
|
169
|
+
if (entries.length === 1) {
|
|
170
|
+
const [key, value] = entries[0];
|
|
171
|
+
const valueStr = typeof value === "string" ? value : JSON.stringify(value);
|
|
172
|
+
const truncated = valueStr.length > 100 ? `${valueStr.slice(0, 100)}…` : valueStr;
|
|
173
|
+
return `${key}: ${truncated}`;
|
|
174
|
+
}
|
|
175
|
+
const compact = JSON.stringify(input);
|
|
176
|
+
return compact.length > 150 ? `${compact.slice(0, 150)}…` : compact;
|
|
177
|
+
}
|
|
178
|
+
return String(input);
|
|
179
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
export function renderJsonlToMarkdown(input) {
|
|
3
|
+
const lines = input.sourceContent
|
|
4
|
+
// A UTF-8 BOM is not JSON whitespace: without this strip the first event
|
|
5
|
+
// would render as malformed AND silently lose its session metadata.
|
|
6
|
+
.replace(/^\uFEFF/u, "")
|
|
7
|
+
.split("\n")
|
|
8
|
+
.map((line, index) => ({ line, lineNumber: index + 1 }))
|
|
9
|
+
.filter(({ line }) => line.trim().length > 0);
|
|
10
|
+
const events = lines.flatMap(({ line }) => {
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(line);
|
|
13
|
+
return isJsonObject(value) ? [value] : [];
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const sections = lines
|
|
20
|
+
.map(({ line, lineNumber }) => renderLine(line, lineNumber))
|
|
21
|
+
.join("\n\n");
|
|
22
|
+
const title = basename(input.relativePath).replace(/\.jsonl$/u, "") || "session";
|
|
23
|
+
const markdown = [
|
|
24
|
+
`# ${title}`,
|
|
25
|
+
"",
|
|
26
|
+
`- provider: ${input.providerName}`,
|
|
27
|
+
`- source: ${input.relativePath}`,
|
|
28
|
+
`- generated_at: ${input.generatedAt}`,
|
|
29
|
+
`- source_type: jsonl`,
|
|
30
|
+
"",
|
|
31
|
+
sections
|
|
32
|
+
].join("\n");
|
|
33
|
+
return { markdown, metadata: extractMetadata(events) };
|
|
34
|
+
}
|
|
35
|
+
function extractMetadata(events) {
|
|
36
|
+
const findString = (pick) => {
|
|
37
|
+
for (const event of events) {
|
|
38
|
+
const value = pick(event);
|
|
39
|
+
if (typeof value === "string") {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
cwd: findString((event) => event.cwd),
|
|
47
|
+
sessionId: findString((event) => event.sessionId ?? event.session_id),
|
|
48
|
+
sessionStartedAt: findString((event) => event.timestamp)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function renderLine(line, lineNumber) {
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(line);
|
|
54
|
+
if (!isJsonObject(value)) {
|
|
55
|
+
return renderMalformedLine(line, lineNumber);
|
|
56
|
+
}
|
|
57
|
+
const timestamp = typeof value.timestamp === "string" ? value.timestamp : "unknown";
|
|
58
|
+
const type = typeof value.type === "string" ? value.type : "unknown";
|
|
59
|
+
const text = extractText(value);
|
|
60
|
+
return [
|
|
61
|
+
`## Event ${lineNumber}: ${type}`,
|
|
62
|
+
"",
|
|
63
|
+
`- timestamp: ${timestamp}`,
|
|
64
|
+
"",
|
|
65
|
+
text ?? "_No direct text content_",
|
|
66
|
+
"",
|
|
67
|
+
"```json",
|
|
68
|
+
JSON.stringify(value, null, 2),
|
|
69
|
+
"```"
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return renderMalformedLine(line, lineNumber);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function extractText(value) {
|
|
77
|
+
if (typeof value.text === "string") {
|
|
78
|
+
return value.text;
|
|
79
|
+
}
|
|
80
|
+
const msg = value.message;
|
|
81
|
+
if (!isJsonObject(msg)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const content = msg.content;
|
|
85
|
+
if (typeof content === "string") {
|
|
86
|
+
return content;
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(content)) {
|
|
89
|
+
const texts = content
|
|
90
|
+
.filter((block) => isJsonObject(block) && typeof block.text === "string")
|
|
91
|
+
.map((block) => block.text);
|
|
92
|
+
return texts.length > 0 ? texts.join("\n\n") : undefined;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function renderMalformedLine(line, lineNumber) {
|
|
97
|
+
const fence = createMarkdownFence(line);
|
|
98
|
+
return [
|
|
99
|
+
`## Event ${lineNumber}: malformed`,
|
|
100
|
+
"",
|
|
101
|
+
`- error: Malformed JSONL line ${lineNumber}`,
|
|
102
|
+
"",
|
|
103
|
+
`${fence}text`,
|
|
104
|
+
line,
|
|
105
|
+
fence
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
function createMarkdownFence(content) {
|
|
109
|
+
const longestRun = content.match(/`+/gu)?.reduce((longest, run) => {
|
|
110
|
+
return Math.max(longest, run.length);
|
|
111
|
+
}, 0) ?? 0;
|
|
112
|
+
return "`".repeat(Math.max(3, longestRun + 1));
|
|
113
|
+
}
|
|
114
|
+
function isJsonObject(value) {
|
|
115
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
116
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { ensureDirectory, writeFileAtomic } from "./filesystem.js";
|
|
4
|
+
// Reports are regenerable noise, not an audit trail (vault git history covers
|
|
5
|
+
// that), so old ones are pruned instead of accumulating forever.
|
|
6
|
+
const REPORT_RETENTION_LIMIT = 50;
|
|
7
|
+
const REPORT_FILE_PATTERN = /^(\d{4}-\d{2}-\d{2}-\d{4}-(?:hourly|manual|weekly))(?:-(\d+))?\.md$/u;
|
|
8
|
+
// The rendered transcript opens with its "# title" line; scanning further
|
|
9
|
+
// would start matching headings inside quoted conversation content.
|
|
10
|
+
const TITLE_SCAN_LIMIT = 10;
|
|
11
|
+
export function createReportWriter(input) {
|
|
12
|
+
// A combined --ingest --consolidate run produces ONE report: each stage call
|
|
13
|
+
// rebuilds the full document from accumulated state and rewrites the same
|
|
14
|
+
// file, so no stage ever has to parse frontmatter it wrote earlier.
|
|
15
|
+
const state = {};
|
|
16
|
+
const write = async () => {
|
|
17
|
+
await ensureDirectory(input.reportsRoot);
|
|
18
|
+
const createdAt = state.createdAt ?? input.now();
|
|
19
|
+
state.createdAt = createdAt;
|
|
20
|
+
state.reportPath =
|
|
21
|
+
state.reportPath ??
|
|
22
|
+
(await claimReportPath(input.reportsRoot, createdAt, input.runKind));
|
|
23
|
+
const content = await buildReportContent({
|
|
24
|
+
consolidation: state.consolidation,
|
|
25
|
+
createdAt,
|
|
26
|
+
ingest: state.ingest,
|
|
27
|
+
runKind: input.runKind,
|
|
28
|
+
vaultRoot: input.vaultRoot
|
|
29
|
+
});
|
|
30
|
+
await writeFileAtomic(state.reportPath, content);
|
|
31
|
+
try {
|
|
32
|
+
await pruneReports(input.reportsRoot, state.reportPath);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Retention is janitorial: a failed delete of an old, disposable report
|
|
36
|
+
// must never abort the run — that would block the manifest advance and
|
|
37
|
+
// force paid re-synthesis of work that already succeeded.
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
path: state.reportPath,
|
|
41
|
+
wikiLink: `reports/${basename(state.reportPath).replace(/\.md$/u, "")}`
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
addConsolidation: async (data) => {
|
|
46
|
+
state.consolidation = data;
|
|
47
|
+
return write();
|
|
48
|
+
},
|
|
49
|
+
addIngest: async (data) => {
|
|
50
|
+
state.ingest = data;
|
|
51
|
+
return write();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
async function buildReportContent(input) {
|
|
56
|
+
const iso = input.createdAt.toISOString();
|
|
57
|
+
const lines = ["---", "type: report", `date: ${iso}`, `run: ${input.runKind}`];
|
|
58
|
+
if (input.ingest) {
|
|
59
|
+
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
|
+
}
|
|
61
|
+
if (input.consolidation) {
|
|
62
|
+
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
|
+
}
|
|
64
|
+
const stamp = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
65
|
+
lines.push("---", "", `# Sync run ${stamp} (${input.runKind})`);
|
|
66
|
+
if (input.ingest) {
|
|
67
|
+
lines.push("", ...(await buildIngestSection(input.ingest, input.vaultRoot)));
|
|
68
|
+
}
|
|
69
|
+
if (input.consolidation) {
|
|
70
|
+
lines.push("", ...buildConsolidationSection(input.consolidation));
|
|
71
|
+
}
|
|
72
|
+
return `${lines.join("\n")}\n`;
|
|
73
|
+
}
|
|
74
|
+
async function buildIngestSection(data, vaultRoot) {
|
|
75
|
+
const lines = ["## Ingest"];
|
|
76
|
+
const projects = [
|
|
77
|
+
...new Set([...data.transcriptsAdded, ...data.transcriptsChanged, ...data.artifacts, ...data.episodes].map((item) => item.project))
|
|
78
|
+
].sort();
|
|
79
|
+
for (const project of projects) {
|
|
80
|
+
lines.push("", `### ${project}`);
|
|
81
|
+
const added = data.transcriptsAdded.filter((item) => item.project === project);
|
|
82
|
+
const changed = data.transcriptsChanged.filter((item) => item.project === project);
|
|
83
|
+
const artifacts = data.artifacts.filter((item) => item.project === project);
|
|
84
|
+
const episodes = data.episodes.filter((item) => item.project === project);
|
|
85
|
+
if (added.length > 0) {
|
|
86
|
+
lines.push("", "New transcripts:");
|
|
87
|
+
for (const transcript of added) {
|
|
88
|
+
lines.push(`- ${await transcriptLine(transcript, vaultRoot)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (changed.length > 0) {
|
|
92
|
+
lines.push("", "Changed transcripts:");
|
|
93
|
+
for (const transcript of changed) {
|
|
94
|
+
lines.push(`- ${await transcriptLine(transcript, vaultRoot)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (artifacts.length > 0) {
|
|
98
|
+
lines.push("", "Artifacts ingested:");
|
|
99
|
+
for (const artifact of artifacts) {
|
|
100
|
+
lines.push(`- ${artifact.type} — ${vaultLink(artifact.path, vaultRoot, basename(artifact.path))}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (episodes.length > 0) {
|
|
104
|
+
lines.push("", "Episodes created:");
|
|
105
|
+
for (const episode of episodes) {
|
|
106
|
+
lines.push(`- [[episodes/${basename(episode.path).replace(/\.md$/u, "")}]]`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (data.failures.length > 0) {
|
|
111
|
+
lines.push("", "### Failures", "");
|
|
112
|
+
for (const failure of data.failures) {
|
|
113
|
+
lines.push(`- ${failure.project} — ${failure.reason}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (data.quarantined.length > 0) {
|
|
117
|
+
lines.push("", "### Quarantined artifacts", "");
|
|
118
|
+
for (const artifact of data.quarantined) {
|
|
119
|
+
lines.push(`- \`${artifact.path}\` — ${artifact.reason}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return lines;
|
|
123
|
+
}
|
|
124
|
+
function buildConsolidationSection(data) {
|
|
125
|
+
const lines = ["## Consolidation", ""];
|
|
126
|
+
if (data.pagesCreated.length > 0) {
|
|
127
|
+
lines.push(`Pages created: ${data.pagesCreated.map((page) => `[[${page}]]`).join(", ")}`);
|
|
128
|
+
}
|
|
129
|
+
if (data.pagesUpdated.length > 0) {
|
|
130
|
+
lines.push(`Pages updated: ${data.pagesUpdated.map((page) => `[[${page}]]`).join(", ")}`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(`Episodes consolidated: ${data.episodesConsolidated}`);
|
|
133
|
+
if (data.skippedProjects.length > 0) {
|
|
134
|
+
lines.push(`Projects skipped by the backend: ${data.skippedProjects.join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
if (data.failures.length > 0) {
|
|
137
|
+
lines.push("", "### Failures", "");
|
|
138
|
+
for (const failure of data.failures) {
|
|
139
|
+
lines.push(`- ${failure.project} — ${failure.reason}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (data.quarantined.length > 0) {
|
|
143
|
+
lines.push("", "### Quarantined episodes", "");
|
|
144
|
+
for (const episode of data.quarantined) {
|
|
145
|
+
lines.push(`- \`${episode.path}\` — ${episode.reason}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
async function transcriptLine(transcript, vaultRoot) {
|
|
151
|
+
const title = await readTranscriptTitle(transcript.path);
|
|
152
|
+
return `${vaultLink(transcript.path, vaultRoot, title)} (${transcript.provider})`;
|
|
153
|
+
}
|
|
154
|
+
async function readTranscriptTitle(path) {
|
|
155
|
+
const fallback = basename(path).replace(/\.md$/u, "");
|
|
156
|
+
let content;
|
|
157
|
+
try {
|
|
158
|
+
content = await readFile(path, "utf8");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// A transcript pruned between sync and report must degrade to the
|
|
162
|
+
// filename, not fail the whole run over a cosmetic title.
|
|
163
|
+
return fallback;
|
|
164
|
+
}
|
|
165
|
+
for (const line of content.split("\n", TITLE_SCAN_LIMIT)) {
|
|
166
|
+
const match = /^# (.+)$/u.exec(line);
|
|
167
|
+
if (match) {
|
|
168
|
+
const title = match[1].trim();
|
|
169
|
+
return title.length > 0 ? title : fallback;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return fallback;
|
|
173
|
+
}
|
|
174
|
+
function vaultLink(path, vaultRoot, alias) {
|
|
175
|
+
const relativePath = relative(vaultRoot, path);
|
|
176
|
+
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
177
|
+
// Outside the vault there is nothing for Obsidian to resolve.
|
|
178
|
+
return `\`${path}\``;
|
|
179
|
+
}
|
|
180
|
+
const target = relativePath.replace(/\.md$/u, "");
|
|
181
|
+
const safeAlias = alias.replaceAll(/[[\]|]/gu, " ").replaceAll(/\s+/gu, " ").trim();
|
|
182
|
+
return safeAlias.length > 0 ? `[[${target}|${safeAlias}]]` : `[[${target}]]`;
|
|
183
|
+
}
|
|
184
|
+
async function claimReportPath(reportsRoot, createdAt, runKind) {
|
|
185
|
+
const iso = createdAt.toISOString();
|
|
186
|
+
const stamp = `${iso.slice(0, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}`;
|
|
187
|
+
const baseName = `${stamp}-${runKind}`;
|
|
188
|
+
for (let suffix = 1; suffix <= 50; suffix += 1) {
|
|
189
|
+
const name = suffix === 1 ? `${baseName}.md` : `${baseName}-${suffix}.md`;
|
|
190
|
+
const candidate = resolve(reportsRoot, name);
|
|
191
|
+
if (!(await pathExists(candidate))) {
|
|
192
|
+
return candidate;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`Could not find a free report filename for ${baseName}`);
|
|
196
|
+
}
|
|
197
|
+
async function pruneReports(reportsRoot, keepPath) {
|
|
198
|
+
const entries = await readdir(reportsRoot);
|
|
199
|
+
const stale = entries
|
|
200
|
+
.filter((name) => REPORT_FILE_PATTERN.test(name))
|
|
201
|
+
.sort(compareReportNames)
|
|
202
|
+
.reverse()
|
|
203
|
+
.slice(REPORT_RETENTION_LIMIT);
|
|
204
|
+
for (const name of stale) {
|
|
205
|
+
const target = resolve(reportsRoot, name);
|
|
206
|
+
if (target === keepPath) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
await rm(target, { force: true });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function compareReportNames(a, b) {
|
|
213
|
+
const matchA = REPORT_FILE_PATTERN.exec(a);
|
|
214
|
+
const matchB = REPORT_FILE_PATTERN.exec(b);
|
|
215
|
+
// Callers filter on the pattern first; this guard only keeps exec honest.
|
|
216
|
+
if (!matchA || !matchB) {
|
|
217
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
218
|
+
}
|
|
219
|
+
// The stamp+kind prefix sorts lexicographically, but the same-minute
|
|
220
|
+
// collision suffix is an unpadded integer and must compare numerically
|
|
221
|
+
// ("-10" is newer than "-2", and no suffix means the first write).
|
|
222
|
+
if (matchA[1] !== matchB[1]) {
|
|
223
|
+
return matchA[1] < matchB[1] ? -1 : 1;
|
|
224
|
+
}
|
|
225
|
+
return Number(matchA[2] ?? "1") - Number(matchB[2] ?? "1");
|
|
226
|
+
}
|
|
227
|
+
async function pathExists(path) {
|
|
228
|
+
try {
|
|
229
|
+
await lstat(path);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (isMissingFileError(error)) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function isMissingFileError(error) {
|
|
240
|
+
return (error instanceof Error &&
|
|
241
|
+
"code" in error &&
|
|
242
|
+
typeof error.code === "string" &&
|
|
243
|
+
error.code === "ENOENT");
|
|
244
|
+
}
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export async function runCommand(command, args, cwd, env) {
|
|
5
|
+
try {
|
|
6
|
+
const result = await execFileAsync(command, [...args], {
|
|
7
|
+
cwd,
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
...(env === undefined ? {} : { env: { ...env } }),
|
|
10
|
+
maxBuffer: 10 * 1024 * 1024
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
stderr: result.stderr,
|
|
14
|
+
stdout: result.stdout
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
throw new Error(buildCommandError(command, args, error));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function runCommandAllowFailure(command, args, cwd) {
|
|
22
|
+
try {
|
|
23
|
+
const result = await execFileAsync(command, [...args], {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
maxBuffer: 10 * 1024 * 1024
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
exitCode: 0,
|
|
30
|
+
stderr: result.stderr,
|
|
31
|
+
stdout: result.stdout
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const normalizedError = error;
|
|
36
|
+
return {
|
|
37
|
+
exitCode: typeof normalizedError.code === "number" ? normalizedError.code : 1,
|
|
38
|
+
stderr: normalizedError.stderr ?? "",
|
|
39
|
+
stdout: normalizedError.stdout ?? ""
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Long prompts are passed as argv: echoing them whole made failure reasons
|
|
44
|
+
// unreadable, while the actual diagnostic (exit code, spawn error, stream
|
|
45
|
+
// tails) was missing entirely.
|
|
46
|
+
const ARGS_PREVIEW_LIMIT = 160;
|
|
47
|
+
const STREAM_TAIL_LIMIT = 400;
|
|
48
|
+
function buildCommandError(command, args, error) {
|
|
49
|
+
const normalizedError = error;
|
|
50
|
+
const lines = [`Command failed: ${command} ${previewArgs(args)}`.trimEnd()];
|
|
51
|
+
if (typeof normalizedError.code === "string") {
|
|
52
|
+
// Spawn-layer failures (ENOENT, EACCES) never start the child: the node
|
|
53
|
+
// error message is the only diagnostic there is.
|
|
54
|
+
lines.push(normalizedError.message);
|
|
55
|
+
}
|
|
56
|
+
else if (typeof normalizedError.code === "number") {
|
|
57
|
+
lines.push(`exit code ${normalizedError.code}`);
|
|
58
|
+
}
|
|
59
|
+
else if (typeof normalizedError.signal === "string") {
|
|
60
|
+
lines.push(`killed by signal ${normalizedError.signal}`);
|
|
61
|
+
}
|
|
62
|
+
const stderrTail = streamTail(normalizedError.stderr);
|
|
63
|
+
if (stderrTail !== "") {
|
|
64
|
+
lines.push(`stderr: ${stderrTail}`);
|
|
65
|
+
}
|
|
66
|
+
// claude -p reports usage limits and API errors on stdout, not stderr.
|
|
67
|
+
const stdoutTail = streamTail(normalizedError.stdout);
|
|
68
|
+
if (stdoutTail !== "") {
|
|
69
|
+
lines.push(`stdout: ${stdoutTail}`);
|
|
70
|
+
}
|
|
71
|
+
return lines.join("\n");
|
|
72
|
+
}
|
|
73
|
+
function previewArgs(args) {
|
|
74
|
+
const joined = args.join(" ");
|
|
75
|
+
return joined.length <= ARGS_PREVIEW_LIMIT
|
|
76
|
+
? joined
|
|
77
|
+
: `${joined.slice(0, ARGS_PREVIEW_LIMIT)}... (+${joined.length - ARGS_PREVIEW_LIMIT} chars)`;
|
|
78
|
+
}
|
|
79
|
+
function streamTail(stream) {
|
|
80
|
+
const trimmed = stream?.trim() ?? "";
|
|
81
|
+
return trimmed.length <= STREAM_TAIL_LIMIT
|
|
82
|
+
? trimmed
|
|
83
|
+
: `... ${trimmed.slice(-STREAM_TAIL_LIMIT)}`;
|
|
84
|
+
}
|
package/dist/slug.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const slugPattern = /^[a-z0-9][a-z0-9-]*$/u;
|
|
2
|
+
export function isSlug(value) {
|
|
3
|
+
return slugPattern.test(value);
|
|
4
|
+
}
|
|
5
|
+
export function isProjectSegment(value) {
|
|
6
|
+
// _inbox is the reserved capture fallback and the only non-slug segment.
|
|
7
|
+
return value === "_inbox" || isSlug(value);
|
|
8
|
+
}
|
|
9
|
+
export function slugify(value) {
|
|
10
|
+
return value
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[\s_]+/gu, "-")
|
|
13
|
+
.replace(/[^a-z0-9-]/gu, "")
|
|
14
|
+
.replace(/-{2,}/gu, "-")
|
|
15
|
+
.replace(/^-+|-+$/gu, "");
|
|
16
|
+
}
|