@thispointon/kondi-chat 0.1.2

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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/bin/kondi-chat +56 -0
  4. package/bin/kondi-chat.js +72 -0
  5. package/package.json +55 -0
  6. package/scripts/demo.tape +49 -0
  7. package/scripts/postinstall.cjs +103 -0
  8. package/src/audit/analytics.ts +261 -0
  9. package/src/audit/ledger.ts +253 -0
  10. package/src/audit/telemetry.ts +165 -0
  11. package/src/cli/backend.ts +675 -0
  12. package/src/cli/commands.ts +419 -0
  13. package/src/cli/help.ts +182 -0
  14. package/src/cli/submit-helpers.ts +159 -0
  15. package/src/cli/submit.ts +539 -0
  16. package/src/cli/wizard.ts +121 -0
  17. package/src/context/bootstrap.ts +138 -0
  18. package/src/context/budget.ts +100 -0
  19. package/src/context/manager.ts +666 -0
  20. package/src/context/memory.ts +160 -0
  21. package/src/context/preflight.ts +176 -0
  22. package/src/context/project-brain.ts +101 -0
  23. package/src/context/receipts.ts +108 -0
  24. package/src/context/skills.ts +154 -0
  25. package/src/context/symbol-index.ts +240 -0
  26. package/src/council/profiles.ts +137 -0
  27. package/src/council/tool.ts +138 -0
  28. package/src/council-engine/cli/council-artifacts.ts +230 -0
  29. package/src/council-engine/cli/council-config.ts +178 -0
  30. package/src/council-engine/cli/council-session-export.ts +116 -0
  31. package/src/council-engine/cli/kondi.ts +98 -0
  32. package/src/council-engine/cli/llm-caller.ts +229 -0
  33. package/src/council-engine/cli/localStorage-shim.ts +119 -0
  34. package/src/council-engine/cli/node-platform.ts +68 -0
  35. package/src/council-engine/cli/run-council.ts +481 -0
  36. package/src/council-engine/cli/run-pipeline.ts +772 -0
  37. package/src/council-engine/cli/session-export.ts +153 -0
  38. package/src/council-engine/configs/councils/analysis.json +101 -0
  39. package/src/council-engine/configs/councils/code-planning.json +86 -0
  40. package/src/council-engine/configs/councils/coding.json +89 -0
  41. package/src/council-engine/configs/councils/debate.json +97 -0
  42. package/src/council-engine/configs/councils/solo-claude.json +34 -0
  43. package/src/council-engine/configs/councils/solo-gpt.json +34 -0
  44. package/src/council-engine/council/coding-orchestrator.ts +1205 -0
  45. package/src/council-engine/council/context-bootstrap.ts +147 -0
  46. package/src/council-engine/council/context-inspection.ts +42 -0
  47. package/src/council-engine/council/context-store.ts +763 -0
  48. package/src/council-engine/council/deliberation-orchestrator.ts +2762 -0
  49. package/src/council-engine/council/factory.ts +164 -0
  50. package/src/council-engine/council/index.ts +201 -0
  51. package/src/council-engine/council/ledger-store.ts +438 -0
  52. package/src/council-engine/council/prompts.ts +1689 -0
  53. package/src/council-engine/council/storage-cleanup.ts +164 -0
  54. package/src/council-engine/council/store.ts +1110 -0
  55. package/src/council-engine/council/synthesis.ts +291 -0
  56. package/src/council-engine/council/types.ts +845 -0
  57. package/src/council-engine/council/validation.ts +613 -0
  58. package/src/council-engine/pipeline/build-detect.ts +73 -0
  59. package/src/council-engine/pipeline/executor.ts +1048 -0
  60. package/src/council-engine/pipeline/index.ts +9 -0
  61. package/src/council-engine/pipeline/install-detect.ts +84 -0
  62. package/src/council-engine/pipeline/memory-store.ts +182 -0
  63. package/src/council-engine/pipeline/output-parsers.ts +146 -0
  64. package/src/council-engine/pipeline/run-output.ts +149 -0
  65. package/src/council-engine/pipeline/session-import.ts +177 -0
  66. package/src/council-engine/pipeline/store.ts +753 -0
  67. package/src/council-engine/pipeline/test-detect.ts +82 -0
  68. package/src/council-engine/pipeline/types.ts +401 -0
  69. package/src/council-engine/services/deliberationSummary.ts +114 -0
  70. package/src/council-engine/tsconfig.json +16 -0
  71. package/src/council-engine/types/mcp.ts +122 -0
  72. package/src/council-engine/utils/filterTools.ts +73 -0
  73. package/src/engine/apply.ts +238 -0
  74. package/src/engine/checkpoints.ts +237 -0
  75. package/src/engine/consultants.ts +347 -0
  76. package/src/engine/diff.ts +171 -0
  77. package/src/engine/errors.ts +102 -0
  78. package/src/engine/git-tools.ts +246 -0
  79. package/src/engine/hooks.ts +181 -0
  80. package/src/engine/loop-guard.ts +155 -0
  81. package/src/engine/permissions.ts +293 -0
  82. package/src/engine/pipeline.ts +376 -0
  83. package/src/engine/sub-agents.ts +133 -0
  84. package/src/engine/task-card.ts +185 -0
  85. package/src/engine/task-router.ts +256 -0
  86. package/src/engine/task-store.ts +86 -0
  87. package/src/engine/tools.ts +783 -0
  88. package/src/engine/verify.ts +111 -0
  89. package/src/mcp/client.ts +225 -0
  90. package/src/mcp/config.ts +120 -0
  91. package/src/mcp/tool-manager.ts +192 -0
  92. package/src/mcp/types.ts +61 -0
  93. package/src/providers/llm-caller.ts +943 -0
  94. package/src/providers/rate-limiter.ts +238 -0
  95. package/src/router/NOTES.md +28 -0
  96. package/src/router/collector.ts +474 -0
  97. package/src/router/embeddings.ts +286 -0
  98. package/src/router/index.ts +299 -0
  99. package/src/router/intent-router.ts +225 -0
  100. package/src/router/nn-router.ts +205 -0
  101. package/src/router/profiles.ts +309 -0
  102. package/src/router/registry.ts +565 -0
  103. package/src/router/rules.ts +274 -0
  104. package/src/router/train.py +408 -0
  105. package/src/session/store.ts +211 -0
  106. package/src/test-utils/mock-llm.ts +39 -0
  107. package/src/types.ts +322 -0
  108. package/src/web/manager.ts +311 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Slash command dispatcher for the TUI backend.
