@kal-elsam/kairo-runtime 0.12.0 → 0.13.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/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +2 -1
- package/src/global/ink/cockpit-control-center.js +4 -1
- package/src/global/ink/obsidian-vault-display.js +37 -0
- package/src/global/observability/build-companion-snapshot.js +23 -2
- package/src/global/observability/index.js +39 -0
- package/src/global/observability/obsidian-knowledge-preview.js +214 -0
- package/src/global/observability/obsidian-knowledge-views.js +227 -0
- package/src/global/observability/obsidian-publisher.js +181 -0
- package/src/global/observability/obsidian-status.js +76 -0
- package/src/global/observability/obsidian-vault.js +259 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Kal-elSam/harness#readme",
|
|
@@ -26,7 +26,8 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
26
26
|
const require = createRequire(import.meta.url);
|
|
27
27
|
const pkg = require(join(root, "package.json"));
|
|
28
28
|
|
|
29
|
-
assert.
|
|
29
|
+
assert.match(pkg.version, /^\d+\.\d+\.\d+$/);
|
|
30
|
+
assert.equal(pkg.name, "@kal-elsam/kairo-runtime");
|
|
30
31
|
assert.ok(pkg.dependencies["ansi-escapes"]);
|
|
31
32
|
|
|
32
33
|
assert.equal(resolveLayoutMode({ columns: 120, rows: 40 }), LAYOUT_MODES.WIDE);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
formatResourceAdviceLines
|
|
12
12
|
} from "./system-resources-display.js";
|
|
13
13
|
import { formatEcosystemUpdateLines } from "./ecosystem-updates-display.js";
|
|
14
|
+
import { formatObsidianVaultLines } from "./obsidian-vault-display.js";
|
|
14
15
|
|
|
15
16
|
const HERMES_WIDE_SESSION_LIMIT = 3;
|
|
16
17
|
const HERMES_TITLE_MAX = 48;
|
|
@@ -21,6 +22,7 @@ export {
|
|
|
21
22
|
formatResourceAdviceLines
|
|
22
23
|
} from "./system-resources-display.js";
|
|
23
24
|
export { formatEcosystemUpdateLines } from "./ecosystem-updates-display.js";
|
|
25
|
+
export { formatObsidianVaultLines } from "./obsidian-vault-display.js";
|
|
24
26
|
|
|
25
27
|
export function buildControlCenterModel({
|
|
26
28
|
projectName = "project",
|
|
@@ -192,7 +194,8 @@ function formatCompanionOverlay(companion, layoutMode = LAYOUT_MODES.COMPACT) {
|
|
|
192
194
|
...formatHermesActivityLines(companion.signals?.hermes?.activity, layoutMode),
|
|
193
195
|
...formatSystemResourcesLines(companion.signals?.system?.resources, layoutMode),
|
|
194
196
|
...formatResourceAdviceLines(companion.signals?.system?.advice, layoutMode),
|
|
195
|
-
...formatEcosystemUpdateLines(companion.signals?.ecosystem?.updates, layoutMode)
|
|
197
|
+
...formatEcosystemUpdateLines(companion.signals?.ecosystem?.updates, layoutMode),
|
|
198
|
+
...formatObsidianVaultLines(companion.signals?.obsidian?.vault, layoutMode)
|
|
196
199
|
],
|
|
197
200
|
links: companion.links ?? [],
|
|
198
201
|
error: companion.error ?? null
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { LAYOUT_MODES } from "./layout.js";
|
|
2
|
+
|
|
3
|
+
/** Display-only Obsidian vault lines — never shows paths, write CTAs, or sync controls. */
|
|
4
|
+
export function formatObsidianVaultLines(status, layoutMode = LAYOUT_MODES.COMPACT) {
|
|
5
|
+
if (status == null || typeof status !== "object") return ["Obsidian · unavailable"];
|
|
6
|
+
const state = typeof status.state === "string" && status.state ? status.state : "error";
|
|
7
|
+
const notes = typeof status.noteCount === "number" && Number.isFinite(status.noteCount)
|
|
8
|
+
? status.noteCount
|
|
9
|
+
: 0;
|
|
10
|
+
const pending = typeof status.pendingProposals === "number" && status.pendingProposals > 0
|
|
11
|
+
? status.pendingProposals
|
|
12
|
+
: 0;
|
|
13
|
+
|
|
14
|
+
if (state === "unconfigured") return ["Obsidian · unconfigured"];
|
|
15
|
+
if (state === "missing") return ["Obsidian · missing"];
|
|
16
|
+
if (state !== "available" && state !== "partial") {
|
|
17
|
+
return [`Obsidian · ${state}`];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const head = state === "partial"
|
|
21
|
+
? `Obsidian · partial · ${notes} notes`
|
|
22
|
+
: `Obsidian · ${notes} notes`;
|
|
23
|
+
const lines = [head];
|
|
24
|
+
if (layoutMode === LAYOUT_MODES.MINIMAL) return lines;
|
|
25
|
+
|
|
26
|
+
if (pending > 0) lines.push(` · pending · ${pending}`);
|
|
27
|
+
else if (layoutMode === LAYOUT_MODES.COMPACT) lines.push(" · no auto-sync");
|
|
28
|
+
|
|
29
|
+
if (layoutMode === LAYOUT_MODES.WIDE) {
|
|
30
|
+
const pub = typeof status.lastPublishAt === "string" && status.lastPublishAt
|
|
31
|
+
? status.lastPublishAt.slice(0, 19)
|
|
32
|
+
: "never";
|
|
33
|
+
lines.push(` · last publish · ${pub}`);
|
|
34
|
+
if (pending === 0) lines.push(" · no auto-sync");
|
|
35
|
+
}
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
@@ -7,6 +7,11 @@ import { loadHermesActivity as defaultHermesActivity } from "./hermes-activity.j
|
|
|
7
7
|
import { loadSystemResources as defaultSystemResources } from "./system-resources.js";
|
|
8
8
|
import { recommendSystemResources } from "./resource-advisor.js";
|
|
9
9
|
import { loadEcosystemUpdates as defaultEcosystemUpdates } from "./ecosystem-updates.js";
|
|
10
|
+
import {
|
|
11
|
+
emptyObsidianVaultStatus,
|
|
12
|
+
loadObsidianVaultStatus as defaultObsidianVault,
|
|
13
|
+
summarizeObsidianVaultStatus
|
|
14
|
+
} from "./obsidian-status.js";
|
|
10
15
|
import { getObservabilityProbe, registerObservabilityProbe } from "./probe-registry.js";
|
|
11
16
|
|
|
12
17
|
export const SOFT_LINK_WINDOW_MS = 60 * 60 * 1000;
|
|
@@ -179,7 +184,8 @@ function emptyCompanion(error = null) {
|
|
|
179
184
|
graphify: { state: "error", error: null, diagnostics: [], graphStatus: null },
|
|
180
185
|
hermes: { activity: emptyHermesActivity() },
|
|
181
186
|
system: { resources: emptySystemResources(), advice: { recommendations: [], deepScan: false } },
|
|
182
|
-
ecosystem: { updates: emptyEcosystemUpdates() }
|
|
187
|
+
ecosystem: { updates: emptyEcosystemUpdates() },
|
|
188
|
+
obsidian: { vault: emptyObsidianVaultStatus() }
|
|
183
189
|
},
|
|
184
190
|
engram: { status: "error", binary: null },
|
|
185
191
|
links: [], alertsCount: null,
|
|
@@ -199,6 +205,7 @@ export async function buildCompanionSnapshot({
|
|
|
199
205
|
loadHermesActivity = defaultHermesActivity,
|
|
200
206
|
loadSystemResources = defaultSystemResources,
|
|
201
207
|
loadEcosystemUpdates = defaultEcosystemUpdates,
|
|
208
|
+
loadObsidianVaultStatus = defaultObsidianVault,
|
|
202
209
|
resourceDeepScan = false,
|
|
203
210
|
observabilityContext = {}
|
|
204
211
|
} = {}) {
|
|
@@ -249,6 +256,19 @@ export async function buildCompanionSnapshot({
|
|
|
249
256
|
ecosystemUpdates = emptyEcosystemUpdates();
|
|
250
257
|
}
|
|
251
258
|
|
|
259
|
+
let obsidianVault = emptyObsidianVaultStatus();
|
|
260
|
+
try {
|
|
261
|
+
obsidianVault = summarizeObsidianVaultStatus(
|
|
262
|
+
await loadObsidianVaultStatus({
|
|
263
|
+
vaultPath: observabilityContext?.obsidianVaultPath ?? observabilityContext?.vaultPath ?? null,
|
|
264
|
+
lastPublishAt: observabilityContext?.obsidianLastPublishAt ?? null,
|
|
265
|
+
pendingProposals: observabilityContext?.obsidianPendingProposals ?? 0
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
} catch {
|
|
269
|
+
obsidianVault = emptyObsidianVaultStatus();
|
|
270
|
+
}
|
|
271
|
+
|
|
252
272
|
const reviewList = Array.isArray(reviews)
|
|
253
273
|
? reviews
|
|
254
274
|
: (typeof loadReviews === "function" ? await loadReviews() : []);
|
|
@@ -259,7 +279,8 @@ export async function buildCompanionSnapshot({
|
|
|
259
279
|
...summarizeCompanionProbes(obs?.probes ?? []),
|
|
260
280
|
hermes: { activity: hermesActivity },
|
|
261
281
|
system: { resources: systemResources, advice: systemAdvice },
|
|
262
|
-
ecosystem: { updates: ecosystemUpdates }
|
|
282
|
+
ecosystem: { updates: ecosystemUpdates },
|
|
283
|
+
obsidian: { vault: obsidianVault }
|
|
263
284
|
};
|
|
264
285
|
const links = [];
|
|
265
286
|
for (const review of reviewList ?? []) {
|
|
@@ -68,6 +68,45 @@ export {
|
|
|
68
68
|
parseHermesUpdateCheck,
|
|
69
69
|
loadEcosystemUpdates
|
|
70
70
|
} from "./ecosystem-updates.js";
|
|
71
|
+
export {
|
|
72
|
+
KAIRO_VAULT_SUBDIR,
|
|
73
|
+
EXCLUDED_DIR_NAMES,
|
|
74
|
+
normalizeVaultPath,
|
|
75
|
+
isExcludedDirName,
|
|
76
|
+
isSecretBasename,
|
|
77
|
+
isAllowedKairoNoteName,
|
|
78
|
+
assertInsideKairoRoot,
|
|
79
|
+
resolveKairoNotePath,
|
|
80
|
+
inspectObsidianVault
|
|
81
|
+
} from "./obsidian-vault.js";
|
|
82
|
+
export {
|
|
83
|
+
formatKnowledgeFrontmatter,
|
|
84
|
+
renderDecisionMarkdown,
|
|
85
|
+
renderArchitectureMarkdown,
|
|
86
|
+
buildObsidianKnowledgePreview,
|
|
87
|
+
loadObsidianKnowledgePreview
|
|
88
|
+
} from "./obsidian-knowledge-preview.js";
|
|
89
|
+
export {
|
|
90
|
+
KAIRO_MANAGED_FRONTMATTER,
|
|
91
|
+
BACKUP_DIR_NAME,
|
|
92
|
+
hasConsent,
|
|
93
|
+
classifyNoteWrite,
|
|
94
|
+
planObsidianPublish,
|
|
95
|
+
publishObsidianProposals
|
|
96
|
+
} from "./obsidian-publisher.js";
|
|
97
|
+
export {
|
|
98
|
+
KAIRO_VIEW_KINDS,
|
|
99
|
+
parseKnowledgeFrontmatter,
|
|
100
|
+
extractWikilinks,
|
|
101
|
+
buildObsidianKnowledgeViews,
|
|
102
|
+
buildKnowledgeIndexProposals,
|
|
103
|
+
loadObsidianKnowledgeViews
|
|
104
|
+
} from "./obsidian-knowledge-views.js";
|
|
105
|
+
export {
|
|
106
|
+
emptyObsidianVaultStatus,
|
|
107
|
+
summarizeObsidianVaultStatus,
|
|
108
|
+
loadObsidianVaultStatus
|
|
109
|
+
} from "./obsidian-status.js";
|
|
71
110
|
export {
|
|
72
111
|
SOFT_LINK_WINDOW_MS,
|
|
73
112
|
parseCompanionTimestamp,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { resolveKairoNotePath } from "./obsidian-vault.js";
|
|
2
|
+
|
|
3
|
+
const MAX_PROPOSALS = 40;
|
|
4
|
+
const TITLE_MAX = 80;
|
|
5
|
+
|
|
6
|
+
function envelope(partial = {}) {
|
|
7
|
+
return {
|
|
8
|
+
state: "error",
|
|
9
|
+
proposals: [],
|
|
10
|
+
diagnostics: [],
|
|
11
|
+
error: null,
|
|
12
|
+
generatedAt: null,
|
|
13
|
+
...partial
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function scrubTitle(raw, fallback = "untitled") {
|
|
18
|
+
const text = String(raw ?? "")
|
|
19
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
20
|
+
.replace(/[\[\]#|\\/]+/g, " ")
|
|
21
|
+
.trim()
|
|
22
|
+
.slice(0, TITLE_MAX);
|
|
23
|
+
return text || fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function slugify(title) {
|
|
27
|
+
return scrubTitle(title, "note")
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "")
|
|
31
|
+
.slice(0, 48) || "note";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Stable YAML-ish frontmatter — no nested objects; values are scalars only. */
|
|
35
|
+
export function formatKnowledgeFrontmatter(fields = {}) {
|
|
36
|
+
const lines = ["---"];
|
|
37
|
+
for (const key of Object.keys(fields).sort()) {
|
|
38
|
+
const value = fields[key];
|
|
39
|
+
if (value == null || value === "") continue;
|
|
40
|
+
const safe = String(value).replace(/[\r\n]+/g, " ").replace(/"/g, "'");
|
|
41
|
+
lines.push(`${key}: "${safe}"`);
|
|
42
|
+
}
|
|
43
|
+
lines.push("---", "");
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderDecisionMarkdown(entry, { generatedAt } = {}) {
|
|
48
|
+
const title = scrubTitle(entry?.title ?? entry?.id, "decision");
|
|
49
|
+
const id = scrubTitle(entry?.id ?? slugify(title), slugify(title));
|
|
50
|
+
const body = String(entry?.body ?? entry?.content ?? "").trim();
|
|
51
|
+
const fm = formatKnowledgeFrontmatter({
|
|
52
|
+
kairo_kind: "decision",
|
|
53
|
+
kairo_id: id,
|
|
54
|
+
source: "engram-export",
|
|
55
|
+
generated_at: generatedAt ?? null,
|
|
56
|
+
title
|
|
57
|
+
});
|
|
58
|
+
const wiki = `[[decisions/${slugify(title)}]]`;
|
|
59
|
+
return {
|
|
60
|
+
relativePath: `decisions/${slugify(title)}.md`,
|
|
61
|
+
title,
|
|
62
|
+
provenance: { system: "engram", kind: "decision", id },
|
|
63
|
+
markdown: `${fm}# ${title}\n\n${body || "_No body provided._"}\n\n---\nSource: Engram export · ${wiki}\n`
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function renderArchitectureMarkdown(entry, { generatedAt } = {}) {
|
|
68
|
+
const title = scrubTitle(entry?.title ?? entry?.name ?? entry?.id, "architecture");
|
|
69
|
+
const id = scrubTitle(entry?.id ?? slugify(title), slugify(title));
|
|
70
|
+
const detail = String(entry?.detail ?? entry?.summary ?? "").trim();
|
|
71
|
+
const links = Array.isArray(entry?.related)
|
|
72
|
+
? entry.related.map((r) => `- [[architecture/${slugify(r)}]]`).join("\n")
|
|
73
|
+
: "";
|
|
74
|
+
const fm = formatKnowledgeFrontmatter({
|
|
75
|
+
kairo_kind: "architecture",
|
|
76
|
+
kairo_id: id,
|
|
77
|
+
source: "graphify-export",
|
|
78
|
+
generated_at: generatedAt ?? null,
|
|
79
|
+
title
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
relativePath: `architecture/${slugify(title)}.md`,
|
|
83
|
+
title,
|
|
84
|
+
provenance: { system: "graphify", kind: "architecture", id },
|
|
85
|
+
markdown: `${fm}# ${title}\n\n${detail || "_No summary provided._"}\n${links ? `\n## Related\n${links}\n` : ""}`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Pure composer — accepts already-exported records only.
|
|
91
|
+
* Never opens Engram/Graphify internal DBs or vault files.
|
|
92
|
+
*/
|
|
93
|
+
export function buildObsidianKnowledgePreview({
|
|
94
|
+
decisions = [],
|
|
95
|
+
architecture = [],
|
|
96
|
+
generatedAt = new Date().toISOString(),
|
|
97
|
+
maxProposals = MAX_PROPOSALS,
|
|
98
|
+
kairoRoot = "/virtual/Kairo"
|
|
99
|
+
} = {}) {
|
|
100
|
+
const diagnostics = [];
|
|
101
|
+
const proposals = [];
|
|
102
|
+
|
|
103
|
+
const push = (draft) => {
|
|
104
|
+
if (proposals.length >= maxProposals) return;
|
|
105
|
+
const gate = resolveKairoNotePath(kairoRoot, draft.relativePath);
|
|
106
|
+
if (!gate.ok) {
|
|
107
|
+
diagnostics.push(`rejected ${draft.relativePath}: ${gate.reason}`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
proposals.push({
|
|
111
|
+
relativePath: draft.relativePath,
|
|
112
|
+
title: draft.title,
|
|
113
|
+
markdown: draft.markdown,
|
|
114
|
+
provenance: draft.provenance,
|
|
115
|
+
absolutePath: gate.path
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (const entry of decisions ?? []) {
|
|
120
|
+
if (entry == null || typeof entry !== "object") {
|
|
121
|
+
diagnostics.push("skipped malformed decision");
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
push(renderDecisionMarkdown(entry, { generatedAt }));
|
|
125
|
+
}
|
|
126
|
+
for (const entry of architecture ?? []) {
|
|
127
|
+
if (entry == null || typeof entry !== "object") {
|
|
128
|
+
diagnostics.push("skipped malformed architecture");
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
push(renderArchitectureMarkdown(entry, { generatedAt }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (proposals.length === 0 && diagnostics.length === 0) {
|
|
135
|
+
return envelope({
|
|
136
|
+
state: "empty",
|
|
137
|
+
proposals: [],
|
|
138
|
+
diagnostics: ["no export records provided"],
|
|
139
|
+
generatedAt,
|
|
140
|
+
error: null
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return envelope({
|
|
145
|
+
state: proposals.length > 0 ? "available" : "partial",
|
|
146
|
+
proposals,
|
|
147
|
+
diagnostics,
|
|
148
|
+
generatedAt,
|
|
149
|
+
error: null
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load preview via injectable export adapters — never vault writes.
|
|
155
|
+
* Adapters must return plain arrays of records (already exported).
|
|
156
|
+
*/
|
|
157
|
+
export async function loadObsidianKnowledgePreview({
|
|
158
|
+
loadEngramExport = null,
|
|
159
|
+
loadGraphifyExport = null,
|
|
160
|
+
kairoRoot = "/virtual/Kairo",
|
|
161
|
+
generatedAt = new Date().toISOString(),
|
|
162
|
+
maxProposals = MAX_PROPOSALS
|
|
163
|
+
} = {}) {
|
|
164
|
+
const diagnostics = [];
|
|
165
|
+
let decisions = [];
|
|
166
|
+
let architecture = [];
|
|
167
|
+
|
|
168
|
+
if (typeof loadEngramExport === "function") {
|
|
169
|
+
try {
|
|
170
|
+
const raw = await loadEngramExport();
|
|
171
|
+
decisions = Array.isArray(raw) ? raw : Array.isArray(raw?.decisions) ? raw.decisions : [];
|
|
172
|
+
if (!Array.isArray(raw) && raw != null && !Array.isArray(raw?.decisions)) {
|
|
173
|
+
diagnostics.push("engram export shape unrecognized");
|
|
174
|
+
}
|
|
175
|
+
} catch (err) {
|
|
176
|
+
diagnostics.push(`engram export failed: ${String(err?.message ?? err)}`);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
diagnostics.push("engram export adapter not provided");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (typeof loadGraphifyExport === "function") {
|
|
183
|
+
try {
|
|
184
|
+
const raw = await loadGraphifyExport();
|
|
185
|
+
architecture = Array.isArray(raw)
|
|
186
|
+
? raw
|
|
187
|
+
: Array.isArray(raw?.architecture)
|
|
188
|
+
? raw.architecture
|
|
189
|
+
: Array.isArray(raw?.communities)
|
|
190
|
+
? raw.communities
|
|
191
|
+
: [];
|
|
192
|
+
if (
|
|
193
|
+
!Array.isArray(raw)
|
|
194
|
+
&& raw != null
|
|
195
|
+
&& !Array.isArray(raw?.architecture)
|
|
196
|
+
&& !Array.isArray(raw?.communities)
|
|
197
|
+
) {
|
|
198
|
+
diagnostics.push("graphify export shape unrecognized");
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
diagnostics.push(`graphify export failed: ${String(err?.message ?? err)}`);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
diagnostics.push("graphify export adapter not provided");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const preview = buildObsidianKnowledgePreview({
|
|
208
|
+
decisions, architecture, generatedAt, maxProposals, kairoRoot
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
...preview,
|
|
212
|
+
diagnostics: [...diagnostics, ...(preview.diagnostics ?? [])]
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { resolveKairoNotePath } from "./obsidian-vault.js";
|
|
2
|
+
import { formatKnowledgeFrontmatter } from "./obsidian-knowledge-preview.js";
|
|
3
|
+
|
|
4
|
+
/** Canonical view folders / kairo_kind values under Kairo/. */
|
|
5
|
+
export const KAIRO_VIEW_KINDS = Object.freeze([
|
|
6
|
+
"projects", "decisions", "architecture", "sessions", "reviews"
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const KIND_SET = new Set(KAIRO_VIEW_KINDS);
|
|
10
|
+
const MAX_INDEX_LINKS = 80;
|
|
11
|
+
|
|
12
|
+
function envelope(partial = {}) {
|
|
13
|
+
return { state: "error", views: emptyViews(), links: [], diagnostics: [], error: null, ...partial };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function emptyViews() {
|
|
17
|
+
return Object.fromEntries(KAIRO_VIEW_KINDS.map((k) => [k, []]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse simple `key: "value"` frontmatter between leading --- fences. */
|
|
21
|
+
export function parseKnowledgeFrontmatter(markdown) {
|
|
22
|
+
const text = String(markdown ?? "");
|
|
23
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
24
|
+
if (!m) return { fields: {}, body: text, hasFrontmatter: false };
|
|
25
|
+
const fields = {};
|
|
26
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
27
|
+
const kv = line.match(/^([A-Za-z0-9_]+):\s*"(.*)"\s*$/);
|
|
28
|
+
if (kv) fields[kv[1]] = kv[2];
|
|
29
|
+
}
|
|
30
|
+
return { fields, body: text.slice(m[0].length), hasFrontmatter: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Extract `[[target]]` / `[[target|alias]]` wikilinks — display-only graph edges. */
|
|
34
|
+
export function extractWikilinks(markdown) {
|
|
35
|
+
const links = [];
|
|
36
|
+
const re = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
|
|
37
|
+
let match;
|
|
38
|
+
const text = String(markdown ?? "");
|
|
39
|
+
while ((match = re.exec(text)) != null) {
|
|
40
|
+
const target = match[1].trim().replace(/\\/g, "/");
|
|
41
|
+
if (target && !target.includes("..")) links.push(target);
|
|
42
|
+
}
|
|
43
|
+
return links;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function inferKind(relativePath, fields) {
|
|
47
|
+
const kind = fields.kairo_kind;
|
|
48
|
+
if (typeof kind === "string" && KIND_SET.has(kind)) return kind;
|
|
49
|
+
const head = String(relativePath ?? "").split(/[/\\]/)[0];
|
|
50
|
+
return KIND_SET.has(head) ? head : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeWikiTarget(target) {
|
|
54
|
+
const t = String(target).replace(/\.md$/i, "");
|
|
55
|
+
return t.startsWith("/") ? t.slice(1) : t;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read-only index of Kairo notes into view buckets + wikilink edges.
|
|
60
|
+
* `contentsByPath` is optional utf8 map; missing content → title-only entries.
|
|
61
|
+
*/
|
|
62
|
+
export function buildObsidianKnowledgeViews({
|
|
63
|
+
notes = [],
|
|
64
|
+
contentsByPath = {},
|
|
65
|
+
kairoRoot = "/virtual/Kairo"
|
|
66
|
+
} = {}) {
|
|
67
|
+
const views = emptyViews();
|
|
68
|
+
const links = [];
|
|
69
|
+
const diagnostics = [];
|
|
70
|
+
const byPath = new Set();
|
|
71
|
+
|
|
72
|
+
for (const note of notes ?? []) {
|
|
73
|
+
if (note == null || typeof note !== "object") {
|
|
74
|
+
diagnostics.push("skipped malformed note");
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const relativePath = String(note.relativePath ?? "");
|
|
78
|
+
const gate = resolveKairoNotePath(kairoRoot, relativePath);
|
|
79
|
+
if (!gate.ok) {
|
|
80
|
+
diagnostics.push(`skipped ${relativePath || "?"}: ${gate.reason}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const markdown = contentsByPath[relativePath];
|
|
84
|
+
const parsed = typeof markdown === "string"
|
|
85
|
+
? parseKnowledgeFrontmatter(markdown)
|
|
86
|
+
: { fields: {}, body: "", hasFrontmatter: false };
|
|
87
|
+
const kind = inferKind(relativePath, parsed.fields);
|
|
88
|
+
if (!kind) {
|
|
89
|
+
diagnostics.push(`unclassified ${relativePath}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const title = parsed.fields.title
|
|
93
|
+
|| note.title
|
|
94
|
+
|| relativePath.replace(/\.md$/i, "").split("/").pop();
|
|
95
|
+
const entry = {
|
|
96
|
+
relativePath,
|
|
97
|
+
title: String(title),
|
|
98
|
+
kairoId: parsed.fields.kairo_id ?? null,
|
|
99
|
+
kind,
|
|
100
|
+
wikiPath: relativePath.replace(/\.md$/i, "")
|
|
101
|
+
};
|
|
102
|
+
views[kind].push(entry);
|
|
103
|
+
byPath.add(entry.wikiPath);
|
|
104
|
+
|
|
105
|
+
if (typeof markdown === "string") {
|
|
106
|
+
for (const target of extractWikilinks(markdown)) {
|
|
107
|
+
links.push({
|
|
108
|
+
from: entry.wikiPath,
|
|
109
|
+
to: normalizeWikiTarget(target),
|
|
110
|
+
kind: entry.kind
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const kind of KAIRO_VIEW_KINDS) {
|
|
117
|
+
views[kind].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
118
|
+
}
|
|
119
|
+
links.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
120
|
+
|
|
121
|
+
const total = KAIRO_VIEW_KINDS.reduce((n, k) => n + views[k].length, 0);
|
|
122
|
+
return envelope({
|
|
123
|
+
state: total > 0 ? "available" : "empty",
|
|
124
|
+
views,
|
|
125
|
+
links,
|
|
126
|
+
diagnostics,
|
|
127
|
+
error: null,
|
|
128
|
+
resolvedTargets: [...byPath].sort()
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build index-note *proposals* only — publish via Slice 03 with consent. */
|
|
133
|
+
export function buildKnowledgeIndexProposals(viewsResult, {
|
|
134
|
+
generatedAt = new Date().toISOString(),
|
|
135
|
+
kairoRoot = "/virtual/Kairo"
|
|
136
|
+
} = {}) {
|
|
137
|
+
const views = viewsResult?.views ?? emptyViews();
|
|
138
|
+
const proposals = [];
|
|
139
|
+
const diagnostics = [];
|
|
140
|
+
|
|
141
|
+
for (const kind of KAIRO_VIEW_KINDS) {
|
|
142
|
+
const entries = views[kind] ?? [];
|
|
143
|
+
const lines = entries.slice(0, MAX_INDEX_LINKS).map((e) => `- [[${e.wikiPath}|${e.title}]]`);
|
|
144
|
+
const fm = formatKnowledgeFrontmatter({
|
|
145
|
+
kairo_kind: kind,
|
|
146
|
+
kairo_id: `index-${kind}`,
|
|
147
|
+
source: "kairo-index",
|
|
148
|
+
generated_at: generatedAt,
|
|
149
|
+
title: `${kind} index`
|
|
150
|
+
});
|
|
151
|
+
const markdown = `${fm}# ${kind}\n\n${lines.length ? lines.join("\n") : "_No notes yet._"}\n`;
|
|
152
|
+
const relativePath = `${kind}/index.md`;
|
|
153
|
+
const gate = resolveKairoNotePath(kairoRoot, relativePath);
|
|
154
|
+
if (!gate.ok) {
|
|
155
|
+
diagnostics.push(`index ${kind}: ${gate.reason}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
proposals.push({
|
|
159
|
+
relativePath,
|
|
160
|
+
title: `${kind} index`,
|
|
161
|
+
markdown,
|
|
162
|
+
provenance: { system: "kairo", kind: "index", id: kind },
|
|
163
|
+
absolutePath: gate.path
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
state: proposals.length ? "available" : "empty",
|
|
169
|
+
proposals,
|
|
170
|
+
diagnostics,
|
|
171
|
+
generatedAt
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convenience: inspect notes + optional content loader → views.
|
|
177
|
+
* Never writes; `readNote` must be injectable (tests / CLI).
|
|
178
|
+
*/
|
|
179
|
+
export async function loadObsidianKnowledgeViews({
|
|
180
|
+
inspectVault,
|
|
181
|
+
vaultPath,
|
|
182
|
+
readNote = null,
|
|
183
|
+
kairoRoot = null
|
|
184
|
+
} = {}) {
|
|
185
|
+
if (typeof inspectVault !== "function") {
|
|
186
|
+
return envelope({ error: "inspectVault required", diagnostics: ["inspectVault required"] });
|
|
187
|
+
}
|
|
188
|
+
let inspected;
|
|
189
|
+
try {
|
|
190
|
+
inspected = await inspectVault({ vaultPath });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return envelope({
|
|
193
|
+
error: String(err?.message ?? err),
|
|
194
|
+
diagnostics: [`inspect failed: ${String(err?.message ?? err)}`]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const root = kairoRoot ?? inspected?.kairoRoot;
|
|
198
|
+
if (!root) {
|
|
199
|
+
return envelope({
|
|
200
|
+
state: inspected?.state ?? "error",
|
|
201
|
+
error: inspected?.error ?? "kairoRoot missing",
|
|
202
|
+
diagnostics: inspected?.diagnostics ?? ["kairoRoot missing"]
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const contentsByPath = {};
|
|
206
|
+
if (typeof readNote === "function") {
|
|
207
|
+
for (const note of inspected.notes ?? []) {
|
|
208
|
+
try {
|
|
209
|
+
const text = await readNote(note.relativePath, root);
|
|
210
|
+
if (typeof text === "string") contentsByPath[note.relativePath] = text;
|
|
211
|
+
} catch {
|
|
212
|
+
/* fail-soft: title-only entry */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const built = buildObsidianKnowledgeViews({
|
|
217
|
+
notes: inspected.notes ?? [],
|
|
218
|
+
contentsByPath,
|
|
219
|
+
kairoRoot: root
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
...built,
|
|
223
|
+
diagnostics: [...(inspected.diagnostics ?? []), ...built.diagnostics],
|
|
224
|
+
vaultPath: inspected.vaultPath ?? vaultPath ?? null,
|
|
225
|
+
kairoRoot: root
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
copyFile, lstat, mkdir, readFile, realpath, rename, rm, writeFile
|
|
5
|
+
} from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { hashBuffer } from "../../hash.js";
|
|
8
|
+
import {
|
|
9
|
+
assertInsideKairoRoot, isAllowedKairoNoteName, resolveKairoNotePath
|
|
10
|
+
} from "./obsidian-vault.js";
|
|
11
|
+
|
|
12
|
+
/** Slice 02 frontmatter marker — managed notes may be updated; manual notes refused. */
|
|
13
|
+
export const KAIRO_MANAGED_FRONTMATTER = /^kairo_kind:\s*"/m;
|
|
14
|
+
export const BACKUP_DIR_NAME = ".kairo-backups";
|
|
15
|
+
|
|
16
|
+
const envelope = (partial = {}) => ({
|
|
17
|
+
state: "error", dryRun: false, results: [], diagnostics: [], error: null, ...partial
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const hasConsent = ({ yes = false, confirm = false } = {}) =>
|
|
21
|
+
yes === true || confirm === true;
|
|
22
|
+
|
|
23
|
+
export function classifyNoteWrite(existingMarkdown, proposedMarkdown) {
|
|
24
|
+
if (existingMarkdown === proposedMarkdown) return { action: "skip", reason: "identical" };
|
|
25
|
+
if (KAIRO_MANAGED_FRONTMATTER.test(String(existingMarkdown ?? ""))) {
|
|
26
|
+
return { action: "update", reason: "managed" };
|
|
27
|
+
}
|
|
28
|
+
return { action: "refuse", reason: "manual content" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateProposal(proposal, kairoRoot) {
|
|
32
|
+
if (proposal == null || typeof proposal !== "object") return { ok: false, reason: "malformed proposal" };
|
|
33
|
+
const relativePath = String(proposal.relativePath ?? "");
|
|
34
|
+
const markdown = proposal.markdown;
|
|
35
|
+
if (typeof markdown !== "string") return { ok: false, reason: "proposal markdown required" };
|
|
36
|
+
const base = relativePath.split(/[/\\]/).pop() ?? "";
|
|
37
|
+
if (!isAllowedKairoNoteName(base)) return { ok: false, reason: "proposal basename not allowed" };
|
|
38
|
+
const gate = resolveKairoNotePath(kairoRoot, relativePath);
|
|
39
|
+
if (!gate.ok) return { ok: false, reason: gate.reason };
|
|
40
|
+
return { ok: true, relativePath, markdown, absolutePath: gate.path };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Plan-only: `existingByPath` maps relativePath → utf8 (null = missing). No writes. */
|
|
44
|
+
export function planObsidianPublish(proposals = [], { kairoRoot, existingByPath = {} } = {}) {
|
|
45
|
+
if (typeof kairoRoot !== "string" || !kairoRoot) {
|
|
46
|
+
return envelope({ error: "kairoRoot required", diagnostics: ["kairoRoot required"] });
|
|
47
|
+
}
|
|
48
|
+
const results = [], diagnostics = [];
|
|
49
|
+
for (const proposal of proposals) {
|
|
50
|
+
const v = validateProposal(proposal, kairoRoot);
|
|
51
|
+
if (!v.ok) {
|
|
52
|
+
results.push({ relativePath: proposal?.relativePath ?? null, action: "refuse", reason: v.reason });
|
|
53
|
+
diagnostics.push(v.reason);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const existing = existingByPath[v.relativePath];
|
|
57
|
+
const cls = existing == null
|
|
58
|
+
? { action: "create", reason: "missing" }
|
|
59
|
+
: classifyNoteWrite(existing, v.markdown);
|
|
60
|
+
results.push({
|
|
61
|
+
relativePath: v.relativePath, absolutePath: v.absolutePath,
|
|
62
|
+
action: cls.action, reason: cls.reason,
|
|
63
|
+
hash: hashBuffer(Buffer.from(v.markdown, "utf8"))
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return envelope({ state: "planned", dryRun: true, results, diagnostics, error: null });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function resolveKairoRoot(kairoRoot, { realpathFn = realpath, existsFn = existsSync } = {}) {
|
|
70
|
+
if (typeof kairoRoot !== "string" || !kairoRoot) return { ok: false, reason: "kairoRoot required" };
|
|
71
|
+
if (!existsFn(kairoRoot)) return { ok: false, reason: "kairoRoot missing" };
|
|
72
|
+
try { return { ok: true, path: await realpathFn(kairoRoot) }; }
|
|
73
|
+
catch { return { ok: false, reason: "kairoRoot unreadable" }; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readExistingMap(proposals, kairoRoot, { readFileFn = readFile, existsFn = existsSync } = {}) {
|
|
77
|
+
const map = {};
|
|
78
|
+
for (const proposal of proposals) {
|
|
79
|
+
const v = validateProposal(proposal, kairoRoot);
|
|
80
|
+
if (!v.ok) continue;
|
|
81
|
+
if (!existsFn(v.absolutePath)) { map[v.relativePath] = null; continue; }
|
|
82
|
+
try { map[v.relativePath] = await readFileFn(v.absolutePath, "utf8"); }
|
|
83
|
+
catch { map[v.relativePath] = null; }
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function backupExisting(absolutePath, relativePath, kairoRoot, {
|
|
89
|
+
copyFileFn = copyFile, mkdirFn = mkdir, nowMs = Date.now()
|
|
90
|
+
} = {}) {
|
|
91
|
+
const backupGate = resolveKairoNotePath(kairoRoot, join(BACKUP_DIR_NAME, `${relativePath}.${nowMs}.bak`));
|
|
92
|
+
if (!backupGate.ok) return { ok: false, reason: backupGate.reason };
|
|
93
|
+
await mkdirFn(dirname(backupGate.path), { recursive: true });
|
|
94
|
+
await copyFileFn(absolutePath, backupGate.path);
|
|
95
|
+
return { ok: true, path: backupGate.path };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function atomicWrite(absolutePath, markdown, kairoRoot, {
|
|
99
|
+
writeFileFn = writeFile, renameFn = rename, rmFn = rm, mkdirFn = mkdir,
|
|
100
|
+
lstatFn = lstat, existsFn = existsSync
|
|
101
|
+
} = {}) {
|
|
102
|
+
const parent = dirname(absolutePath);
|
|
103
|
+
await mkdirFn(parent, { recursive: true });
|
|
104
|
+
const parentGate = await assertInsideKairoRoot(parent, kairoRoot, { lstatFn, existsFn });
|
|
105
|
+
if (!parentGate.ok && !parentGate.missing) throw new Error(parentGate.reason ?? "parent escapes Kairo/");
|
|
106
|
+
const destGate = await assertInsideKairoRoot(absolutePath, kairoRoot, { lstatFn, existsFn });
|
|
107
|
+
if (!destGate.ok) throw new Error(destGate.reason);
|
|
108
|
+
if (destGate.symlink) throw new Error("destination is a symlink; refusing");
|
|
109
|
+
const tmp = join(parent, `.kairo-write-${randomBytes(12).toString("hex")}.tmp`);
|
|
110
|
+
try {
|
|
111
|
+
await writeFileFn(tmp, markdown, { encoding: "utf8", flag: "wx" });
|
|
112
|
+
await renameFn(tmp, absolutePath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await rmFn(tmp, { force: true }).catch(() => {});
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Consent-gated publish. dryRun / no consent → plan only. Never deletes notes. */
|
|
120
|
+
export async function publishObsidianProposals({
|
|
121
|
+
kairoRoot, proposals = [], yes = false, confirm = false, dryRun = false,
|
|
122
|
+
readFileFn = readFile, writeFileFn = writeFile, renameFn = rename, rmFn = rm,
|
|
123
|
+
mkdirFn = mkdir, copyFileFn = copyFile, lstatFn = lstat, existsFn = existsSync,
|
|
124
|
+
realpathFn = realpath, nowMs = Date.now()
|
|
125
|
+
} = {}) {
|
|
126
|
+
const resolved = await resolveKairoRoot(kairoRoot, { realpathFn, existsFn });
|
|
127
|
+
if (!resolved.ok) return envelope({ error: resolved.reason, diagnostics: [resolved.reason] });
|
|
128
|
+
kairoRoot = resolved.path;
|
|
129
|
+
|
|
130
|
+
const plan = planObsidianPublish(proposals, {
|
|
131
|
+
kairoRoot,
|
|
132
|
+
existingByPath: await readExistingMap(proposals, kairoRoot, { readFileFn, existsFn })
|
|
133
|
+
});
|
|
134
|
+
if (dryRun === true || !hasConsent({ yes, confirm })) {
|
|
135
|
+
const reason = dryRun === true ? null : "consent required: pass yes or confirm";
|
|
136
|
+
return {
|
|
137
|
+
...plan, state: dryRun === true ? "planned" : "blocked", dryRun: true, error: reason,
|
|
138
|
+
diagnostics: reason ? [...plan.diagnostics, reason] : plan.diagnostics
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const results = [], diagnostics = [...plan.diagnostics];
|
|
143
|
+
const io = { writeFileFn, renameFn, rmFn, mkdirFn, copyFileFn, lstatFn, existsFn, nowMs };
|
|
144
|
+
for (const step of plan.results) {
|
|
145
|
+
if (step.action === "refuse" || step.action === "skip") { results.push({ ...step }); continue; }
|
|
146
|
+
const markdown = proposals.find((p) => p?.relativePath === step.relativePath)?.markdown;
|
|
147
|
+
if (typeof markdown !== "string") {
|
|
148
|
+
results.push({ ...step, action: "refuse", reason: "proposal markdown required" });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
let backupPath = null;
|
|
153
|
+
if (step.action === "update" && existsFn(step.absolutePath)) {
|
|
154
|
+
const bak = await backupExisting(step.absolutePath, step.relativePath, kairoRoot, io);
|
|
155
|
+
if (!bak.ok) {
|
|
156
|
+
results.push({ ...step, action: "refuse", reason: bak.reason });
|
|
157
|
+
diagnostics.push(bak.reason);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
backupPath = bak.path;
|
|
161
|
+
}
|
|
162
|
+
const gate = resolveKairoNotePath(kairoRoot, step.relativePath);
|
|
163
|
+
if (!gate.ok) { results.push({ ...step, action: "refuse", reason: gate.reason }); continue; }
|
|
164
|
+
await atomicWrite(gate.path, markdown, kairoRoot, io);
|
|
165
|
+
results.push({
|
|
166
|
+
...step, absolutePath: gate.path, backupPath,
|
|
167
|
+
action: step.action === "create" ? "created" : "updated"
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const msg = String(err?.message ?? err);
|
|
171
|
+
results.push({ ...step, action: "error", reason: msg });
|
|
172
|
+
diagnostics.push(msg);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const wrote = results.some((r) => r.action === "created" || r.action === "updated");
|
|
176
|
+
const errored = results.some((r) => r.action === "error");
|
|
177
|
+
return envelope({
|
|
178
|
+
state: errored ? "partial" : wrote ? "applied" : "noop",
|
|
179
|
+
dryRun: false, results, diagnostics, error: null
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { inspectObsidianVault as defaultInspect } from "./obsidian-vault.js";
|
|
2
|
+
|
|
3
|
+
function envelope(partial = {}) {
|
|
4
|
+
return {
|
|
5
|
+
state: "unconfigured",
|
|
6
|
+
vaultPath: null,
|
|
7
|
+
kairoRoot: null,
|
|
8
|
+
noteCount: 0,
|
|
9
|
+
lastPublishAt: null,
|
|
10
|
+
pendingProposals: 0,
|
|
11
|
+
diagnostics: [],
|
|
12
|
+
error: null,
|
|
13
|
+
...partial
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function emptyObsidianVaultStatus() {
|
|
18
|
+
return envelope({ state: "error", error: "error" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function summarizeObsidianVaultStatus(raw) {
|
|
22
|
+
if (raw == null || typeof raw !== "object") return emptyObsidianVaultStatus();
|
|
23
|
+
const noteCount = Array.isArray(raw.notes)
|
|
24
|
+
? raw.notes.length
|
|
25
|
+
: (typeof raw.noteCount === "number" && Number.isFinite(raw.noteCount) ? raw.noteCount : 0);
|
|
26
|
+
return envelope({
|
|
27
|
+
state: typeof raw.state === "string" && raw.state ? raw.state : "error",
|
|
28
|
+
vaultPath: raw.vaultPath ?? null,
|
|
29
|
+
kairoRoot: raw.kairoRoot ?? null,
|
|
30
|
+
noteCount,
|
|
31
|
+
lastPublishAt: typeof raw.lastPublishAt === "string" ? raw.lastPublishAt : null,
|
|
32
|
+
pendingProposals: typeof raw.pendingProposals === "number" && raw.pendingProposals > 0
|
|
33
|
+
? Math.floor(raw.pendingProposals)
|
|
34
|
+
: 0,
|
|
35
|
+
diagnostics: Array.isArray(raw.diagnostics) ? raw.diagnostics.map(String) : [],
|
|
36
|
+
error: raw.error == null ? null : String(raw.error)
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read-only vault status for Cockpit. No writes / no auto-sync.
|
|
42
|
+
* Without an absolute vaultPath → unconfigured (never guesses a home vault).
|
|
43
|
+
*/
|
|
44
|
+
export async function loadObsidianVaultStatus({
|
|
45
|
+
vaultPath = null,
|
|
46
|
+
lastPublishAt = null,
|
|
47
|
+
pendingProposals = 0,
|
|
48
|
+
inspectObsidianVault = defaultInspect
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (vaultPath == null || vaultPath === "") {
|
|
51
|
+
return envelope({
|
|
52
|
+
state: "unconfigured",
|
|
53
|
+
diagnostics: ["vaultPath not configured"],
|
|
54
|
+
lastPublishAt,
|
|
55
|
+
pendingProposals: typeof pendingProposals === "number" ? Math.max(0, Math.floor(pendingProposals)) : 0
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const inspected = await inspectObsidianVault({ vaultPath });
|
|
60
|
+
return summarizeObsidianVaultStatus({
|
|
61
|
+
...inspected,
|
|
62
|
+
noteCount: inspected?.notes?.length ?? 0,
|
|
63
|
+
lastPublishAt,
|
|
64
|
+
pendingProposals
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return envelope({
|
|
68
|
+
state: "error",
|
|
69
|
+
vaultPath: String(vaultPath),
|
|
70
|
+
error: String(err?.message ?? err),
|
|
71
|
+
diagnostics: [String(err?.message ?? err)],
|
|
72
|
+
lastPublishAt,
|
|
73
|
+
pendingProposals: typeof pendingProposals === "number" ? Math.max(0, Math.floor(pendingProposals)) : 0
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { lstat, readdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
|
4
|
+
import { isPathInside } from "../component-paths.js";
|
|
5
|
+
|
|
6
|
+
/** Obsidian vault subfolder Kairo may read — never the whole vault. */
|
|
7
|
+
export const KAIRO_VAULT_SUBDIR = "Kairo";
|
|
8
|
+
|
|
9
|
+
/** Directory basenames refused anywhere under the Kairo tree. */
|
|
10
|
+
export const EXCLUDED_DIR_NAMES = Object.freeze([
|
|
11
|
+
".obsidian", ".git", ".trash", "attachments", "Attachment", "Assets", "assets"
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const SECRET_BASENAME = /(?:^|\.)(env|secret|secrets|credentials|token|tokens|key|keys|pem|p12|pfx)(?:\.|$)/i;
|
|
15
|
+
const MARKDOWN_EXT = /\.md$/i;
|
|
16
|
+
const MAX_NOTES = 200;
|
|
17
|
+
const MAX_WALK_DEPTH = 8;
|
|
18
|
+
|
|
19
|
+
function envelope(partial = {}) {
|
|
20
|
+
return {
|
|
21
|
+
state: "error",
|
|
22
|
+
vaultPath: null,
|
|
23
|
+
kairoRoot: null,
|
|
24
|
+
notes: [],
|
|
25
|
+
diagnostics: [],
|
|
26
|
+
error: null,
|
|
27
|
+
...partial
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Absolute vault path only — no home expansion, no relative paths. */
|
|
32
|
+
export function normalizeVaultPath(raw) {
|
|
33
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
34
|
+
return { ok: false, reason: "vaultPath required" };
|
|
35
|
+
}
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
if (!isAbsolute(trimmed)) {
|
|
38
|
+
return { ok: false, reason: "vaultPath must be absolute" };
|
|
39
|
+
}
|
|
40
|
+
const resolved = resolve(trimmed);
|
|
41
|
+
if (resolved.includes("\0")) {
|
|
42
|
+
return { ok: false, reason: "vaultPath invalid" };
|
|
43
|
+
}
|
|
44
|
+
return { ok: true, path: resolved };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isExcludedDirName(name) {
|
|
48
|
+
return EXCLUDED_DIR_NAMES.includes(String(name ?? ""));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isSecretBasename(name) {
|
|
52
|
+
return SECRET_BASENAME.test(String(name ?? ""));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isAllowedKairoNoteName(name) {
|
|
56
|
+
const base = basename(String(name ?? ""));
|
|
57
|
+
if (!MARKDOWN_EXT.test(base)) return false;
|
|
58
|
+
if (isSecretBasename(base)) return false;
|
|
59
|
+
if (base.startsWith(".")) return false;
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Candidate must resolve inside kairoRoot (realpath). Symlinks that escape fail.
|
|
65
|
+
* Relative segments with `..` that leave Kairo/ fail before IO when possible.
|
|
66
|
+
*/
|
|
67
|
+
export async function assertInsideKairoRoot(candidatePath, kairoRoot, {
|
|
68
|
+
lstatFn = lstat,
|
|
69
|
+
realpathFn = realpath,
|
|
70
|
+
existsFn = existsSync
|
|
71
|
+
} = {}) {
|
|
72
|
+
const root = resolve(kairoRoot);
|
|
73
|
+
const claimed = resolve(candidatePath);
|
|
74
|
+
if (!isPathInside(root, claimed) && claimed !== root) {
|
|
75
|
+
return { ok: false, reason: "path escapes Kairo/" };
|
|
76
|
+
}
|
|
77
|
+
if (!existsFn(claimed)) {
|
|
78
|
+
return { ok: true, path: claimed, missing: true };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const st = await lstatFn(claimed);
|
|
82
|
+
if (st.isSymbolicLink()) {
|
|
83
|
+
const target = await realpathFn(claimed);
|
|
84
|
+
if (!isPathInside(root, target) && target !== root) {
|
|
85
|
+
return { ok: false, reason: "symlink escapes Kairo/" };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, path: target, symlink: true };
|
|
88
|
+
}
|
|
89
|
+
const real = await realpathFn(claimed);
|
|
90
|
+
if (!isPathInside(root, real) && real !== root) {
|
|
91
|
+
return { ok: false, reason: "realpath escapes Kairo/" };
|
|
92
|
+
}
|
|
93
|
+
return { ok: true, path: real };
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, reason: "path unreadable" };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refuseVaultSymlink(vaultPath, { lstatFn = lstat } = {}) {
|
|
100
|
+
try {
|
|
101
|
+
if ((await lstatFn(vaultPath)).isSymbolicLink()) {
|
|
102
|
+
return "vault root is a symlink; refusing";
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
return "vault unreadable";
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read-only inspect of an Obsidian vault's Kairo/ subtree.
|
|
112
|
+
* Never writes; never reads Engram/Graphify stores; never opens .obsidian.
|
|
113
|
+
*/
|
|
114
|
+
export async function inspectObsidianVault({
|
|
115
|
+
vaultPath,
|
|
116
|
+
lstatFn = lstat,
|
|
117
|
+
readdirFn = readdir,
|
|
118
|
+
realpathFn = realpath,
|
|
119
|
+
existsFn = existsSync,
|
|
120
|
+
maxNotes = MAX_NOTES
|
|
121
|
+
} = {}) {
|
|
122
|
+
const norm = normalizeVaultPath(vaultPath);
|
|
123
|
+
if (!norm.ok) {
|
|
124
|
+
return envelope({ state: "unavailable", error: norm.reason, diagnostics: [norm.reason] });
|
|
125
|
+
}
|
|
126
|
+
const root = norm.path;
|
|
127
|
+
if (!existsFn(root)) {
|
|
128
|
+
return envelope({
|
|
129
|
+
state: "missing", vaultPath: root, error: "vault missing",
|
|
130
|
+
diagnostics: ["vault path does not exist"]
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const vaultSym = await refuseVaultSymlink(root, { lstatFn });
|
|
134
|
+
if (vaultSym) {
|
|
135
|
+
return envelope({
|
|
136
|
+
state: "error", vaultPath: root, error: vaultSym, diagnostics: [vaultSym]
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let vaultReal;
|
|
141
|
+
try { vaultReal = await realpathFn(root); }
|
|
142
|
+
catch {
|
|
143
|
+
return envelope({
|
|
144
|
+
state: "error", vaultPath: root, error: "vault unreadable",
|
|
145
|
+
diagnostics: ["vault realpath failed"]
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const kairoRoot = join(vaultReal, KAIRO_VAULT_SUBDIR);
|
|
150
|
+
if (!existsFn(kairoRoot)) {
|
|
151
|
+
return envelope({
|
|
152
|
+
state: "partial", vaultPath: vaultReal, kairoRoot,
|
|
153
|
+
error: null, diagnostics: ["Kairo/ subdirectory missing"]
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const kairoGate = await assertInsideKairoRoot(kairoRoot, kairoRoot, {
|
|
158
|
+
lstatFn, realpathFn, existsFn
|
|
159
|
+
});
|
|
160
|
+
if (!kairoGate.ok) {
|
|
161
|
+
return envelope({
|
|
162
|
+
state: "error", vaultPath: vaultReal, kairoRoot,
|
|
163
|
+
error: kairoGate.reason, diagnostics: [kairoGate.reason]
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (kairoGate.symlink) {
|
|
167
|
+
return envelope({
|
|
168
|
+
state: "error", vaultPath: vaultReal, kairoRoot,
|
|
169
|
+
error: "Kairo/ is a symlink; refusing",
|
|
170
|
+
diagnostics: ["Kairo/ is a symlink; refusing"]
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const notes = [];
|
|
175
|
+
const diagnostics = [];
|
|
176
|
+
await walkKairoNotes(kairoGate.path, kairoGate.path, {
|
|
177
|
+
notes, diagnostics, depth: 0, maxNotes,
|
|
178
|
+
lstatFn, readdirFn, realpathFn, existsFn
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return envelope({
|
|
182
|
+
state: "available",
|
|
183
|
+
vaultPath: vaultReal,
|
|
184
|
+
kairoRoot: kairoGate.path,
|
|
185
|
+
notes,
|
|
186
|
+
diagnostics,
|
|
187
|
+
error: null
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function walkKairoNotes(dir, kairoRoot, ctx) {
|
|
192
|
+
if (ctx.notes.length >= ctx.maxNotes || ctx.depth > MAX_WALK_DEPTH) return;
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = await ctx.readdirFn(dir, { withFileTypes: true });
|
|
196
|
+
} catch {
|
|
197
|
+
ctx.diagnostics.push("directory unreadable");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const ordered = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
202
|
+
for (const entry of ordered) {
|
|
203
|
+
if (ctx.notes.length >= ctx.maxNotes) break;
|
|
204
|
+
const name = entry.name;
|
|
205
|
+
if (name === "." || name === ".." || name.includes("\0")) continue;
|
|
206
|
+
if (isExcludedDirName(name)) continue;
|
|
207
|
+
|
|
208
|
+
const full = join(dir, name);
|
|
209
|
+
const gate = await assertInsideKairoRoot(full, kairoRoot, {
|
|
210
|
+
lstatFn: ctx.lstatFn, realpathFn: ctx.realpathFn, existsFn: ctx.existsFn
|
|
211
|
+
});
|
|
212
|
+
if (!gate.ok) {
|
|
213
|
+
ctx.diagnostics.push(`skipped unsafe path (${gate.reason})`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (gate.missing) continue;
|
|
217
|
+
|
|
218
|
+
let st;
|
|
219
|
+
try { st = await ctx.lstatFn(full); }
|
|
220
|
+
catch {
|
|
221
|
+
ctx.diagnostics.push("entry unreadable");
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (st.isSymbolicLink()) {
|
|
226
|
+
ctx.diagnostics.push("skipped symlink");
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (st.isDirectory()) {
|
|
230
|
+
await walkKairoNotes(gate.path, kairoRoot, { ...ctx, depth: ctx.depth + 1 });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!st.isFile()) continue;
|
|
234
|
+
if (!isAllowedKairoNoteName(name)) continue;
|
|
235
|
+
|
|
236
|
+
const rel = gate.path.slice(kairoRoot.length).replace(/^[\\/]/, "").split(sep).join("/");
|
|
237
|
+
ctx.notes.push({
|
|
238
|
+
relativePath: rel,
|
|
239
|
+
title: basename(name, ".md")
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Pure helper for callers composing relative note paths under Kairo/. */
|
|
245
|
+
export function resolveKairoNotePath(kairoRoot, relativePath) {
|
|
246
|
+
const root = resolve(kairoRoot);
|
|
247
|
+
const parts = String(relativePath ?? "").split(/[/\\]/).filter(Boolean);
|
|
248
|
+
if (parts.length === 0 || parts.some((p) => p === ".." || p.includes("\0"))) {
|
|
249
|
+
return { ok: false, reason: "invalid relativePath" };
|
|
250
|
+
}
|
|
251
|
+
if (parts.some((p) => isExcludedDirName(p) || isSecretBasename(p))) {
|
|
252
|
+
return { ok: false, reason: "relativePath excluded" };
|
|
253
|
+
}
|
|
254
|
+
const claimed = resolve(join(root, ...parts));
|
|
255
|
+
if (!isPathInside(root, claimed)) {
|
|
256
|
+
return { ok: false, reason: "path escapes Kairo/" };
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, path: claimed };
|
|
259
|
+
}
|