@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.
@@ -2,10 +2,13 @@
2
2
  * Live semantic Overview for Cockpit HOME — product cover.
3
3
  * Nav owns the sole focus mark — this panel never renders `>`.
4
4
  * ASCII wordmark only here (wide/compact); minimal is textual.
5
+ *
6
+ * Rule: show purpose + one next step + a few plain-language needs.
7
+ * Machine/system noise stays out of the first screen (Details only).
5
8
  */
6
9
  import React from "react";
7
10
  import { Box, Text } from "ink";
8
- import { CONTROL_PLANE_HEALTH } from "../../control-plane-snapshot.js";
11
+ import { DASHBOARD_PURPOSE } from "../../dashboard-guidance.js";
9
12
  import { LAYOUT_MODES } from "../layout.js";
10
13
  import { COCKPIT_COLORS } from "../theme.js";
11
14
  import {
@@ -14,86 +17,95 @@ import {
14
17
  wordmarkLines
15
18
  } from "../brand/wordmark.js";
16
19
  import { ActionList, Callout, Details } from "./semantic.js";
20
+ import {
21
+ humanizeDestination,
22
+ humanizeHealthTitle,
23
+ humanizePrimary,
24
+ mapHealthTone,
25
+ partitionCompanionLines
26
+ } from "./overview-needs.js";
17
27
 
18
- const DESTINATION_LABELS = {
19
- setup: "Setup",
20
- changes: "Governance",
21
- ides: "Agents",
22
- runs: "Orchestration",
23
- "control-center": "Overview",
24
- activity: "Activity",
25
- usage: "Usage",
26
- profile: "Settings"
27
- };
28
-
29
- export function mapHealthTone(kind) {
30
- switch (kind) {
31
- case CONTROL_PLANE_HEALTH.CHECK_FAILED:
32
- return "danger";
33
- case CONTROL_PLANE_HEALTH.ACTION_REQUIRED:
34
- case CONTROL_PLANE_HEALTH.NOT_CONFIGURED:
35
- case CONTROL_PLANE_HEALTH.HEALTHY_WITH_NOTES:
36
- return "warn";
37
- case CONTROL_PLANE_HEALTH.HEALTHY:
38
- return "ready";
39
- default:
40
- return "warn";
41
- }
42
- }
43
-
44
- function humanizeDestination(destination) {
45
- if (!destination) return null;
46
- return DESTINATION_LABELS[destination] ?? null;
47
- }
28
+ export {
29
+ humanizeCompanionNeed,
30
+ humanizeHealthTitle,
31
+ humanizePrimary,
32
+ mapHealthTone,
33
+ partitionCompanionLines
34
+ } from "./overview-needs.js";
48
35
 
49
- /** Safe Details lines only — never invent paths/IDs; honest empty when none. */
50
- export function buildOverviewDetails(model = {}) {
36
+ /** Safe Details lines — leftovers + raw signals; never invent paths/IDs. */
37
+ export function buildOverviewDetails(model = {}, companionRest = []) {
51
38
  const lines = [];
52
39
  const next = model.nextAction ?? model.cta ?? {};
53
40
  const dest = humanizeDestination(next.destination);
54
- if (dest) lines.push(`Next destination · ${dest}`);
55
- if (typeof model.alerts?.count === "number") {
41
+ if (dest) lines.push(`Opens · ${dest}`);
42
+ if (typeof model.alerts?.count === "number" && model.alerts.count > 0) {
56
43
  lines.push(`Open alerts · ${model.alerts.count}`);
57
44
  }
45
+ const tokens = model.tokens?.headline;
46
+ if (typeof tokens === "string" && tokens && !/unavailable/i.test(tokens)) {
47
+ lines.push(`Tokens · ${tokens}`);
48
+ }
58
49
  const secondary = model.companionNextAction;
59
50
  if (secondary?.title && secondary.kind !== "idle") {
60
51
  lines.push(`Companion · ${secondary.title}`);
61
52
  }
62
- if (lines.length === 0) return ["No extra evidence beyond the metrics above."];
53
+ for (const line of companionRest) {
54
+ if (typeof line === "string" && line.trim()) lines.push(line);
55
+ }
56
+ if (lines.length === 0) return ["Nothing else to show right now."];
63
57
  return lines;
64
58
  }
65
59
 
66
60
  /**
67
61
  * Pure adapter: buildControlCenterModel → semantic overview props.
68
- * Callout / CTA / metrics never include paths or IDs.
69
- * Primary action is always governance CTA — companion is secondary metrics/details only.
62
+ * First screen = purpose + next step + plain needs. Machine noise → Details.
70
63
  */
71
64
  export function adaptControlCenterToOverview(model = {}) {
72
65
  const status = model.status ?? model.health ?? {};
73
66
  const next = model.nextAction ?? model.cta ?? {};
74
- const companionMetrics = (model.companion?.lines ?? []).map((label, i) => ({
75
- id: `companion-${i}`,
76
- label
77
- }));
67
+ const { needs, rest } = partitionCompanionLines(model.companion?.lines ?? []);
68
+ const primary = humanizePrimary(next);
69
+
70
+ const activity = model.activity?.headline;
71
+ const metrics = [];
72
+ const hasUsefulActivity = typeof activity === "string"
73
+ && activity
74
+ && activity !== "Idle"
75
+ && activity !== "No activity yet";
76
+ if (hasUsefulActivity) {
77
+ metrics.push({ id: "activity", label: `Last activity · ${activity.replace(/^Last ·\s*/, "")}` });
78
+ }
79
+ for (let i = 0; i < needs.length; i += 1) {
80
+ metrics.push({ id: `need-${i}`, label: needs[i] });
81
+ }
82
+ if ((model.alerts?.count ?? 0) > 0) {
83
+ metrics.push({
84
+ id: "alerts",
85
+ label: `Alerts · ${model.alerts.headline ?? `${model.alerts.count} open`}`
86
+ });
87
+ }
88
+ if (rest.length > 0) {
89
+ metrics.push({
90
+ id: "more",
91
+ label: `${rest.length} more in Details · Space`
92
+ });
93
+ }
94
+ if (metrics.length === 0) {
95
+ metrics.push({ id: "quiet", label: "Nothing else needs you right now" });
96
+ }
97
+
78
98
  return {
79
99
  title: model.title ?? "Overview",
100
+ purpose: DASHBOARD_PURPOSE,
80
101
  callout: {
81
102
  tone: mapHealthTone(status.kind),
82
- title: status.label ?? "Unknown",
103
+ title: humanizeHealthTitle(status.kind, status.label),
83
104
  body: status.summaryLine ?? ""
84
105
  },
85
- primary: {
86
- label: next.actionTitle ?? "Review control plane",
87
- detail: next.actionDetail || null,
88
- hint: next.enterHint ?? null
89
- },
90
- metrics: [
91
- { id: "activity", label: `Activity · ${model.activity?.headline ?? "Idle"}` },
92
- { id: "alerts", label: `Alerts · ${model.alerts?.headline ?? "Alert data unavailable"}` },
93
- { id: "tokens", label: `Tokens · ${model.tokens?.headline ?? "Data unavailable"}` },
94
- ...companionMetrics
95
- ],
96
- details: buildOverviewDetails(model)
106
+ primary,
107
+ metrics,
108
+ details: buildOverviewDetails(model, rest)
97
109
  };
98
110
  }
99
111
 
@@ -150,11 +162,14 @@ export function SemanticOverviewPanel({
150
162
 
151
163
  return React.createElement(Box, { flexDirection: "column" },
152
164
  hero,
165
+ React.createElement(Text, {
166
+ color: colorEnabled ? COCKPIT_COLORS.muted : undefined
167
+ }, view.purpose),
153
168
  React.createElement(Box, { marginTop: 1, flexDirection: "column" },
154
169
  React.createElement(Text, {
155
170
  bold: true,
156
171
  color: colorEnabled ? COCKPIT_COLORS.interactive : undefined
157
- }, view.primary.label),
172
+ }, `→ ${view.primary.label}`),
158
173
  view.primary.detail
159
174
  ? React.createElement(Text, null, view.primary.detail)
160
175
  : null,
@@ -175,7 +190,7 @@ export function SemanticOverviewPanel({
175
190
  ),
176
191
  React.createElement(Details, {
177
192
  open: detailsOpen,
178
- summary: "Details",
193
+ summary: "More info",
179
194
  lines: view.details,
180
195
  colorEnabled,
181
196
  focused: false,
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Plain-language Overview needs — no machine noise on the first screen.
3
+ */
4
+ import { CONTROL_PLANE_HEALTH } from "../../control-plane-snapshot.js";
5
+ import { formatCliCommand } from "../../brand/cli.js";
6
+
7
+ export const OVERVIEW_NEED_LIMIT = 3;
8
+
9
+ /** Prefixes that never belong on the first screen (machine / internals). */
10
+ export const DETAILS_ONLY_PREFIXES = [
11
+ "System",
12
+ "Advisor",
13
+ "Soft links",
14
+ "Gentle",
15
+ "Graphify",
16
+ "Hermes"
17
+ ];
18
+
19
+ const DESTINATION_LABELS = {
20
+ setup: "Setup",
21
+ changes: "Governance",
22
+ ides: "Agents",
23
+ runs: "Orchestration",
24
+ "control-center": "Overview",
25
+ activity: "Activity",
26
+ usage: "Usage",
27
+ profile: "Settings"
28
+ };
29
+
30
+ export function mapHealthTone(kind) {
31
+ switch (kind) {
32
+ case CONTROL_PLANE_HEALTH.CHECK_FAILED:
33
+ return "danger";
34
+ case CONTROL_PLANE_HEALTH.ACTION_REQUIRED:
35
+ case CONTROL_PLANE_HEALTH.NOT_CONFIGURED:
36
+ case CONTROL_PLANE_HEALTH.HEALTHY_WITH_NOTES:
37
+ return "warn";
38
+ case CONTROL_PLANE_HEALTH.HEALTHY:
39
+ return "ready";
40
+ default:
41
+ return "warn";
42
+ }
43
+ }
44
+
45
+ /** Plain-language status title — never shouty ALL-CAPS jargon alone. */
46
+ export function humanizeHealthTitle(kind, fallback = "Unknown") {
47
+ switch (kind) {
48
+ case CONTROL_PLANE_HEALTH.NOT_CONFIGURED:
49
+ return "Needs setup";
50
+ case CONTROL_PLANE_HEALTH.ACTION_REQUIRED:
51
+ return "Needs attention";
52
+ case CONTROL_PLANE_HEALTH.CHECK_FAILED:
53
+ return "Something failed";
54
+ case CONTROL_PLANE_HEALTH.HEALTHY_WITH_NOTES:
55
+ return "Ready · with notes";
56
+ case CONTROL_PLANE_HEALTH.HEALTHY:
57
+ return "Ready";
58
+ default:
59
+ return typeof fallback === "string" && fallback ? fallback : "Unknown";
60
+ }
61
+ }
62
+
63
+ export function humanizeDestination(destination) {
64
+ if (!destination) return null;
65
+ return DESTINATION_LABELS[destination] ?? null;
66
+ }
67
+
68
+ /**
69
+ * Turn a raw companion line into a user-facing need, or null to skip.
70
+ * System/machine lines are never returned here.
71
+ */
72
+ export function humanizeCompanionNeed(line) {
73
+ if (typeof line !== "string" || line.trim().length === 0) return null;
74
+ if (/^\s/.test(line)) return null;
75
+
76
+ for (const prefix of DETAILS_ONLY_PREFIXES) {
77
+ if (line.startsWith(prefix)) return null;
78
+ }
79
+
80
+ if (line.startsWith("Obsidian · unconfigured")) {
81
+ return "Obsidian not connected · open Settings to choose your vault";
82
+ }
83
+ if (line.startsWith("Obsidian · missing")) {
84
+ return "Obsidian vault folder missing · check Settings";
85
+ }
86
+ if (line.startsWith("Obsidian · unavailable") || line.startsWith("Obsidian · error")) {
87
+ return "Obsidian unavailable · check Settings";
88
+ }
89
+ if (/^Updates · \d+\s+available/i.test(line)) {
90
+ return `Update available · run ${formatCliCommand("updates check")}`;
91
+ }
92
+ if (line.startsWith("Engram · conflict")) {
93
+ return "Memory conflict · open Settings → Engram";
94
+ }
95
+ if (line.startsWith("Engram · missing") || line.startsWith("Engram · error")) {
96
+ return "Memory not ready · open Settings → Engram";
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /** Partition companion lines: plain needs for Overview, raw rest for Details. */
102
+ export function partitionCompanionLines(lines = []) {
103
+ const needs = [];
104
+ const rest = [];
105
+ for (const line of lines) {
106
+ if (typeof line !== "string" || line.trim().length === 0) continue;
107
+ const need = humanizeCompanionNeed(line);
108
+ if (need && needs.length < OVERVIEW_NEED_LIMIT) {
109
+ needs.push(need);
110
+ continue;
111
+ }
112
+ rest.push(line);
113
+ }
114
+ return { needs, rest };
115
+ }
116
+
117
+ export function humanizePrimary(next = {}) {
118
+ const kind = next.kind;
119
+ const detail = typeof next.actionDetail === "string" && next.actionDetail.trim()
120
+ ? next.actionDetail.trim()
121
+ : null;
122
+ switch (kind) {
123
+ case "setup":
124
+ return {
125
+ label: "Start setup",
126
+ detail: detail ?? `Run ${formatCliCommand("setup")} to configure agents for this project.`,
127
+ hint: "Enter → open Setup"
128
+ };
129
+ case "repair":
130
+ return {
131
+ label: "Fix drift",
132
+ detail: detail ?? `Run ${formatCliCommand("sync")} to repair managed content.`,
133
+ hint: "Enter → preview in Governance"
134
+ };
135
+ case "verify":
136
+ return {
137
+ label: "Check what failed",
138
+ detail: detail ?? `Run ${formatCliCommand("doctor")} for details.`,
139
+ hint: "Enter → reload"
140
+ };
141
+ case "review":
142
+ return {
143
+ label: "Review notes",
144
+ detail: detail ?? "Open Governance to inspect remaining notes.",
145
+ hint: "Enter → open"
146
+ };
147
+ case "idle":
148
+ return {
149
+ label: "All clear",
150
+ detail: detail ?? "No action needed right now.",
151
+ hint: null
152
+ };
153
+ default:
154
+ return {
155
+ label: next.actionTitle ?? "Review control plane",
156
+ detail,
157
+ hint: next.enterHint ?? "Enter →"
158
+ };
159
+ }
160
+ }
@@ -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
+ }