Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 266 additions & 5 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"dependencies": {
"@kibhq/core": "^1.0.0",
"@kibhq/dashboard": "^1.1.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "^5.4.1",
"commander": "^14.0.0",
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/commands/ui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { resolveVaultRoot, VaultNotFoundError } from "@kibhq/core";
import chalk from "chalk";

export async function ui(opts: { port?: string; open?: boolean }) {
let root: string;
try {
root = resolveVaultRoot();
} catch (e) {
if (e instanceof VaultNotFoundError) {
console.error(e.message);
process.exit(1);
}
throw e;
}

// Load saved API keys
const { loadCredentials } = await import("../ui/setup-provider.js");
loadCredentials();

const port = Number.parseInt(opts.port ?? "4848", 10);

const { startServer } = await import("@kibhq/dashboard");
const { url } = await startServer(root, port);

console.log(chalk.bold(`\n kib dashboard running at ${chalk.cyan(url)}\n`));

// Auto-open browser unless --no-open
if (opts.open !== false) {
try {
Bun.spawn(["open", url]);
} catch {
// Non-macOS or open not available — that's fine
}
}

// Keep process alive
await new Promise(() => {});
}
10 changes: 10 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ program
await serve();
});

program
.command("ui")
.description("Launch the local web dashboard")
.option("--port <port>", "server port", "4848")
.option("--no-open", "don't auto-open browser")
.action(async (opts) => {
const { ui } = await import("./commands/ui.js");
await ui(opts);
});

program
.command("mcp [subcommand]")
.description("Configure MCP in AI clients (default: setup)")
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/compile/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface CompileOptions {
onProgress?: (msg: string) => void;
/** Callback fired for each article as it is processed */
onArticle?: (event: ArticleEvent) => void;
/** Signal to abort compilation between sources */
signal?: AbortSignal;
}

// ─── Token estimation ──────────────────────────────────────────
Expand Down Expand Up @@ -511,6 +513,10 @@ async function compileVaultInner(

// Process in batches
for (let i = 0; i < sourcesToCompile.length; i += maxParallel) {
if (options.signal?.aborted) {
options.onProgress?.("Compile aborted.");
break;
}
// Check token budget before starting a new batch
if (maxTokensPerPass && totalInputTokens >= maxTokensPerPass) {
allWarnings.push(
Expand Down Expand Up @@ -580,6 +586,10 @@ async function compileVaultInner(
} else {
// Sequential compilation (original behavior)
for (const [sourceId, sourcePath] of sourcesToCompile) {
if (options.signal?.aborted) {
options.onProgress?.("Compile aborted.");
break;
}
// Check token budget
if (maxTokensPerPass && totalInputTokens >= maxTokensPerPass) {
allWarnings.push(
Expand Down
87 changes: 76 additions & 11 deletions packages/core/src/compile/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,93 @@ export function parseCompileOutput(raw: string): FileOperation[] {

/**
* Extract JSON array from LLM output that may contain surrounding text.
* Handles: code fences, leading/trailing prose, truncated output, nested brackets in strings.
*/
function extractJson(raw: string): string {
let text = raw.trim();

// Strip markdown code fences
text = text.replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
// Strip all markdown code fences (including nested ones wrapping the whole output)
text = text.replace(/^```(?:json)?\s*\n?/gi, "").replace(/\n?```\s*$/gi, "");
text = text.trim();

// If it already starts with [, try it directly
if (text.startsWith("[")) {
return text;
}

// Try to find a JSON array in the text
// Find the first top-level [ and walk to its matching ]
const arrayStart = text.indexOf("[");
const arrayEnd = text.lastIndexOf("]");
if (arrayStart === -1) return text;

let depth = 0;
let inString = false;
let escaped = false;
let arrayEnd = -1;

if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) {
for (let i = arrayStart; i < text.length; i++) {
const ch = text[i];

if (escaped) {
escaped = false;
continue;
}

if (ch === "\\") {
escaped = true;
continue;
}

if (ch === '"') {
inString = !inString;
continue;
}

if (inString) continue;

if (ch === "[" || ch === "{") depth++;
else if (ch === "]" || ch === "}") {
depth--;
if (depth === 0) {
arrayEnd = i;
break;
}
}
}

if (arrayEnd !== -1) {
return text.slice(arrayStart, arrayEnd + 1);
}

// Nothing worked, return as-is and let JSON.parse fail with a clear error
// Truncated output — try to repair by closing open structures
if (depth > 0) {
let repaired = text.slice(arrayStart);
// If we're inside a string, close it
if (inString) repaired += '"';
// Strip any trailing incomplete key-value pair
repaired = repaired.replace(/,\s*"[^"]*"?\s*:?\s*"?[^"]*$/, "");
// Close remaining open structures
// Find what's still open by re-scanning
let s = false;
let esc = false;
const stack: string[] = [];
for (const c of repaired) {
if (esc) {
esc = false;
continue;
}
if (c === "\\") {
esc = true;
continue;
}
if (c === '"') {
s = !s;
continue;
}
if (s) continue;
if (c === "[") stack.push("]");
else if (c === "{") stack.push("}");
else if (c === "]" || c === "}") stack.pop();
}
// Close in reverse order
repaired += stack.reverse().join("");
return repaired;
}

return text;
}

Expand Down
18 changes: 18 additions & 0 deletions packages/dashboard/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kib</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>
37 changes: 37 additions & 0 deletions packages/dashboard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@kibhq/dashboard",
"version": "1.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/server/index.ts"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@kibhq/core": "^1.1.0",
"d3-force": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"lucide-react": "^0.475.0",
"marked": "^15.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.7",
"@types/d3-force": "^3.0.0",
"@types/d3-selection": "^3.0.0",
"@types/d3-zoom": "^3.0.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"typescript": "^5.8.3",
"vite": "^6.3.0"
}
}
5 changes: 5 additions & 0 deletions packages/dashboard/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
41 changes: 41 additions & 0 deletions packages/dashboard/src/client/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "./api.js";
import { BrowsePage } from "./components/BrowsePage.js";
import { GraphPage } from "./components/GraphPage.js";
import { IngestPage } from "./components/IngestPage.js";
import { QueryPage } from "./components/QueryPage.js";
import { SearchPage } from "./components/SearchPage.js";
import { type Page, Shell } from "./components/Shell.js";
import { StatusPage } from "./components/StatusPage.js";
import { useEvents } from "./useEvents.js";

export function App() {
const [page, setPage] = useState<Page>("status");
const [vaultPath, setVaultPath] = useState<string>();
const { revision, lastEvent } = useEvents();

// biome-ignore lint/correctness/useExhaustiveDependencies: revision triggers re-fetch on vault changes
useEffect(() => {
api
.getStatus()
.then((s) => setVaultPath(s.root))
.catch(() => {});
}, [revision]);

const handleNavigateToArticle = useCallback((_path: string) => {
setPage("browse");
}, []);

return (
<Shell currentPage={page} onNavigate={setPage} vaultPath={vaultPath} lastEvent={lastEvent}>
{page === "status" && <StatusPage revision={revision} lastEvent={lastEvent} />}
{page === "browse" && <BrowsePage revision={revision} />}
{page === "search" && <SearchPage onNavigateToArticle={handleNavigateToArticle} />}
{page === "query" && <QueryPage />}
{page === "graph" && (
<GraphPage onNavigateToArticle={handleNavigateToArticle} revision={revision} />
)}
{page === "ingest" && <IngestPage />}
</Shell>
);
}
Loading
Loading