@kal-elsam/kairo-runtime 0.12.0 → 0.13.1
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/CHANGELOG.md +1179 -0
- package/LICENSE +21 -0
- package/README.md +63 -1193
- package/package.json +5 -3
- package/scripts/cockpit-smoke.mjs +2 -1
- package/src/cli.js +15 -139
- package/src/global/cli-help.js +180 -0
- package/src/global/ink/cockpit-control-center.js +4 -1
- package/src/global/ink/obsidian-vault-display.js +37 -0
- package/src/global/ink/ux/live-overview.js +72 -57
- package/src/global/ink/ux/overview-needs.js +160 -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
|
@@ -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
|
+
}
|