karajan-code 3.1.0 → 3.3.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/README.md +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +21 -1
- package/packages/hu-board/public/utils/formatters.js +68 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +137 -0
- package/packages/hu-board/src/format.js +124 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/agents/aider-agent.js +42 -1
- package/src/agents/claude-agent.js +14 -3
- package/src/agents/codex-agent.js +44 -7
- package/src/agents/gemini-agent.js +37 -1
- package/src/agents/opencode-agent.js +34 -1
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/flow-runner.js +10 -2
- package/src/orchestrator/hu-sub-pipeline.js +82 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/session/journal/summary-writer.js +55 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/budget.js +38 -9
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
- package/src/utils/telemetry.js +43 -0
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v3.
|
|
26
|
+
> **v3.3.0 released** — Cross-provider cache observability (epic KJC-PCS-0056 / Phase 0 closed). Every `kj run` now measures provider-level prompt-cache hits end-to-end across Anthropic, OpenAI/Codex, Gemini, aider and opencode — same `cached_tokens` field flows from agent → BudgetTracker → `summary.md` → `board.db` → HU Board badge `🎯 N%` → telemetry `pipeline_complete`. Real data: Claude cold→hot 47.2% → 94.3% cache_pct (**76.4% cost savings on a single HU**), Gemini 87.9% → 96.8%. Null-safe: badge hides when unmeasured. Drop-in upgrade from v3.2.0. Full notes in [CHANGELOG.md](CHANGELOG.md).
|
|
27
27
|
|
|
28
28
|
You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
|
|
29
29
|
|
|
@@ -121,7 +121,7 @@ pip install .
|
|
|
121
121
|
|
|
122
122
|
Both options expect the `kj` binary to be on your `PATH` (via one of the methods above) — the Python wrapper does not vendor it.
|
|
123
123
|
|
|
124
|
-
That's it. `kj init` auto-detects your installed agents and installs RTK for token optimization.
|
|
124
|
+
That's it. `kj init` auto-detects your installed agents and installs the bundled integrations (RTK + Squeezr for token optimization; QMD for the per-project semantic wiki).
|
|
125
125
|
|
|
126
126
|
### Optional scanners for `kj audit` + `kj webperf`
|
|
127
127
|
|
|
@@ -366,14 +366,22 @@ Karajan auto-detects and auto-configures everything it can:
|
|
|
366
366
|
|
|
367
367
|
No per-project configuration required. If you want to customize, config is layered: session > project > global.
|
|
368
368
|
|
|
369
|
+
## Bundled integrations
|
|
370
|
+
|
|
371
|
+
These ship with Karajan and `kj init` installs them as part of setup. They're token-efficiency layers the pipeline depends on — not nice-to-haves.
|
|
372
|
+
|
|
373
|
+
| Tool | Invoked by | Why |
|
|
374
|
+
|------|-----------|-----|
|
|
375
|
+
| [**RTK**](https://github.com/rtk-ai/rtk) | Karajan (auto, on Bash outputs) | Reduces token consumption by 60-90% on Bash command outputs |
|
|
376
|
+
| [**Squeezr**](https://www.npmjs.com/package/squeezr-ai) | Karajan (auto, on agent tool outputs) | Compresses verbose tool results before they reach the agent context |
|
|
377
|
+
| [**QMD**](https://github.com/manufosela/qmd) | Karajan (`kj init` auto-registers `docs/`, `.reviews/`, `.karajan/plans/`); you (via `kj qmd query`) | Per-project semantic wiki for **humans**. Complements the **agent-facing** RAG index — `kj rag query` is what the pipeline reads; `kj qmd query` is what you read |
|
|
378
|
+
|
|
369
379
|
## Recommended companions
|
|
370
380
|
|
|
371
381
|
None of these are required. Karajan runs fine on its own. They're tools that, when present, Karajan can take advantage of — or that help *you* work better around Karajan.
|
|
372
382
|
|
|
373
383
|
| Tool | Invoked by | Why |
|
|
374
384
|
|------|-----------|-----|
|
|
375
|
-
| [**RTK**](https://github.com/rtk-ai/rtk) | Karajan (auto, on Bash outputs) | Reduces token consumption by 60-90% on Bash command outputs |
|
|
376
|
-
| [**QMD**](https://github.com/manufosela/qmd) | You (CLI / MCP), complementary to RAG | Semantic search engine over Markdown corpora — works alongside `kj rag query` when you want a richer index over your own docs |
|
|
377
385
|
| [**GitHub MCP**](https://github.com/github/github-mcp-server) | Your AI agent (via MCP) | Create PRs, manage issues directly from the agent |
|
|
378
386
|
| [**Chrome DevTools MCP**](https://github.com/ChromeDevTools/chrome-devtools-mcp) | Your AI agent (via MCP) | Verify UI changes visually after frontend modifications |
|
|
379
387
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "karajan-code",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=22.22.1"
|
|
28
28
|
},
|
|
29
|
+
"workspaces": [
|
|
30
|
+
"packages/ai-trash",
|
|
31
|
+
"packages/core",
|
|
32
|
+
"packages/hu-board"
|
|
33
|
+
],
|
|
29
34
|
"imports": {
|
|
30
35
|
"#utils/*": "./src/utils/*",
|
|
31
36
|
"#session/*": "./src/session/*",
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
"test": "vitest run"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
+
"@karajan/core": "*",
|
|
16
17
|
"better-sqlite3": "^12.10.0",
|
|
17
18
|
"chokidar": "^5.0.0",
|
|
18
19
|
"express": "^5.1.0",
|
|
19
20
|
"express-rate-limit": "^8.5.0",
|
|
20
|
-
"helmet": "^8.1.0"
|
|
21
|
+
"helmet": "^8.1.0",
|
|
22
|
+
"js-yaml": "^4.2.0"
|
|
21
23
|
},
|
|
22
24
|
"devDependencies": {
|
|
23
25
|
"supertest": "^7.1.0",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<button class="nav-btn" data-view="sessions" title="Every kj run that has touched the board, newest first. Filter by project; click a session to inspect its iterations, commits and checkpoints.">Sessions</button>
|
|
27
27
|
<a class="nav-btn" href="/pipeline.html" title="Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs.">Pipeline</a>
|
|
28
28
|
<a class="nav-btn" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
|
|
29
|
+
<a class="nav-btn" href="/wiki.html" title="Search the per-project QMD semantic wiki (docs, plans, reviews).">Wiki</a>
|
|
29
30
|
<select class="project-select" id="project-select">
|
|
30
31
|
<option value="">All Projects</option>
|
|
31
32
|
</select>
|
|
@@ -244,6 +244,17 @@ a:hover {
|
|
|
244
244
|
font-family: var(--font-mono);
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
.section-header__cost {
|
|
248
|
+
font-size: 0.8rem;
|
|
249
|
+
color: var(--text-muted);
|
|
250
|
+
font-family: var(--font-mono);
|
|
251
|
+
padding: 2px 8px;
|
|
252
|
+
border: 1px solid var(--border);
|
|
253
|
+
border-radius: var(--radius-sm);
|
|
254
|
+
background: var(--bg-secondary, transparent);
|
|
255
|
+
cursor: help;
|
|
256
|
+
}
|
|
257
|
+
|
|
247
258
|
/* ---- Kanban Board ---- */
|
|
248
259
|
.kanban {
|
|
249
260
|
display: grid;
|
|
@@ -341,6 +352,12 @@ a:hover {
|
|
|
341
352
|
.story-card__score--ok { color: var(--color-yellow); }
|
|
342
353
|
.story-card__score--bad { color: var(--color-red); }
|
|
343
354
|
|
|
355
|
+
.story-card__cost {
|
|
356
|
+
font-family: var(--font-mono);
|
|
357
|
+
font-size: 0.75rem;
|
|
358
|
+
color: var(--text-muted);
|
|
359
|
+
}
|
|
360
|
+
|
|
344
361
|
.quality-bar {
|
|
345
362
|
display: inline-flex;
|
|
346
363
|
gap: 1px;
|
|
@@ -939,3 +956,18 @@ body.scoped-mode .header__nav a[href="/pipeline.html"] {
|
|
|
939
956
|
.rag-bar__label { color: var(--text-secondary, #cbd5e1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
940
957
|
.rag-bar__fill { display: block; height: 14px; background: linear-gradient(90deg, #7c3aed, #a78bfa); border-radius: 3px; }
|
|
941
958
|
.rag-bar__value { text-align: right; color: var(--text-primary, #f8fafc); font-variant-numeric: tabular-nums; }
|
|
959
|
+
|
|
960
|
+
/* KJC-TSK-0508 — Wiki panel (QMD semantic search) */
|
|
961
|
+
.wiki-search { padding: 1rem; }
|
|
962
|
+
.wiki-form { display: grid; grid-template-columns: 1fr 220px auto auto; gap: .5rem; align-items: center; }
|
|
963
|
+
.wiki-form input[type="search"], .wiki-form input[type="text"] { padding: .5rem .75rem; border-radius: 6px; border: 1px solid var(--border, #334155); background: var(--bg-secondary, #1e1e2e); color: var(--text-primary, #f8fafc); font-size: .9rem; }
|
|
964
|
+
.wiki-fast { display: flex; align-items: center; gap: .35rem; font-size: .85rem; color: var(--text-secondary, #94a3b8); }
|
|
965
|
+
.wiki-hint { margin: .5rem 0 0; font-size: .8rem; color: var(--text-secondary, #94a3b8); font-style: italic; }
|
|
966
|
+
.wiki-results { display: grid; gap: .75rem; padding: 0 1rem 1rem; }
|
|
967
|
+
.wiki-hit { background: var(--bg-secondary, #1e1e2e); border-radius: 8px; padding: .9rem 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
968
|
+
.wiki-hit__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: .4rem; }
|
|
969
|
+
.wiki-hit__src { font-family: ui-monospace, monospace; font-size: .85rem; color: #a78bfa; }
|
|
970
|
+
.wiki-hit__score { font-size: .75rem; color: var(--text-secondary, #94a3b8); font-variant-numeric: tabular-nums; }
|
|
971
|
+
.wiki-hit__snip { margin: 0; font-family: ui-monospace, monospace; font-size: .82rem; line-height: 1.45; color: var(--text-primary, #f8fafc); white-space: pre-wrap; word-break: break-word; }
|
|
972
|
+
.wiki-code { background: #0f172a; color: #e2e8f0; padding: .5rem .75rem; border-radius: 6px; font-family: ui-monospace, monospace; font-size: .85rem; margin: .5rem 0; }
|
|
973
|
+
@media (max-width: 720px) { .wiki-form { grid-template-columns: 1fr; } }
|
|
@@ -35,7 +35,14 @@ async function renderBoard() {
|
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const stories = await
|
|
38
|
+
const [stories, projectCost] = await Promise.all([
|
|
39
|
+
api(`/api/projects/${encodeURIComponent(selectedProject)}/stories`),
|
|
40
|
+
api(`/api/projects/${encodeURIComponent(selectedProject)}/cost`).catch(() => null),
|
|
41
|
+
]);
|
|
42
|
+
const costSummary = projectCost ? formatProjectCostSummary(projectCost) : null;
|
|
43
|
+
// Φ0-G (KJC-TSK-0525): aggregated cache-hit badge at the project
|
|
44
|
+
// header, rendered alongside the project cost chip.
|
|
45
|
+
const cacheSummary = projectCost ? formatCacheRatio(projectCost.cachedTokens, projectCost.tokensIn) : null;
|
|
39
46
|
|
|
40
47
|
// Pre-resolve project initials + name for every distinct project_id in
|
|
41
48
|
// the fetched stories so `renderStoryCard` is synchronous and the header
|
|
@@ -96,6 +103,8 @@ async function renderBoard() {
|
|
|
96
103
|
onclick="event.stopPropagation(); window.renameProjectModal('${esc(selectedProject)}', '${esc(projectDisplayName.replace(/'/g, '''))}');">✎</button>
|
|
97
104
|
` : ''}
|
|
98
105
|
<span class="section-header__count">${stories.length} stories</span>
|
|
106
|
+
${costSummary ? `<span class="section-header__cost" title="${esc(costSummary.tooltip)}">💵 ${esc(costSummary.label)}</span>` : ''}
|
|
107
|
+
${cacheSummary ? `<span class="section-header__cache" title="${esc(cacheSummary.tooltip)}">${esc(cacheSummary.label)}</span>` : ''}
|
|
99
108
|
${isRunning ? `
|
|
100
109
|
<button id="running-badge-btn" class="section-header__badge"
|
|
101
110
|
style="margin-left:auto;padding:4px 10px;font-size:0.8rem;background:var(--color-yellow,#eab308);color:#000;border-radius:var(--radius-sm);font-weight:600;border:none;cursor:pointer;"
|
|
@@ -372,6 +381,17 @@ function renderStoryCard(story) {
|
|
|
372
381
|
${story.quality_total}/60 ${qualityBar(story.quality_total)}
|
|
373
382
|
</span>
|
|
374
383
|
` : ''}
|
|
384
|
+
${(() => {
|
|
385
|
+
const c = formatCost(story.cost_usd);
|
|
386
|
+
return c ? `<span class="story-card__cost" title="${esc(c.tooltip)}">💵 ${esc(c.label)}</span>` : '';
|
|
387
|
+
})()}
|
|
388
|
+
${(() => {
|
|
389
|
+
// Φ0-G (KJC-TSK-0525): per-HU cache hit badge. Rendered next to
|
|
390
|
+
// the cost so users can correlate "this run cost $X and 73% of
|
|
391
|
+
// input was served from cache" in one glance.
|
|
392
|
+
const r = formatCacheRatio(story.cached_tokens, story.tokens_in);
|
|
393
|
+
return r ? `<span class="story-card__cache" title="${esc(r.tooltip)}">${esc(r.label)}</span>` : '';
|
|
394
|
+
})()}
|
|
375
395
|
</div>
|
|
376
396
|
${missingTestContract ? `
|
|
377
397
|
<div class="story-card__meta" style="margin-top:4px;font-size:0.75rem;color:var(--color-yellow,#eab308);font-weight:600" title="This HU has no acceptance_tests declared — Run plan will reject it until you add at least one.">
|
|
@@ -45,6 +45,74 @@ function formatSessionLabel(session) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function formatCost(costUsd) {
|
|
49
|
+
if (costUsd === null || costUsd === undefined) return null;
|
|
50
|
+
const n = Number(costUsd);
|
|
51
|
+
if (!Number.isFinite(n)) return null;
|
|
52
|
+
return {
|
|
53
|
+
label: `$${n.toFixed(2)}`,
|
|
54
|
+
tooltip: `Estimated cost: $${n.toFixed(4)}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Φ0-G (KJC-TSK-0525): mirror of formatCacheRatio in src/format.js.
|
|
59
|
+
// Kept duplicated because this file is a classic script loaded into
|
|
60
|
+
// the browser, while src/format.js uses ES modules and is consumed by
|
|
61
|
+
// Node tests. See KJC-TSK-0501 header notes above.
|
|
62
|
+
function formatCacheRatio(cachedTokens, tokensIn) {
|
|
63
|
+
if (cachedTokens === null || cachedTokens === undefined) return null;
|
|
64
|
+
if (tokensIn === null || tokensIn === undefined) return null;
|
|
65
|
+
const c = Number(cachedTokens);
|
|
66
|
+
const t = Number(tokensIn);
|
|
67
|
+
if (!Number.isFinite(c) || !Number.isFinite(t)) return null;
|
|
68
|
+
if (t <= 0 || c < 0) return null;
|
|
69
|
+
const pct = Math.round((c / t) * 1000) / 10;
|
|
70
|
+
const num = c.toLocaleString("en-US");
|
|
71
|
+
const den = t.toLocaleString("en-US");
|
|
72
|
+
return {
|
|
73
|
+
label: `🎯 ${pct}%`,
|
|
74
|
+
tooltip: `Cache hits: ${num} / ${den} input tokens (${pct}%)`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatProjectCostSummary(cost) {
|
|
79
|
+
if (!cost || typeof cost !== "object") return null;
|
|
80
|
+
const total = Number(cost.totalUsd);
|
|
81
|
+
if (!Number.isFinite(total)) return null;
|
|
82
|
+
const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
|
|
83
|
+
if (total === 0 && byPlan.length === 0) return null;
|
|
84
|
+
|
|
85
|
+
const lines = [`Total: $${total.toFixed(4)}`];
|
|
86
|
+
// Φ0-G (KJC-TSK-0525): aggregate cache hits line + per-plan suffix.
|
|
87
|
+
const projCache = formatCacheRatio(cost.cachedTokens, cost.tokensIn);
|
|
88
|
+
if (projCache) lines.push(`Cache: ${projCache.tooltip.split(": ")[1]}`);
|
|
89
|
+
if (byPlan.length > 0) {
|
|
90
|
+
lines.push("By plan:");
|
|
91
|
+
for (const p of byPlan) {
|
|
92
|
+
const planTotal = Number(p?.totalUsd);
|
|
93
|
+
if (!Number.isFinite(planTotal)) continue;
|
|
94
|
+
const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
|
|
95
|
+
const planLabel = p?.planId || "unassigned";
|
|
96
|
+
const cachePct = p?.cachedRatioPct;
|
|
97
|
+
const cacheSuffix = (cachePct !== null && cachePct !== undefined && Number.isFinite(Number(cachePct)))
|
|
98
|
+
? ` 🎯 ${cachePct}%`
|
|
99
|
+
: "";
|
|
100
|
+
lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})${cacheSuffix}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const unk = cost.unknownModelTokens;
|
|
104
|
+
if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
|
|
105
|
+
const unkTotal = unk.tokensIn + unk.tokensOut;
|
|
106
|
+
if (unkTotal > 0) {
|
|
107
|
+
lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
label: `Total: $${total.toFixed(2)}`,
|
|
112
|
+
tooltip: lines.join("\n"),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
48
116
|
function humaniseProjectName(id) {
|
|
49
117
|
if (!id || typeof id !== 'string') return id || '';
|
|
50
118
|
const tail = id.split(/[/_]/).filter(Boolean).pop() || id;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Karajan Wiki · QMD semantic search</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="header">
|
|
11
|
+
<div class="header__logo"><div><div class="header__title">Karajan Wiki</div><div class="header__subtitle">QMD semantic search</div></div></div>
|
|
12
|
+
<nav class="header__nav">
|
|
13
|
+
<a class="nav-btn" href="/">← Board</a>
|
|
14
|
+
<a class="nav-btn" href="/rag.html">RAG</a>
|
|
15
|
+
<button class="nav-btn active">Wiki</button>
|
|
16
|
+
</nav>
|
|
17
|
+
</header>
|
|
18
|
+
<main class="main" id="wiki-app">
|
|
19
|
+
<section class="wiki-search">
|
|
20
|
+
<form id="wiki-form" class="wiki-form" autocomplete="off">
|
|
21
|
+
<input id="wiki-q" type="search" placeholder="Search the project wiki…" aria-label="Query" required>
|
|
22
|
+
<input id="wiki-collection" type="text" placeholder="collection (optional)" aria-label="Collection">
|
|
23
|
+
<label class="wiki-fast"><input id="wiki-fast" type="checkbox"> fast (no rerank)</label>
|
|
24
|
+
<button type="submit" class="control-btn" id="wiki-go">Search</button>
|
|
25
|
+
</form>
|
|
26
|
+
<p class="wiki-hint">QMD runs locally — 0 tokens, ~60s with rerank, ~5s in fast mode.</p>
|
|
27
|
+
</section>
|
|
28
|
+
<section id="wiki-results" class="wiki-results"></section>
|
|
29
|
+
</main>
|
|
30
|
+
<script src="/wiki.js"></script>
|
|
31
|
+
</body>
|
|
32
|
+
</html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// KJC-TSK-0508 — HU Board Wiki panel frontend. Calls POST /api/wiki/query
|
|
2
|
+
// (server-side spawns `qmd query --format json`) and renders the hits.
|
|
3
|
+
// A 503 with `installable:true` triggers an "Install QMD" CTA instead of
|
|
4
|
+
// a generic error so the user can act on the missing binary.
|
|
5
|
+
const TOKEN = new URLSearchParams(location.search).get("token") || localStorage.getItem("kj_board_token") || "";
|
|
6
|
+
const HEADERS = { "Content-Type": "application/json", ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}) };
|
|
7
|
+
const ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
8
|
+
|
|
9
|
+
function renderInstallCTA(root) {
|
|
10
|
+
root.innerHTML = `<div class="rag-card rag-card--error">
|
|
11
|
+
<h3>QMD not installed</h3>
|
|
12
|
+
<p>The semantic wiki engine is not available on this host. Install it once:</p>
|
|
13
|
+
<pre class="wiki-code">npm install -g @tobilu/qmd</pre>
|
|
14
|
+
<p class="rag-stat-sub">Then re-run <code>kj init</code> in this project to register the wiki collection.</p>
|
|
15
|
+
</div>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderHits(root, results) {
|
|
19
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
20
|
+
root.innerHTML = '<p class="rag-empty">no hits — try a broader query or run <code>qmd update</code>.</p>';
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
root.innerHTML = results.map((r) => {
|
|
24
|
+
const src = r.source || r.path || r.file || "(unknown source)";
|
|
25
|
+
const snip = r.snippet || r.text || r.content || "";
|
|
26
|
+
const score = typeof r.score === "number" ? r.score.toFixed(3) : "";
|
|
27
|
+
return `<article class="wiki-hit">
|
|
28
|
+
<header class="wiki-hit__head"><span class="wiki-hit__src">${ESC(src)}</span>${score ? `<span class="wiki-hit__score">${ESC(score)}</span>` : ""}</header>
|
|
29
|
+
<pre class="wiki-hit__snip">${ESC(snip)}</pre>
|
|
30
|
+
</article>`;
|
|
31
|
+
}).join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runSearch(ev) {
|
|
35
|
+
ev?.preventDefault();
|
|
36
|
+
const root = document.getElementById("wiki-results");
|
|
37
|
+
const query = document.getElementById("wiki-q").value.trim();
|
|
38
|
+
if (!query) return;
|
|
39
|
+
const collection = document.getElementById("wiki-collection").value.trim() || undefined;
|
|
40
|
+
const fast = document.getElementById("wiki-fast").checked;
|
|
41
|
+
root.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Searching the wiki…</p></div>';
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch("/api/wiki/query", { method: "POST", headers: HEADERS, body: JSON.stringify({ query, collection, fast }) });
|
|
44
|
+
const data = await res.json().catch(() => ({}));
|
|
45
|
+
if (res.status === 503 && data.installable) { renderInstallCTA(root); return; }
|
|
46
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
47
|
+
renderHits(root, data.results);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
root.innerHTML = `<div class="rag-card rag-card--error"><strong>Error:</strong> ${ESC(err.message || String(err))}</div>`;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
document.getElementById("wiki-form").addEventListener("submit", runSearch);
|
|
@@ -323,6 +323,18 @@ export function initDb() {
|
|
|
323
323
|
// unconditionally so solo devs who later promote a plan don't lose
|
|
324
324
|
// values they typed pre-share.
|
|
325
325
|
try { db.exec('ALTER TABLE stories ADD COLUMN assignee TEXT'); } catch { /* already migrated */ }
|
|
326
|
+
// KJC-TSK-0514 (Cost C): persisted USD cost of running this HU. NULL
|
|
327
|
+
// until the orchestrator (Cost D) calls setStoryCost on HU close. UI
|
|
328
|
+
// renders "—" while NULL. Stored as REAL so the SQL aggregations in
|
|
329
|
+
// Cost E (/api/projects/:id/cost) can SUM directly without parsing.
|
|
330
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN cost_usd REAL'); } catch { /* already migrated */ }
|
|
331
|
+
// KJC-TSK-0525 (Φ0-G): persisted cached/input tokens per HU so the
|
|
332
|
+
// board can render the "🎯 N% cached" badge without re-parsing the
|
|
333
|
+
// session summary. NULL = unmeasured (older runs, cold runs without
|
|
334
|
+
// cache support). INTEGER because providers report token counts as
|
|
335
|
+
// ints. Ratio is computed at read time as cached_tokens / tokens_in.
|
|
336
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN cached_tokens INTEGER'); } catch { /* already migrated */ }
|
|
337
|
+
try { db.exec('ALTER TABLE stories ADD COLUMN tokens_in INTEGER'); } catch { /* already migrated */ }
|
|
326
338
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
|
|
327
339
|
|
|
328
340
|
// is_test (KJC-TSK-0371 — board polish #3): per-project flag that
|
|
@@ -499,6 +511,61 @@ export function updateStoryStatus(storyId, status) {
|
|
|
499
511
|
return res.changes > 0;
|
|
500
512
|
}
|
|
501
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Persist the USD cost of a HU. Called by the orchestrator (Cost D) once
|
|
516
|
+
* the HU finishes — the value comes from `aggregateRunCost(events).totalUsd`
|
|
517
|
+
* (Cost B). `null` clears the cell so the UI renders "—" again. Other
|
|
518
|
+
* numeric inputs are coerced via `Number`; non-finite values reject with
|
|
519
|
+
* an explicit throw so callers don't silently NULL-out real cost rows.
|
|
520
|
+
*
|
|
521
|
+
* @param {string} storyId
|
|
522
|
+
* @param {number|null} costUsd
|
|
523
|
+
* @returns {boolean} true when the row existed and was updated
|
|
524
|
+
*/
|
|
525
|
+
export function setStoryCost(storyId, costUsd) {
|
|
526
|
+
let value = null;
|
|
527
|
+
if (costUsd !== null && costUsd !== undefined) {
|
|
528
|
+
const n = Number(costUsd);
|
|
529
|
+
if (!Number.isFinite(n)) {
|
|
530
|
+
throw new Error(`setStoryCost: costUsd must be a finite number or null (got ${costUsd})`);
|
|
531
|
+
}
|
|
532
|
+
value = n;
|
|
533
|
+
}
|
|
534
|
+
const res = getDb()
|
|
535
|
+
.prepare('UPDATE stories SET cost_usd = ?, updated_at = ? WHERE id = ?')
|
|
536
|
+
.run(value, new Date().toISOString(), storyId);
|
|
537
|
+
return res.changes > 0;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Persist cached/input tokens for a HU (KJC-TSK-0525, Φ0-G). Called by
|
|
542
|
+
* the orchestrator alongside `setStoryCost` once the HU finishes. Both
|
|
543
|
+
* values come from `BudgetTracker.since(huCursor)` (Φ0-E). Pass `null`
|
|
544
|
+
* to clear either cell. Non-finite numbers throw so callers don't
|
|
545
|
+
* silently null-out real telemetry.
|
|
546
|
+
*
|
|
547
|
+
* @param {string} storyId
|
|
548
|
+
* @param {number|null} cachedTokens
|
|
549
|
+
* @param {number|null} tokensIn
|
|
550
|
+
* @returns {boolean} true when the row existed and was updated
|
|
551
|
+
*/
|
|
552
|
+
export function setStoryCachedTokens(storyId, cachedTokens, tokensIn) {
|
|
553
|
+
const coerce = (v, name) => {
|
|
554
|
+
if (v === null || v === undefined) return null;
|
|
555
|
+
const n = Number(v);
|
|
556
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
557
|
+
throw new Error(`setStoryCachedTokens: ${name} must be a non-negative finite number or null (got ${v})`);
|
|
558
|
+
}
|
|
559
|
+
return Math.round(n);
|
|
560
|
+
};
|
|
561
|
+
const cached = coerce(cachedTokens, 'cachedTokens');
|
|
562
|
+
const tin = coerce(tokensIn, 'tokensIn');
|
|
563
|
+
const res = getDb()
|
|
564
|
+
.prepare('UPDATE stories SET cached_tokens = ?, tokens_in = ?, updated_at = ? WHERE id = ?')
|
|
565
|
+
.run(cached, tin, new Date().toISOString(), storyId);
|
|
566
|
+
return res.changes > 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
502
569
|
/**
|
|
503
570
|
* List every distinct `plan_id` stamped on the stories of a given project.
|
|
504
571
|
* Used by "mark plan ready" so the board can act on each plan of a project
|
|
@@ -598,6 +665,76 @@ export function getStoriesByProject(projectId) {
|
|
|
598
665
|
`).all(projectId);
|
|
599
666
|
}
|
|
600
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Aggregate $ cost for a project (KJC-TSK-0516, Cost E).
|
|
670
|
+
* Sums stories.cost_usd, groups by plan_id ('unassigned' for NULL).
|
|
671
|
+
* Returns null if project does not exist; otherwise an object with
|
|
672
|
+
* totalUsd=0 and byPlan=[] when no stories carry cost_usd yet.
|
|
673
|
+
* @param {string} projectId
|
|
674
|
+
* @returns {{ totalUsd:number, byPlan:Array<{planId:string,totalUsd:number,huCount:number}>, unknownModelTokens:object, currency:string } | null}
|
|
675
|
+
*/
|
|
676
|
+
export function getProjectCost(projectId) {
|
|
677
|
+
const project = getDb()
|
|
678
|
+
.prepare('SELECT id FROM projects WHERE id = ?')
|
|
679
|
+
.get(projectId);
|
|
680
|
+
if (!project) return null;
|
|
681
|
+
|
|
682
|
+
// Φ0-G: cached_tokens / tokens_in are nullable independently of
|
|
683
|
+
// cost_usd, so we read all three columns but keep cost as the
|
|
684
|
+
// primary filter (a row with only cache telemetry but no $ should
|
|
685
|
+
// not appear in the per-plan totals — cost rules the byPlan list).
|
|
686
|
+
const rows = getDb()
|
|
687
|
+
.prepare(
|
|
688
|
+
`SELECT plan_id, cost_usd, cached_tokens, tokens_in FROM stories
|
|
689
|
+
WHERE project_id = ? AND cost_usd IS NOT NULL`
|
|
690
|
+
)
|
|
691
|
+
.all(projectId);
|
|
692
|
+
|
|
693
|
+
const round4 = (n) => Math.round(n * 10000) / 10000;
|
|
694
|
+
const byPlanMap = new Map();
|
|
695
|
+
let totalUsd = 0;
|
|
696
|
+
let totalCached = 0;
|
|
697
|
+
let totalTokensIn = 0;
|
|
698
|
+
for (const r of rows) {
|
|
699
|
+
const planId = r.plan_id || 'unassigned';
|
|
700
|
+
const cost = Number(r.cost_usd);
|
|
701
|
+
if (!Number.isFinite(cost)) continue;
|
|
702
|
+
totalUsd += cost;
|
|
703
|
+
const cached = Number(r.cached_tokens);
|
|
704
|
+
const tin = Number(r.tokens_in);
|
|
705
|
+
if (Number.isFinite(cached) && cached >= 0) totalCached += cached;
|
|
706
|
+
if (Number.isFinite(tin) && tin >= 0) totalTokensIn += tin;
|
|
707
|
+
const cur = byPlanMap.get(planId) || { planId, totalUsd: 0, huCount: 0, cachedTokens: 0, tokensIn: 0 };
|
|
708
|
+
cur.totalUsd += cost;
|
|
709
|
+
cur.huCount += 1;
|
|
710
|
+
if (Number.isFinite(cached) && cached >= 0) cur.cachedTokens += cached;
|
|
711
|
+
if (Number.isFinite(tin) && tin >= 0) cur.tokensIn += tin;
|
|
712
|
+
byPlanMap.set(planId, cur);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const ratio = (cached, tin) => tin > 0 ? Math.round((cached / tin) * 1000) / 10 : null;
|
|
716
|
+
const byPlan = Array.from(byPlanMap.values())
|
|
717
|
+
.map((p) => ({
|
|
718
|
+
planId: p.planId,
|
|
719
|
+
totalUsd: round4(p.totalUsd),
|
|
720
|
+
huCount: p.huCount,
|
|
721
|
+
cachedTokens: p.cachedTokens,
|
|
722
|
+
tokensIn: p.tokensIn,
|
|
723
|
+
cachedRatioPct: ratio(p.cachedTokens, p.tokensIn),
|
|
724
|
+
}))
|
|
725
|
+
.sort((a, b) => b.totalUsd - a.totalUsd);
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
totalUsd: round4(totalUsd),
|
|
729
|
+
byPlan,
|
|
730
|
+
cachedTokens: totalCached,
|
|
731
|
+
tokensIn: totalTokensIn,
|
|
732
|
+
cachedRatioPct: ratio(totalCached, totalTokensIn),
|
|
733
|
+
unknownModelTokens: { tokensIn: 0, tokensOut: 0, models: [] },
|
|
734
|
+
currency: 'USD',
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
601
738
|
/**
|
|
602
739
|
* Returns full story detail including context requests.
|
|
603
740
|
* @param {string} storyId
|
|
@@ -60,6 +60,130 @@ export function shortTask(text, max = MAX_TASK_CHARS) {
|
|
|
60
60
|
* created_at?: string|null }} session
|
|
61
61
|
* @returns {{ title: string, subtitle: string, idChip: string }}
|
|
62
62
|
*/
|
|
63
|
+
/**
|
|
64
|
+
* Format a per-HU cost (USD) for the card badge. Cost F (KJC-TSK-0517).
|
|
65
|
+
*
|
|
66
|
+
* Two outputs in one call so the UI can render both at once:
|
|
67
|
+
* - `label`: 2-decimal "$X.XX" shown on the card (compact).
|
|
68
|
+
* - `tooltip`: 4-decimal "Estimated cost: $X.XXXX" shown on hover
|
|
69
|
+
* (matches the precision aggregated by Cost B/C/D, see
|
|
70
|
+
* `packages/core/src/cost/index.js`).
|
|
71
|
+
*
|
|
72
|
+
* Returns `null` for null/undefined/non-finite inputs so the caller
|
|
73
|
+
* skips the badge entirely (rendering "$0.00" for an unknown cost
|
|
74
|
+
* would be misleading — there is a real difference between "we know it
|
|
75
|
+
* was free" and "we haven't measured it yet").
|
|
76
|
+
*
|
|
77
|
+
* @param {number|null|undefined} costUsd
|
|
78
|
+
* @returns {{ label: string, tooltip: string } | null}
|
|
79
|
+
*/
|
|
80
|
+
export function formatCost(costUsd) {
|
|
81
|
+
if (costUsd === null || costUsd === undefined) return null;
|
|
82
|
+
const n = Number(costUsd);
|
|
83
|
+
if (!Number.isFinite(n)) return null;
|
|
84
|
+
return {
|
|
85
|
+
label: `$${n.toFixed(2)}`,
|
|
86
|
+
tooltip: `Estimated cost: $${n.toFixed(4)}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Φ0-G (KJC-TSK-0525): per-HU and per-plan cached-tokens badge.
|
|
92
|
+
*
|
|
93
|
+
* Returns `{ label, tooltip }` for "🎯 23%" with a tooltip showing the
|
|
94
|
+
* exact numerator/denominator. Same "don't render 0% when there is no
|
|
95
|
+
* data" stance as formatCost — returns null when:
|
|
96
|
+
* - either arg is null/undefined/non-finite,
|
|
97
|
+
* - tokens_in is 0 (no input → ratio undefined, not zero),
|
|
98
|
+
* - cached is negative (would only happen from corrupted telemetry).
|
|
99
|
+
*
|
|
100
|
+
* The badge intentionally shows 1 decimal to keep narrow runs (e.g.
|
|
101
|
+
* a 73-token cache out of 1 200) readable without rounding to 0%.
|
|
102
|
+
*
|
|
103
|
+
* @param {number|null|undefined} cachedTokens
|
|
104
|
+
* @param {number|null|undefined} tokensIn
|
|
105
|
+
* @returns {{ label: string, tooltip: string } | null}
|
|
106
|
+
*/
|
|
107
|
+
export function formatCacheRatio(cachedTokens, tokensIn) {
|
|
108
|
+
// Reject null/undefined explicitly — Number(null) coerces to 0,
|
|
109
|
+
// which would otherwise render an honest-looking "🎯 0%" badge for
|
|
110
|
+
// runs where we simply have no telemetry. Same stance as formatCost.
|
|
111
|
+
if (cachedTokens === null || cachedTokens === undefined) return null;
|
|
112
|
+
if (tokensIn === null || tokensIn === undefined) return null;
|
|
113
|
+
const c = Number(cachedTokens);
|
|
114
|
+
const t = Number(tokensIn);
|
|
115
|
+
if (!Number.isFinite(c) || !Number.isFinite(t)) return null;
|
|
116
|
+
if (t <= 0 || c < 0) return null;
|
|
117
|
+
const pct = Math.round((c / t) * 1000) / 10;
|
|
118
|
+
const num = c.toLocaleString("en-US");
|
|
119
|
+
const den = t.toLocaleString("en-US");
|
|
120
|
+
return {
|
|
121
|
+
label: `🎯 ${pct}%`,
|
|
122
|
+
tooltip: `Cache hits: ${num} / ${den} input tokens (${pct}%)`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Format the aggregated project-level cost chip rendered in the board
|
|
128
|
+
* header. Cost G (KJC-TSK-0518). Consumes the shape returned by
|
|
129
|
+
* `GET /api/projects/:id/cost` (Cost E):
|
|
130
|
+
* { totalUsd, byPlan: [{planId, totalUsd, huCount}, …],
|
|
131
|
+
* unknownModelTokens: { tokensIn, tokensOut, models }, currency }
|
|
132
|
+
*
|
|
133
|
+
* Returns `{ label, tooltip }` where:
|
|
134
|
+
* - `label` is the compact "Total: $X.XX" shown in the header.
|
|
135
|
+
* - `tooltip` is a multi-line string with full precision, the
|
|
136
|
+
* per-plan breakdown, and a note when some tokens used a model we
|
|
137
|
+
* don't price (they aren't included in the total — surfacing the
|
|
138
|
+
* skip is more honest than silently swallowing it).
|
|
139
|
+
*
|
|
140
|
+
* Returns `null` when there is nothing to show (no input, or no
|
|
141
|
+
* measured cost yet and no plans). The chip is then hidden — same
|
|
142
|
+
* reasoning as `formatCost`: "$0.00" with no data is misleading.
|
|
143
|
+
*
|
|
144
|
+
* @param {{totalUsd:number, byPlan?:Array, unknownModelTokens?:object}|null|undefined} cost
|
|
145
|
+
* @returns {{ label: string, tooltip: string } | null}
|
|
146
|
+
*/
|
|
147
|
+
export function formatProjectCostSummary(cost) {
|
|
148
|
+
if (!cost || typeof cost !== "object") return null;
|
|
149
|
+
const total = Number(cost.totalUsd);
|
|
150
|
+
if (!Number.isFinite(total)) return null;
|
|
151
|
+
const byPlan = Array.isArray(cost.byPlan) ? cost.byPlan : [];
|
|
152
|
+
if (total === 0 && byPlan.length === 0) return null;
|
|
153
|
+
|
|
154
|
+
const lines = [`Total: $${total.toFixed(4)}`];
|
|
155
|
+
// Φ0-G (KJC-TSK-0525): when the API includes project-level cache
|
|
156
|
+
// counters, show the aggregate cached% line right under the total
|
|
157
|
+
// so users can see at a glance how much was served from cache.
|
|
158
|
+
const projCache = formatCacheRatio(cost.cachedTokens, cost.tokensIn);
|
|
159
|
+
if (projCache) lines.push(`Cache: ${projCache.tooltip.split(": ")[1]}`);
|
|
160
|
+
if (byPlan.length > 0) {
|
|
161
|
+
lines.push("By plan:");
|
|
162
|
+
for (const p of byPlan) {
|
|
163
|
+
const planTotal = Number(p?.totalUsd);
|
|
164
|
+
if (!Number.isFinite(planTotal)) continue;
|
|
165
|
+
const huCount = Number.isFinite(p?.huCount) ? p.huCount : 0;
|
|
166
|
+
const planLabel = p?.planId || "unassigned";
|
|
167
|
+
const cachePct = p?.cachedRatioPct;
|
|
168
|
+
const cacheSuffix = (cachePct !== null && cachePct !== undefined && Number.isFinite(Number(cachePct)))
|
|
169
|
+
? ` 🎯 ${cachePct}%`
|
|
170
|
+
: "";
|
|
171
|
+
lines.push(` ${planLabel}: $${planTotal.toFixed(2)} (${huCount} HU${huCount === 1 ? "" : "s"})${cacheSuffix}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const unk = cost.unknownModelTokens;
|
|
175
|
+
if (unk && Number.isFinite(unk.tokensIn) && Number.isFinite(unk.tokensOut)) {
|
|
176
|
+
const unkTotal = unk.tokensIn + unk.tokensOut;
|
|
177
|
+
if (unkTotal > 0) {
|
|
178
|
+
lines.push(`(${unkTotal} tokens with unknown pricing not included)`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
label: `Total: $${total.toFixed(2)}`,
|
|
183
|
+
tooltip: lines.join("\n"),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
63
187
|
export function formatSessionLabel(session) {
|
|
64
188
|
if (!session || !session.id) {
|
|
65
189
|
return { title: "(no session)", subtitle: "", idChip: "" };
|