3
+ *
4
+ * Split out of backend.ts to shrink the god-object. Every command handler
5
+ * is a branch of one switch statement; the runtime dependencies it
6
+ * reaches for are bundled into a single `CommandDeps` param instead of
7
+ * 20 positional args. Keep this file free of startup wiring and stdin
8
+ * plumbing — its only job is: given the typed deps and a command
9
+ * string, return the string to display.
10
+ *
11
+ * Two side effects the handlers can produce beyond their return value:
12
+ * - Calling `deps.emit(...)` to push a TUI event (used by /use and
13
+ * /mode so the model indicator refreshes without a turn).
14
+ * - Mutating shared state on `deps.profiles`, `deps.router`,
15
+ * `deps.checkpointManager`, etc. — these are live references, not
16
+ * snapshots, so changes persist for the rest of the session.
17
+ */
18
+
19
+ import { readFileSync } from 'node:fs';
20
+ import { resolve } from 'node:path';
21
+ import type { Session, ImageAttachment } from '../types.ts';
22
+ import type { Ledger } from '../audit/ledger.ts';
23
+ import type { ContextManager } from '../context/manager.ts';
24
+ import type { ModelRegistry } from '../router/registry.ts';
25
+ import type { RoutingCollector } from '../router/collector.ts';
26
+ import type { ProfileManager } from '../router/profiles.ts';
27
+ import type { Router as UnifiedRouter } from '../router/index.ts';
28
+ import type { CouncilProfileManager } from '../council/profiles.ts';
29
+ import { executeCouncil } from '../council/tool.ts';
30
+ import type { Analytics } from '../audit/analytics.ts';
31
+ import type { CheckpointManager } from '../engine/checkpoints.ts';
32
+ import type { SessionStore } from '../session/store.ts';
33
+ import type { RateLimiter } from '../providers/rate-limiter.ts';
34
+ import type { TelemetryEmitter } from '../audit/telemetry.ts';
35
+ import type { ToolContext } from '../engine/tools.ts';
36
+ import type { McpClientManager } from '../mcp/client.ts';
37
+ import type { ToolManager } from '../mcp/tool-manager.ts';
38
+ import { saveMcpServer, removeMcpServer } from '../mcp/config.ts';
39
+ import { formatHelp } from './help.ts';
40
+ import { writeActiveProfile } from './wizard.ts';
41
+ import { pickCompressionModel } from './submit-helpers.ts';
42
+
43
+ export interface CommandDeps {
44
+ session: Session;
45
+ contextManager: ContextManager;
46
+ ledger: Ledger;
47
+ registry: ModelRegistry;
48
+ collector: RoutingCollector;
49
+ toolCtx: ToolContext;
50
+ mcpClient: McpClientManager;
51
+ toolManager: ToolManager;
52
+ workingDir: string;
53
+ profiles: ProfileManager;
54
+ router: UnifiedRouter;
55
+ councilProfiles: CouncilProfileManager;
56
+ analytics: Analytics;
57
+ checkpointManager: CheckpointManager;
58
+ sessionStore: SessionStore;
59
+ rateLimiter: RateLimiter;
60
+ pendingImages: ImageAttachment[];
61
+ telemetry: TelemetryEmitter;
62
+ /** Push a live event back to the TUI. */
63
+ emit: (event: Record<string, unknown>) => void;
64
+ }
65
+
66
+ export async function handleCommand(input: string, deps: CommandDeps): Promise<string> {
67
+ const {
68
+ session, contextManager, ledger, registry, collector, toolCtx,
69
+ mcpClient, toolManager, workingDir,
70
+ profiles, router, councilProfiles, analytics,
71
+ checkpointManager, sessionStore, rateLimiter, pendingImages, telemetry, emit,
72
+ } = deps;
73
+
74
+ const parts = input.split(/\s+/);
75
+ const cmd = parts[0];
76
+
77
+ switch (cmd) {
78
+ case '/mode-details': {
79
+ // Show full config for a profile. No arg = active profile.
80
+ const name = parts[1] || profiles.getActive().name;
81
+ const all = profiles.getAll();
82
+ const p = all[name];
83
+ if (!p) return `Unknown profile: ${name}. Available: ${profiles.getNames().join(', ')}`;
84
+
85
+ // Build a model roster. If the profile has rolePinning, show those
86
+ // specific models. If not, show all enabled models (the profile uses
87
+ // whatever's available via capability preferences).
88
+ const modelIds = new Set<string>();
89
+ if (p.rolePinning && Object.keys(p.rolePinning).length > 0) {
90
+ for (const id of Object.values(p.rolePinning)) modelIds.add(id as string);
91
+ } else {
92
+ for (const m of registry.getEnabled()) modelIds.add(m.id);
93
+ }
94
+
95
+ const lines: string[] = [
96
+ `═══ ${p.name}${p.name === profiles.getActive().name ? ' (active)' : ''} ═══`,
97
+ p.description,
98
+ '',
99
+ ];
100
+
101
+ // Models in this profile
102
+ if (modelIds.size > 0) {
103
+ lines.push('── Models ────────────────────────────────────────────');
104
+ for (const id of modelIds) {
105
+ const m = registry.getById(id);
106
+ if (m) {
107
+ const alias = m.alias ? `@${m.alias}` : '';
108
+ const cost = m.inputCostPer1M === 0 && m.outputCostPer1M === 0
109
+ ? 'free'
110
+ : `$${m.inputCostPer1M}/$${m.outputCostPer1M} per 1M`;
111
+ // Find which phases this model is pinned to
112
+ const phases: string[] = [];
113
+ if (p.rolePinning) {
114
+ for (const [phase, pinId] of Object.entries(p.rolePinning)) {
115
+ if (pinId === id) phases.push(phase);
116
+ }
117
+ }
118
+ const phaseStr = phases.length > 0 ? ` ← ${phases.join(', ')}` : '';
119
+ lines.push(` ${m.name} ${alias} (${m.provider}, ${cost})${phaseStr}`);
120
+ lines.push(` capabilities: ${m.capabilities.join(', ')}`);
121
+ } else {
122
+ lines.push(` ${id} (not in registry)`);
123
+ }
124
+ }
125
+ lines.push('');
126
+ }
127
+
128
+ // Role assignments
129
+ if (p.rolePinning && Object.keys(p.rolePinning).length > 0) {
130
+ lines.push('── Phase → Model ─────────────────────────────────────');
131
+ for (const [phase, modelId] of Object.entries(p.rolePinning)) {
132
+ const m = registry.getById(modelId as string);
133
+ const label = m ? `${m.name} @${m.alias || m.id}` : modelId;
134
+ lines.push(` ${(phase as string).padEnd(14)} → ${label}`);
135
+ }
136
+ lines.push('');
137
+ }
138
+
139
+ // Settings
140
+ lines.push('── Settings ──────────────────────────────────────────');
141
+ lines.push(` Context budget: ${p.contextBudget.toLocaleString()} tokens`);
142
+ lines.push(` Loop caps: ${p.loopIterationCap} iterations, $${p.loopCostCap.toFixed(2)}`);
143
+ lines.push(` Max output tokens: ${p.maxOutputTokens.toLocaleString()}`);
144
+ lines.push(` Prefer local: ${p.preferLocal ? 'yes' : 'no'}`);
145
+ lines.push(` Reflection: ${p.includeReflection ? 'yes' : 'no'}`);
146
+ lines.push(` Verification: ${p.includeVerification ? 'yes' : 'no'}`);
147
+ lines.push(` Promotion after: ${p.promotionThreshold} failures`);
148
+
149
+ // Capability preferences
150
+ lines.push('');
151
+ lines.push('── Capability Preferences ────────────────────────────');
152
+ lines.push(` Planning: [${p.planningPreference.join(', ')}]`);
153
+ lines.push(` Execution: [${p.executionPreference.join(', ')}]`);
154
+ lines.push(` Review: [${p.reviewPreference.join(', ')}]`);
155
+
156
+ return lines.join('\n');
157
+ }
158
+ case '/mode': {
159
+ const mode = parts[1];
160
+ if (!mode) return profiles.format();
161
+ try {
162
+ profiles.setProfile(mode);
163
+ router.rules.setProfile(profiles.getActive());
164
+ // Reapply profile scope to intent router + compression model so
165
+ // switching to/from zai updates everything in one shot.
166
+ const p = profiles.getActive();
167
+ const cheap = pickCompressionModel(registry, p);
168
+ if (cheap) contextManager.setCompressionModel(cheap.provider, cheap.id);
169
+ router.setProfileScope({
170
+ classifier: cheap ? { provider: cheap.provider, model: cheap.id } : undefined,
171
+ rolePinning: p.rolePinning,
172
+ });
173
+ writeActiveProfile(resolve(workingDir, '.kondi-chat'), profiles.getActive().name);
174
+ // Switching mode clears any /use override — the user wants the
175
+ // profile's routing, not a stale manual pin.
176
+ router.rules.setOverride(undefined);
177
+ emit({ type: 'model_override', label: profiles.getActive().name, pinned: false });
178
+ return `Mode: ${profiles.getActive().name}`;
179
+ } catch (e) { return (e as Error).message; }
180
+ }
181
+ case '/use': {
182
+ const alias = parts[1];
183
+ if (!alias) return router.rules.getOverride()
184
+ ? `Using: ${router.rules.getOverride()!.alias || router.rules.getOverride()!.id}`
185
+ : 'Router: auto';
186
+ if (alias === 'auto') {
187
+ router.rules.setOverride(undefined);
188
+ emit({ type: 'model_override', label: profiles.getActive().name, pinned: false });
189
+ return 'Router: auto';
190
+ }
191
+ const model = registry.getByAlias(alias);
192
+ if (!model) {
193
+ const candidates: string[] = registry.findAliasCandidates(alias);
194
+ const hint = candidates.length > 1
195
+ ? ` — ambiguous, could be: ${candidates.map((a: string) => `@${a}`).join(', ')}`
196
+ : ` — available: ${registry.getAliases().join(', ')}`;
197
+ return `Unknown: ${alias}${hint}`;
198
+ }
199
+ router.rules.setOverride(model);
200
+ emit({ type: 'model_override', label: model.alias || model.id, pinned: true });
201
+ return `Using: ${model.name} (@${model.alias})`;
202
+ }
203
+ case '/consultants': {
204
+ const roster = toolCtx.consultants ?? [];
205
+ if (roster.length === 0) return 'No consultants configured. Edit .kondi-chat/consultants.json to add some.';
206
+ const lines: string[] = ['Available consultants:', ''];
207
+ for (const c of roster) {
208
+ lines.push(` ${c.role}`);
209
+ lines.push(` ${c.name} (${c.provider}/${c.model})`);
210
+ lines.push(` ${c.description}`);
211
+ lines.push('');
212
+ }
213
+ lines.push('Edit .kondi-chat/consultants.json to add, remove, or tune them.');
214
+ return lines.join('\n');
215
+ }
216
+ case '/models': return registry.format();
217
+ case '/health': { await registry.checkHealth(); return registry.formatHealth(); }
218
+ case '/routing': return collector.formatStats();
219
+ case '/status': {
220
+ const budget = contextManager.getBudgetStatus();
221
+ return [
222
+ `Session: ${session.id.slice(0, 8)}`,
223
+ `Tokens: ${session.totalInputTokens.toLocaleString()}in / ${session.totalOutputTokens.toLocaleString()}out`,
224
+ `Cost: $${session.totalCostUsd.toFixed(4)}`,
225
+ `Context: ${budget.currentContextSize.toLocaleString()}/${budget.modelContextWindow.toLocaleString()} (${(budget.contextUtilization * 100).toFixed(0)}%)`,
226
+ ].join('\n');
227
+ }
228
+ case '/cost': {
229
+ const totals = ledger.getTotals();
230
+ if (totals.calls === 0) return 'No calls yet.';
231
+ const lines = [
232
+ `═══ Session Cost Breakdown ═══`,
233
+ `Total: ${totals.calls} calls | ${totals.inputTokens.toLocaleString()}in / ${totals.outputTokens.toLocaleString()}out | $${totals.costUsd.toFixed(4)}`,
234
+ '',
235
+ 'By Model:',
236
+ ];
237
+ type ModelTotal = { calls: number; inputTokens: number; outputTokens: number; costUsd: number };
238
+ const byModel = totals.byModel as Record<string, ModelTotal>;
239
+ for (const [m, d] of Object.entries(byModel).sort((a, b) => b[1].costUsd - a[1].costUsd)) {
240
+ lines.push(` ${m.slice(0, 28).padEnd(30)} ${String(d.calls).padStart(3)} calls ${d.inputTokens.toLocaleString().padStart(10)}in ${d.outputTokens.toLocaleString().padStart(8)}out $${d.costUsd.toFixed(4)}`);
241
+ }
242
+ const byPhase = totals.byPhase as Record<string, ModelTotal>;
243
+ if (Object.keys(byPhase).length > 1) {
244
+ lines.push('', 'By Phase:');
245
+ for (const [p, d] of Object.entries(byPhase).sort((a, b) => b[1].costUsd - a[1].costUsd)) {
246
+ lines.push(` ${p.padEnd(15)} ${String(d.calls).padStart(3)} calls ${d.inputTokens.toLocaleString().padStart(10)}in ${d.outputTokens.toLocaleString().padStart(8)}out $${d.costUsd.toFixed(4)}`);
247
+ }
248
+ }
249
+ return lines.join('\n');
250
+ }
251
+ case '/council': {
252
+ if (!parts[1] || parts[1] === 'list') return councilProfiles.format();
253
+ if (parts[1] === 'run' && parts[2]) {
254
+ const brief = parts.slice(3).join(' ');
255
+ if (!brief) return 'Usage: /council run <profile> <brief>';
256
+ const result = await executeCouncil(parts[2], brief, [], workingDir, councilProfiles);
257
+ return result.content;
258
+ }
259
+ return 'Usage: /council [list|run <profile> <brief>]';
260
+ }
261
+ case '/analytics': {
262
+ const days = parts[1] ? parseInt(parts[1]) : 30;
263
+ if (parts[1] === 'rebuild') { analytics.rebuild(); return 'Analytics rebuilt from all ledger files.'; }
264
+ if (parts[1] === 'export') { return analytics.exportAll(); }
265
+ return analytics.format(days);
266
+ }
267
+ case '/attach': {
268
+ const p = parts.slice(1).join(' ');
269
+ if (!p) return 'Usage: /attach <path to image>';
270
+ try {
271
+ const abs = resolve(workingDir, p);
272
+ const buf = readFileSync(abs);
273
+ const MAX_BYTES = 10 * 1024 * 1024;
274
+ if (buf.byteLength > MAX_BYTES) return `Image too large: ${buf.byteLength} > 10MB`;
275
+ if (pendingImages.length >= 5) return 'Already 5 images queued for next message.';
276
+ const ext = (p.split('.').pop() || '').toLowerCase();
277
+ const mime: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' };
278
+ const mimeType = mime[ext];
279
+ if (!mimeType) return `Unsupported image type: .${ext}`;
280
+ pendingImages.push({
281
+ mimeType,
282
+ base64: buf.toString('base64'),
283
+ originalPath: p,
284
+ sizeBytes: buf.byteLength,
285
+ });
286
+ return `Attached ${p} (${mimeType}, ${buf.byteLength} bytes). Queued ${pendingImages.length}/5 for next message.`;
287
+ } catch (e) {
288
+ return `Attach failed: ${(e as Error).message}`;
289
+ }
290
+ }
291
+ case '/telemetry': {
292
+ const sub = parts[1] || 'status';
293
+ if (sub === 'enable') { telemetry.enable(); return 'Telemetry: local-only (no network). Run /telemetry details to see the schema.'; }
294
+ if (sub === 'disable') { telemetry.disable(); return 'Telemetry: disabled (local events cleared).'; }
295
+ if (sub === 'delete') { telemetry.deleteAll(); return 'Telemetry: all local events deleted.'; }
296
+ if (sub === 'export') { return telemetry.export(); }
297
+ if (sub === 'details') {
298
+ return [
299
+ 'Telemetry records anonymous counters only. Allowed kinds:',
300
+ ' feature_used — enum counter (session_started, undo_invoked, …)',
301
+ ' tool_called — counter by category (filesystem_read, git, web, …)',
302
+ ' error_occurred — counter by class (llm_timeout, permission_denied, …)',
303
+ 'NEVER recorded: prompts, responses, tool args, file paths, URLs, API keys.',
304
+ 'Storage: .kondi-chat/telemetry.json (local only). No network in v1.',
305
+ ].join('\n');
306
+ }
307
+ return telemetry.format();
308
+ }
309
+ case '/rate-limits': return rateLimiter.format();
310
+ case '/sessions': return sessionStore.format(workingDir);
311
+ case '/resume': {
312
+ if (!parts[1]) return 'Usage: /resume <session-id>';
313
+ const p = sessionStore.load(parts[1]);
314
+ if (!p) return `Session not found: ${parts[1]}`;
315
+ return `To resume ${p.session.id.slice(0, 8)}, restart with:\n kondi-chat --resume ${p.session.id}`;
316
+ }
317
+ case '/checkpoints': return checkpointManager.format();
318
+ case '/undo': {
319
+ const arg = parts[1];
320
+ try {
321
+ if (!arg) {
322
+ const r = checkpointManager.restore(-1);
323
+ return `Reverted ${r.restored.id} (turn ${r.restored.turnNumber}): ${r.restored.summary}\n files: ${r.filesRestored.length}${r.errors.length ? ` errors: ${r.errors.join('; ')}` : ''}`;
324
+ }
325
+ if (/^\d+$/.test(arg)) {
326
+ const n = parseInt(arg, 10);
327
+ const r = checkpointManager.restore(-n);
328
+ return `Reverted ${n} checkpoint(s) to ${r.restored.id} (turn ${r.restored.turnNumber}). Files: ${r.filesRestored.length}`;
329
+ }
330
+ const cp = checkpointManager.get(arg);
331
+ if (!cp) return `Unknown checkpoint: ${arg}. Run /checkpoints to list.`;
332
+ const r = checkpointManager.restore(arg);
333
+ return `Restored ${r.restored.id}. Files: ${r.filesRestored.join(', ') || '(none)'}`;
334
+ } catch (e) {
335
+ return `Undo failed: ${(e as Error).message}`;
336
+ }
337
+ }
338
+ case '/mcp': {
339
+ const sub = parts[1];
340
+ if (!sub) return mcpClient.format();
341
+ if (sub === 'add' && parts[2]) {
342
+ // /mcp add <name> <command> [args...]
343
+ // /mcp add <name> http <url>
344
+ const name = parts[2];
345
+ if (parts[3] === 'http' || parts[3] === 'https') {
346
+ const url = parts[4];
347
+ if (!url) return 'Usage: /mcp add <name> http <url>';
348
+ saveMcpServer(workingDir, name, { type: 'http', url } as any);
349
+ await mcpClient.connect(name, { type: 'http', url, scope: 'project' } as any);
350
+ return `Added HTTP MCP server: ${name} → ${url}`;
351
+ }
352
+ const command = parts[3];
353
+ const args = parts.slice(4);
354
+ if (!command) return 'Usage: /mcp add <name> <command> [args...]';
355
+ saveMcpServer(workingDir, name, { command, args });
356
+ await mcpClient.connect(name, { command, args, scope: 'project' } as any);
357
+ return `Added stdio MCP server: ${name} → ${command} ${args.join(' ')}`;
358
+ }
359
+ if (sub === 'remove' && parts[2]) {
360
+ const name = parts[2];
361
+ await mcpClient.disconnect(name);
362
+ const removed = removeMcpServer(workingDir, name) || removeMcpServer(workingDir, name, 'user');
363
+ return removed ? `Removed MCP server: ${name}` : `Server not found in config: ${name}`;
364
+ }
365
+ if (sub === 'reconnect') {
366
+ const name = parts[2];
367
+ if (name) {
368
+ const server = mcpClient.getServer(name);
369
+ if (!server) return `Unknown server: ${name}`;
370
+ await mcpClient.disconnect(name);
371
+ await mcpClient.connect(name, server.config);
372
+ return `Reconnected: ${name}`;
373
+ }
374
+ // Reconnect all
375
+ const servers = mcpClient.getServers();
376
+ for (const s of servers) {
377
+ await mcpClient.disconnect(s.name);
378
+ await mcpClient.connect(s.name, s.config);
379
+ }
380
+ return `Reconnected ${servers.length} server(s)`;
381
+ }
382
+ return 'Usage: /mcp [add <name> <command> [args...] | remove <name> | reconnect [name]]';
383
+ }
384
+ case '/tools': {
385
+ const all = toolManager.getTools();
386
+ const summary = toolManager.getSummary();
387
+ const lines = [
388
+ '═══ Slash Commands ═══',
389
+ ' /mode [name] Show or set budget profile',
390
+ ' /mode-details [name] Full config for a profile',
391
+ ' /use <alias> Pin to a model (/use auto to unpin)',
392
+ ' /models List models and aliases',
393
+ ' /health Check model availability',
394
+ ' /cost Session cost breakdown by model',
395
+ ' /analytics [days] Cross-session cost by model and day',
396
+ ' /routing Router stats and tier distribution',
397
+ ' /tools This list',
398
+ ' /consultants List domain-expert consultants',
399
+ ' /council Multi-model deliberation',
400
+ ' /tasks List task cards',
401
+ ' /loop <goal> Autonomous agent loop',
402
+ ' /sessions List recent sessions',
403
+ ' /checkpoints List checkpoints',
404
+ ' /undo [N] Revert to checkpoint',
405
+ ' /attach <path> Queue image for next message',
406
+ ' /mcp List MCP servers and tools',
407
+ ' /rate-limits Per-provider RPM/TPM usage',
408
+ ' /help [topic] Detailed help on any topic',
409
+ ' /quit Exit',
410
+ '',
411
+ `═══ Agent Tools (${all.length}: ${summary.builtIn} built-in, ${summary.mcp} MCP) ═══`,
412
+ ...all.map(t => ` ${t.name.padEnd(22)} ${(t.description || '').slice(0, 55)}`),
413
+ ];
414
+ return lines.join('\n');
415
+ }
416
+ case '/help': return formatHelp(parts[1]);
417
+ default: return `Unknown: ${cmd}. Try /help`;
418
+ }
419
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * In-app help. Hand-authored topic database keyed by slash command or feature
3
+ * name. `/help` lists topics; `/help <topic>` shows a single entry; if the
4
+ * topic is unknown, the closest-match suggestion is returned.
5
+ */
6
+
7
+ export interface HelpTopic {
8
+ syntax?: string;
9
+ description: string;
10
+ examples?: string[];
11
+ related?: string[];
12
+ }
13
+
14
+ const TOPICS: Record<string, HelpTopic> = {
15
+ '/mode': {
16
+ syntax: '/mode [quality|balanced|cheap|zai|<custom>]',
17
+ description: 'Show or set the active budget profile. Profiles control loop caps, cost caps, model priorities, and optional provider allow-lists. Persisted across restarts via .kondi-chat/config.json.',
18
+ examples: ['/mode', '/mode quality', '/mode zai'],
19
+ related: ['/use', '/cost', '/routing'],
20
+ },
21
+ '/use': {
22
+ syntax: '/use <alias> | /use auto',
23
+ description: 'Pin the agent to a specific model for every subsequent turn, or return to auto-routing. Aliases resolve on an unambiguous prefix (`/use gemi` → gemini). Ambiguous prefixes list the candidates. The bottom-of-viewport model indicator updates immediately when this runs — no turn required.',
24
+ examples: ['/use claude', '/use gemini', '/use glm', '/use auto'],
25
+ related: ['/models', '/mode', 'mentions'],
26
+ },
27
+ '/models': {
28
+ description: 'List all registered models with their aliases and health status.',
29
+ related: ['/use', '/health'],
30
+ },
31
+ '/status': {
32
+ description: 'Show session cost, token usage, and context window utilization.',
33
+ related: ['/cost', '/analytics'],
34
+ },
35
+ '/cost': {
36
+ description: 'Breakdown of LLM cost by model and phase for the current session.',
37
+ related: ['/status', '/analytics'],
38
+ },
39
+ '/attach': {
40
+ syntax: '/attach <path>',
41
+ description: 'Queue an image (PNG/JPG/GIF/WebP, ≤10MB) to send with the next message. Up to 5 images per turn.',
42
+ examples: ['/attach ./screenshot.png'],
43
+ related: [],
44
+ },
45
+ '/sessions': {
46
+ description: 'List recent sessions (id, message count, cost).',
47
+ related: ['/resume'],
48
+ },
49
+ '/resume': {
50
+ syntax: '/resume <id>',
51
+ description: 'Print the exact restart command to resume a session. v1 does not hot-swap; relaunch with --resume <id>.',
52
+ related: ['/sessions'],
53
+ },
54
+ '/checkpoints': {
55
+ description: 'List checkpoints created before mutating tool calls.',
56
+ related: ['/undo'],
57
+ },
58
+ '/undo': {
59
+ syntax: '/undo [N | <id>]',
60
+ description: 'Revert to a previous checkpoint. No argument restores the latest; N reverts that many checkpoints back; an id restores a specific one.',
61
+ examples: ['/undo', '/undo 2', '/undo cp-1712438400-abcd'],
62
+ related: ['/checkpoints'],
63
+ },
64
+ '/routing': {
65
+ description: 'Routing dashboard: tier distribution (intent/nn/rules), per-model success rates and cost, model×tier matrix, quality scores, NN training readiness, and by-phase breakdown. The intent tier is the primary — if it is dominant you know the router is picking models with full model descriptions instead of falling back to hardcoded rules.',
66
+ related: ['/models', '/cost', '/analytics'],
67
+ },
68
+ '/rate-limits': {
69
+ description: 'Show per-provider RPM/TPM usage and any queued requests.',
70
+ },
71
+ '/telemetry': {
72
+ syntax: '/telemetry [enable|disable|status|details|export|delete]',
73
+ description: 'Control opt-in local telemetry. Nothing is sent to any server in v1.',
74
+ },
75
+ '/consultants': {
76
+ description:
77
+ 'List the domain-expert consultants configured for this project. Each consultant is a (model, system-prompt) pair stored in .kondi-chat/consultants.json. The agent can call them via the `consult` tool when it decides a problem has a clear domain angle — aerospace safety, security, database, etc. Edit the JSON to add, remove, or tune experts without touching code.',
78
+ related: ['consultants', '/models'],
79
+ },
80
+ 'consultants': {
81
+ description:
82
+ 'Consultants are domain-expert personas the agent calls on demand via the `consult` tool. Each is a triple of (role id, provider/model, system prompt) stored in .kondi-chat/consultants.json. Consultants are pure text-in/text-out — they cannot read files, run commands, or see the main conversation. They exist to give the agent a specialized opinion without setting up a full sub-agent. Defaults include aerospace-engineer, security-auditor, and database-architect. To add one, append an entry to the JSON: {"role": "ml-researcher", "name": "...", "description": "...", "provider": "anthropic", "model": "claude-sonnet-4-5-20250929", "system": "You are a machine-learning researcher..."}. Consultations are logged to the ledger as phase: consult so /routing and /cost can attribute the spend.',
83
+ related: ['/consultants', '/loop'],
84
+ },
85
+ '/loop': {
86
+ syntax: '/loop <goal>',
87
+ description: 'Run an autonomous agent loop toward a stated goal. Each iteration the model may call tools, produce partial output, and decide whether the goal is met. If the model returns final text without calling any tool but has not emitted DONE or STUCK, the backend synthesizes a "continue" follow-up and keeps iterating — LoopGuard still enforces the profile\'s iteration and cost caps. The model signals termination with DONE (success) or STUCK: <reason> (blocked) on its own line. The `/loop` command streams tool_call and activity events in real time like a normal submit, not a silent command.',
88
+ examples: ['/loop fix all the failing tests and commit when green', '/loop find every TODO in src/ and resolve them'],
89
+ related: ['/mode', 'type-ahead', '/undo'],
90
+ },
91
+ '/council': {
92
+ syntax: '/council [list | run <profile> <brief>]',
93
+ description: 'Run multi-model deliberation via the council tool. Councils are expensive (fan out across frontier models for multiple rounds) and blocking (synchronous subprocess) — the agent CANNOT invoke them automatically; only explicit /council runs them. Not available from inside the agent toolset.',
94
+ },
95
+ '/help': {
96
+ syntax: '/help [topic]',
97
+ description: 'Show general help or a specific topic.',
98
+ examples: ['/help', '/help /undo', '/help memory'],
99
+ },
100
+ // Feature topics
101
+ 'memory': {
102
+ description: 'KONDI.md and AGENTS.md files provide persistent project conventions injected into the system prompt. AGENTS.md is an open cross-tool convention (Claude Code, Cursor, Copilot, Aider, Zed, etc.); KONDI.md is kondi-chat-specific. Both are searched at three levels: user (~/.kondi-chat/), project (<workingDir>/), and nearest-ancestor subdirectory. If both exist at the same level, both are loaded. Agent writes (update_memory tool) go to KONDI.md only — AGENTS.md is hand-authored.',
103
+ related: ['/help update_memory'],
104
+ },
105
+ 'permissions': {
106
+ description: 'Tools run through a permission gate (auto-approve/confirm/always-confirm). Dangerous shell commands (rm -rf, sudo, git push --force) are always-confirm regardless of config.',
107
+ },
108
+ 'checkpoints': {
109
+ description: 'Every turn that mutates files snapshots state first. Git repos use git stash; non-git dirs copy files. /undo restores the latest.',
110
+ related: ['/undo', '/checkpoints'],
111
+ },
112
+ 'hooks': {
113
+ description: 'Shell or tool-call hooks run before or after agent tools. Configured in .kondi-chat/hooks.json. See docs/hooks.md.',
114
+ },
115
+ 'non-interactive': {
116
+ description: 'Flags: --prompt "<text>", --pipe, --json, --sessions. Exit codes: 0 ok, 1 error, 2 max-iter, 3 max-cost, 5 permission-denied.',
117
+ },
118
+ 'shortcuts': {
119
+ description: 'TUI keybindings. Ctrl+C quit · Enter send OR queue if a turn is running (see `type-ahead`) · Ctrl+N newline in input · Ctrl+O toggle tool-call detail view · Ctrl+T toggle stats detail view · Ctrl+R toggle reasoning detail view (chain-of-thought for reasoning models) · Ctrl+Y copy last assistant response to clipboard · Ctrl+A toggle activity log · Left/Right/Home/End move input cursor · Up/Down recall input history · Esc cascades: close detail view → clear input → clear queued submits. Permission dialogs: y/Enter approve · n/Esc deny · a approve this exact command for session · t yolo-approve everything for this turn.',
120
+ related: ['permissions', 'type-ahead', 'mentions'],
121
+ },
122
+ 'type-ahead': {
123
+ description: 'Enter during an in-flight turn queues the new message instead of dispatching it. A dim "⧗ queued: …" line drops into scrollback and the status bar shows "⧗ queued: N (Esc to clear)". When the current turn finishes, the oldest queued entry fires automatically. Guarantees at most one handleSubmit is ever in flight on the backend, so concurrent turns cannot race over session state, tool attribution, or permissions. Esc on an empty input clears the queue.',
124
+ related: ['shortcuts'],
125
+ },
126
+ 'mentions': {
127
+ description: '@<alias> at the start of a message forces one specific model for that single turn without changing the router state. Typing `@` alone triggers an autocomplete list of every enabled alias, filterable as you keep typing. Aliases resolve on unambiguous prefix (`@gemi` → @gemini). Ambiguous prefixes report candidates. For a persistent pin use /use <alias> instead.',
128
+ related: ['/use', '/models', 'shortcuts'],
129
+ },
130
+ 'zai': {
131
+ description: 'Z.AI (GLM) is supported as an OpenAI-compatible provider. Set ZAI_API_KEY in .env. The Coding Plan endpoint (https://api.z.ai/api/coding/paas/v4) is used — NOT the pay-as-you-go /api/paas/v4. Use /mode zai to route everything through the tiered zai profile: glm-5.1 (reasoning) for planning/review, glm-4.6 for execution/coding, glm-4.5-flash (free!) for compression and summarization. Profile restricts routing via allowedProviders so nothing leaks to other providers.',
132
+ related: ['/mode', 'reasoning-models', 'compression'],
133
+ },
134
+ 'reasoning-models': {
135
+ description: 'Reasoning models (GLM-5.x, OpenAI o-series, DeepSeek-R1, Anthropic extended-thinking) emit hidden chain-of-thought that is billed as OUTPUT tokens at full rate but not shown inline. A single 20-char reply can cost 500+ output tokens of unseen reasoning — the "80× reasoning tax." Ctrl+R opens the reasoning panel so you can see what the model was actually thinking. Keep reasoning models off the hot path if quota matters; use them only where the depth pays for itself (planning, code review). Cache discount still applies to cached input tokens.',
136
+ related: ['shortcuts', 'zai'],
137
+ },
138
+ 'compression': {
139
+ description: 'Context is capped at the active profile contextBudget. Inside a single agent-loop turn, old tool_result payloads are stubbed in place across three escalation passes (keep 2 turns at 300 chars, keep 1 turn at 100 chars, keep 1 turn at 50 chars) — no LLM calls, just string rewriting. Between turns, ContextManager.compact() summarizes older messages via the active profile compression model (glm-4.5-flash in zai mode, claude-haiku-4-5 otherwise) and writes a COMPACT_BOUNDARY marker. Compaction triggers at contextBudget × 1.2, not at the model context window.',
140
+ related: ['/mode', 'zai'],
141
+ },
142
+ 'intent-router': {
143
+ description: 'The LLM-based intent router is the primary model-selection tier. It reads every model declared in the profile (via rolePinning values, not all enabled models), sees what happened in prior pipeline phases ("Gemini just wrote the code, tests passed"), understands what each phase needs (dispatch = planning, execute = coding, reflect = review), and picks the best model for the current step. When comparable models are available (e.g. Opus and GPT-5.4 for planning), the classifier weighs capabilities against cost per turn. Profile pins serve as fallback if the intent tier fails — the router gets first shot at an intelligent pick. Classifier LLM is profile-scoped (zai uses glm-4.5-flash — free). /routing shows the per-tier distribution.',
144
+ related: ['/routing', '/mode'],
145
+ },
146
+ 'caching': {
147
+ description: 'Prompt cache hits are tracked on both Anthropic (cache_read_input_tokens) and OpenAI-compatible (prompt_tokens_details.cached_tokens) responses. Cached tokens are recorded separately on the ledger entry and discounted in cost estimates (10% of input rate on Anthropic, 50% on OpenAI/Z.AI). See cachedInputTokens in the ledger and /routing for live totals.',
148
+ related: ['/routing', '/cost'],
149
+ },
150
+ };
151
+
152
+ export function formatHelp(topic?: string): string {
153
+ if (!topic) {
154
+ const keys = Object.keys(TOPICS).sort();
155
+ return [
156
+ 'Topics (use /help <topic> for details):',
157
+ ...keys.map(k => ` ${k} — ${TOPICS[k].description.slice(0, 60)}`),
158
+ ].join('\n');
159
+ }
160
+ const entry = TOPICS[topic] || TOPICS[topic.startsWith('/') ? topic : `/${topic}`];
161
+ if (!entry) {
162
+ const suggestion = closestMatch(topic, Object.keys(TOPICS));
163
+ return suggestion ? `No help for ${topic}. Did you mean ${suggestion}?` : `No help for ${topic}.`;
164
+ }
165
+ const parts: string[] = [];
166
+ if (entry.syntax) parts.push(`Syntax: ${entry.syntax}`);
167
+ parts.push(entry.description);
168
+ if (entry.examples?.length) parts.push('\nExamples:\n ' + entry.examples.join('\n '));
169
+ if (entry.related?.length) parts.push(`\nRelated: ${entry.related.join(', ')}`);
170
+ return parts.join('\n');
171
+ }
172
+
173
+ function closestMatch(query: string, candidates: string[]): string | null {
174
+ let best: { k: string; score: number } | null = null;
175
+ for (const c of candidates) {
176
+ if (c.includes(query) || query.includes(c)) {
177
+ const score = Math.abs(c.length - query.length);
178
+ if (!best || score < best.score) best = { k: c, score };
179
+ }
180
+ }
181
+ return best?.k || null;
182
+ }