@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.12.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.equal(pkg.version, "0.11.0");
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
+ }