gnosys 5.5.0 → 5.6.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.
Files changed (60) hide show
  1. package/README.md +44 -0
  2. package/dist/cli.js +204 -18
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/chat/choose.d.ts +75 -0
  5. package/dist/lib/chat/choose.d.ts.map +1 -0
  6. package/dist/lib/chat/choose.js +146 -0
  7. package/dist/lib/chat/choose.js.map +1 -0
  8. package/dist/lib/chat/commands.d.ts +96 -0
  9. package/dist/lib/chat/commands.d.ts.map +1 -0
  10. package/dist/lib/chat/commands.js +367 -0
  11. package/dist/lib/chat/commands.js.map +1 -0
  12. package/dist/lib/chat/focus.d.ts +70 -0
  13. package/dist/lib/chat/focus.d.ts.map +1 -0
  14. package/dist/lib/chat/focus.js +120 -0
  15. package/dist/lib/chat/focus.js.map +1 -0
  16. package/dist/lib/chat/index.d.ts +32 -0
  17. package/dist/lib/chat/index.d.ts.map +1 -0
  18. package/dist/lib/chat/index.js +151 -0
  19. package/dist/lib/chat/index.js.map +1 -0
  20. package/dist/lib/chat/intent.d.ts +100 -0
  21. package/dist/lib/chat/intent.d.ts.map +1 -0
  22. package/dist/lib/chat/intent.js +192 -0
  23. package/dist/lib/chat/intent.js.map +1 -0
  24. package/dist/lib/chat/llmTurn.d.ts +37 -0
  25. package/dist/lib/chat/llmTurn.d.ts.map +1 -0
  26. package/dist/lib/chat/llmTurn.js +61 -0
  27. package/dist/lib/chat/llmTurn.js.map +1 -0
  28. package/dist/lib/chat/recall.d.ts +58 -0
  29. package/dist/lib/chat/recall.d.ts.map +1 -0
  30. package/dist/lib/chat/recall.js +109 -0
  31. package/dist/lib/chat/recall.js.map +1 -0
  32. package/dist/lib/chat/render.d.ts +30 -0
  33. package/dist/lib/chat/render.d.ts.map +1 -0
  34. package/dist/lib/chat/render.js +737 -0
  35. package/dist/lib/chat/render.js.map +1 -0
  36. package/dist/lib/chat/session.d.ts +121 -0
  37. package/dist/lib/chat/session.d.ts.map +1 -0
  38. package/dist/lib/chat/session.js +148 -0
  39. package/dist/lib/chat/session.js.map +1 -0
  40. package/dist/lib/chat/types.d.ts +42 -0
  41. package/dist/lib/chat/types.d.ts.map +1 -0
  42. package/dist/lib/chat/types.js +6 -0
  43. package/dist/lib/chat/types.js.map +1 -0
  44. package/dist/lib/chat/write.d.ts +66 -0
  45. package/dist/lib/chat/write.d.ts.map +1 -0
  46. package/dist/lib/chat/write.js +203 -0
  47. package/dist/lib/chat/write.js.map +1 -0
  48. package/dist/lib/db.d.ts +3 -1
  49. package/dist/lib/db.d.ts.map +1 -1
  50. package/dist/lib/db.js +18 -2
  51. package/dist/lib/db.js.map +1 -1
  52. package/dist/lib/exportProject.d.ts +51 -0
  53. package/dist/lib/exportProject.d.ts.map +1 -0
  54. package/dist/lib/exportProject.js +72 -0
  55. package/dist/lib/exportProject.js.map +1 -0
  56. package/dist/lib/importProject.d.ts +35 -0
  57. package/dist/lib/importProject.d.ts.map +1 -0
  58. package/dist/lib/importProject.js +135 -0
  59. package/dist/lib/importProject.js.map +1 -0
  60. package/package.json +7 -1
@@ -0,0 +1,737 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * Chat TUI — ink-based React components.
4
+ *
5
+ * Layout:
6
+ * ┌────────────────────────────────────────┐
7
+ * │ Header: project | provider/model | tokens │
8
+ * ├────────────────────────────────────────┤
9
+ * │ Conversation │
10
+ * │ user / assistant turns scroll │
11
+ * │ system messages in dim text │
12
+ * ├────────────────────────────────────────┤
13
+ * │ Status: idle | thinking… | error │
14
+ * ├────────────────────────────────────────┤
15
+ * │ > input prompt │
16
+ * └────────────────────────────────────────┘
17
+ */
18
+ import { useState, useEffect, useRef } from "react";
19
+ import { Box, Text, useApp } from "ink";
20
+ import TextInput from "ink-text-input";
21
+ import SelectInput from "ink-select-input";
22
+ import Spinner from "ink-spinner";
23
+ import { dispatchCommand } from "./commands.js";
24
+ import { appendEvent } from "./session.js";
25
+ import { runTurn, buildProvider } from "./llmTurn.js";
26
+ import { runRecall, reinforceMemory, buildRecallQuery } from "./recall.js";
27
+ import { promoteToMemory, lastExchange, formatExchange, detectAutoPromote } from "./write.js";
28
+ import { inferIntent, describeIntent, isDestructive, shouldAutoAccept, recordAcceptance, newAcceptanceLog, } from "./intent.js";
29
+ import { extractChooseFence, formatSelection } from "./choose.js";
30
+ import { newFocusState, applyFocus, applyBranch, applyResumeFocus, popBranch, } from "./focus.js";
31
+ import { GnosysDB } from "../db.js";
32
+ export const ChatApp = ({ initialHeader, initialBuffer, config, projectId, onExit }) => {
33
+ const { exit } = useApp();
34
+ const [header, setHeader] = useState(initialHeader);
35
+ const [buffer, setBuffer] = useState(initialBuffer);
36
+ const [status, setStatus] = useState({ kind: "idle" });
37
+ const [input, setInput] = useState("");
38
+ const [systemNotice, setSystemNotice] = useState([]);
39
+ const configRef = useRef(config);
40
+ // Recall state — pinned IDs, scope, threshold
41
+ const [pinnedIds, setPinnedIds] = useState([]);
42
+ const [scope, setScope] = useState("federated");
43
+ const [threshold, setThreshold] = useState(0);
44
+ // Intent detection — inferred-but-not-yet-confirmed action
45
+ const [pendingIntent, setPendingIntent] = useState(null);
46
+ const acceptanceLogRef = useRef(newAcceptanceLog());
47
+ // Multiple-choice — when the LLM emits a gnosys-choose fence, capture
48
+ // the block here and render a SelectInput in place of the regular text input.
49
+ const [pendingChoice, setPendingChoice] = useState(null);
50
+ // Focus boundaries — declared topic + saved snapshots + branch stack
51
+ const [focusState, setFocusState] = useState(newFocusState());
52
+ function nowIso() {
53
+ return new Date().toISOString();
54
+ }
55
+ function pushSystem(lines) {
56
+ const arr = Array.isArray(lines) ? lines : [lines];
57
+ setSystemNotice(arr);
58
+ // Auto-clear after a few seconds so the buffer doesn't grow with stale notices
59
+ setTimeout(() => setSystemNotice([]), 5000);
60
+ }
61
+ /** Run an inferred intent as if the user had typed the equivalent slash command. */
62
+ async function executeIntent(intent) {
63
+ const cmdText = describeIntent(intent);
64
+ appendEvent(header.sessionId, {
65
+ type: "intent_inferred",
66
+ ts: nowIso(),
67
+ pattern: intent.matchedPattern ?? "(llm)",
68
+ intent: intent.command,
69
+ accepted: true,
70
+ });
71
+ // Re-enter handleSubmit with the slash form — reuses the existing dispatch path
72
+ await handleSubmit(cmdText);
73
+ }
74
+ async function handleSubmit(raw) {
75
+ const text = raw.trim();
76
+ if (!text) {
77
+ // Empty submit while a pending intent is shown → accept
78
+ if (pendingIntent) {
79
+ const intent = pendingIntent;
80
+ recordAcceptance(acceptanceLogRef.current, intent.matchedPattern);
81
+ setPendingIntent(null);
82
+ setInput("");
83
+ await executeIntent(intent);
84
+ return;
85
+ }
86
+ return;
87
+ }
88
+ setInput("");
89
+ // Pending-intent confirm step ──────────────────────────────────────
90
+ if (pendingIntent) {
91
+ const lower = text.toLowerCase();
92
+ if (lower === "y" || lower === "yes") {
93
+ const intent = pendingIntent;
94
+ recordAcceptance(acceptanceLogRef.current, intent.matchedPattern);
95
+ setPendingIntent(null);
96
+ await executeIntent(intent);
97
+ return;
98
+ }
99
+ if (lower === "n" || lower === "no") {
100
+ appendEvent(header.sessionId, {
101
+ type: "intent_inferred",
102
+ ts: nowIso(),
103
+ pattern: pendingIntent.matchedPattern ?? "(llm)",
104
+ intent: pendingIntent.command,
105
+ accepted: false,
106
+ });
107
+ setPendingIntent(null);
108
+ pushSystem("Intent declined. Type your message normally.");
109
+ return;
110
+ }
111
+ if (lower === "e" || lower === "edit") {
112
+ // Drop the intent into the input box for tweaking
113
+ const cmdText = describeIntent(pendingIntent);
114
+ setPendingIntent(null);
115
+ setInput(cmdText);
116
+ return;
117
+ }
118
+ // Anything else cancels the pending intent and is treated as the new input
119
+ setPendingIntent(null);
120
+ pushSystem("Intent declined.");
121
+ // Fall through to normal handling of `text`
122
+ }
123
+ // Slash command path
124
+ if (text.startsWith("/")) {
125
+ const ctx = {
126
+ sessionId: header.sessionId,
127
+ buffer,
128
+ provider: header.provider,
129
+ model: header.model,
130
+ };
131
+ appendEvent(header.sessionId, {
132
+ type: "command",
133
+ ts: nowIso(),
134
+ name: text.split(/\s+/)[0],
135
+ args: text.split(/\s+/).slice(1),
136
+ });
137
+ const result = await dispatchCommand(text, ctx);
138
+ if (!result)
139
+ return; // not a command, fall through (shouldn't happen since we checked /)
140
+ switch (result.kind) {
141
+ case "ok":
142
+ if (result.message)
143
+ pushSystem(result.message);
144
+ break;
145
+ case "show":
146
+ pushSystem(result.lines);
147
+ break;
148
+ case "clear-buffer":
149
+ setBuffer([]);
150
+ pushSystem("Buffer cleared (session log preserved).");
151
+ break;
152
+ case "switch-provider": {
153
+ try {
154
+ const p = buildProvider(configRef.current, result.provider, result.model);
155
+ setHeader((h) => ({ ...h, provider: p.name, model: p.model }));
156
+ pushSystem(`Switched to ${p.name} / ${p.model}`);
157
+ }
158
+ catch (err) {
159
+ pushSystem(`Failed to switch provider: ${err instanceof Error ? err.message : String(err)}`);
160
+ }
161
+ break;
162
+ }
163
+ case "exit":
164
+ appendEvent(header.sessionId, { type: "session_end", ts: nowIso(), reason: "quit" });
165
+ if (onExit)
166
+ onExit();
167
+ exit();
168
+ break;
169
+ case "pin":
170
+ if (pinnedIds.includes(result.memoryId)) {
171
+ pushSystem(`Already pinned: ${result.memoryId}`);
172
+ }
173
+ else {
174
+ setPinnedIds((p) => [...p, result.memoryId]);
175
+ appendEvent(header.sessionId, { type: "pin", ts: nowIso(), memory_id: result.memoryId });
176
+ pushSystem(`Pinned ${result.memoryId} — included in every turn until /unpin`);
177
+ }
178
+ break;
179
+ case "unpin":
180
+ if (!pinnedIds.includes(result.memoryId)) {
181
+ pushSystem(`Not pinned: ${result.memoryId}`);
182
+ }
183
+ else {
184
+ setPinnedIds((p) => p.filter((id) => id !== result.memoryId));
185
+ appendEvent(header.sessionId, { type: "unpin", ts: nowIso(), memory_id: result.memoryId });
186
+ pushSystem(`Unpinned ${result.memoryId}`);
187
+ }
188
+ break;
189
+ case "scope":
190
+ setScope(result.scope);
191
+ pushSystem(`Recall scope set to ${result.scope}`);
192
+ break;
193
+ case "threshold":
194
+ setThreshold(result.value);
195
+ pushSystem(`Confidence threshold set to ${result.value}`);
196
+ break;
197
+ case "preview-recall": {
198
+ const db = GnosysDB.openCentral();
199
+ if (!db.isAvailable()) {
200
+ pushSystem("Central DB unavailable");
201
+ db.close();
202
+ break;
203
+ }
204
+ try {
205
+ const recalled = runRecall(db, {
206
+ query: result.query,
207
+ scope,
208
+ projectId,
209
+ threshold,
210
+ pinnedIds,
211
+ });
212
+ if (recalled.memories.length === 0) {
213
+ pushSystem(`No memories matched "${result.query}"`);
214
+ }
215
+ else {
216
+ const lines = [`Recall preview (${recalled.memories.length} match${recalled.memories.length === 1 ? "" : "es"}):`];
217
+ for (const m of recalled.memories) {
218
+ const tag = m.pinned ? " [pinned]" : "";
219
+ lines.push(` ${m.id.padEnd(14)} ${m.confidence.toFixed(2)} ${m.title}${tag}`);
220
+ }
221
+ pushSystem(lines);
222
+ }
223
+ }
224
+ finally {
225
+ db.close();
226
+ }
227
+ break;
228
+ }
229
+ case "reinforce": {
230
+ const db = GnosysDB.openCentral();
231
+ if (!db.isAvailable()) {
232
+ pushSystem("Central DB unavailable");
233
+ db.close();
234
+ break;
235
+ }
236
+ try {
237
+ const ok = reinforceMemory(db, result.memoryId);
238
+ pushSystem(ok ? `Reinforced ${result.memoryId}` : `Memory not found: ${result.memoryId}`);
239
+ }
240
+ finally {
241
+ db.close();
242
+ }
243
+ break;
244
+ }
245
+ case "remember": {
246
+ const db = GnosysDB.openCentral();
247
+ if (!db.isAvailable()) {
248
+ pushSystem("Central DB unavailable");
249
+ db.close();
250
+ break;
251
+ }
252
+ try {
253
+ const promoted = await promoteToMemory(db, {
254
+ content: result.text,
255
+ source: "remember",
256
+ sessionId: header.sessionId,
257
+ projectId,
258
+ config: configRef.current,
259
+ });
260
+ appendEvent(header.sessionId, {
261
+ type: "memory_promoted",
262
+ ts: nowIso(),
263
+ memory_id: promoted.id,
264
+ source: "remember",
265
+ });
266
+ pushSystem(`Saved as ${promoted.id} — "${promoted.title}" [${promoted.category}]`);
267
+ }
268
+ catch (err) {
269
+ pushSystem(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
270
+ }
271
+ finally {
272
+ db.close();
273
+ }
274
+ break;
275
+ }
276
+ case "save-turn": {
277
+ const pair = lastExchange(buffer);
278
+ if (!pair) {
279
+ pushSystem("No recent user+assistant exchange to save.");
280
+ break;
281
+ }
282
+ const db = GnosysDB.openCentral();
283
+ if (!db.isAvailable()) {
284
+ pushSystem("Central DB unavailable");
285
+ db.close();
286
+ break;
287
+ }
288
+ try {
289
+ const promoted = await promoteToMemory(db, {
290
+ content: formatExchange(pair),
291
+ source: "save-turn",
292
+ sessionId: header.sessionId,
293
+ projectId,
294
+ config: configRef.current,
295
+ });
296
+ appendEvent(header.sessionId, {
297
+ type: "memory_promoted",
298
+ ts: nowIso(),
299
+ memory_id: promoted.id,
300
+ source: "save-turn",
301
+ });
302
+ pushSystem(`Saved exchange as ${promoted.id} — "${promoted.title}" [${promoted.category}]`);
303
+ }
304
+ catch (err) {
305
+ pushSystem(`Failed to save: ${err instanceof Error ? err.message : String(err)}`);
306
+ }
307
+ finally {
308
+ db.close();
309
+ }
310
+ break;
311
+ }
312
+ case "attach": {
313
+ // Ingest the file via the existing multimodal pipeline, then pin
314
+ // any resulting memories so they're injected into the next turn.
315
+ pushSystem(`Ingesting ${result.filePath}…`);
316
+ try {
317
+ const { ingestFile } = await import("../multimodalIngest.js");
318
+ const { GnosysResolver } = await import("../resolver.js");
319
+ const r = new GnosysResolver();
320
+ await r.resolve();
321
+ const stores = r.getStores();
322
+ if (stores.length === 0) {
323
+ pushSystem("No store found — run 'gnosys init' first.");
324
+ break;
325
+ }
326
+ const ingestResult = await ingestFile({
327
+ filePath: result.filePath,
328
+ storePath: stores[0].path,
329
+ });
330
+ const newIds = ingestResult.memories.map((m) => m.id).slice(0, 10);
331
+ for (const id of newIds) {
332
+ if (!pinnedIds.includes(id)) {
333
+ setPinnedIds((p) => [...p, id]);
334
+ appendEvent(header.sessionId, { type: "memory_promoted", ts: nowIso(), memory_id: id, source: "attach" });
335
+ appendEvent(header.sessionId, { type: "pin", ts: nowIso(), memory_id: id });
336
+ }
337
+ }
338
+ pushSystem(`Ingested ${newIds.length} memor${newIds.length === 1 ? "y" : "ies"} from ${result.filePath} — pinned for this session.`);
339
+ }
340
+ catch (err) {
341
+ pushSystem(`Attach failed: ${err instanceof Error ? err.message : String(err)}`);
342
+ }
343
+ break;
344
+ }
345
+ case "focus": {
346
+ const updated = applyFocus(focusState, buffer, result.topic, nowIso());
347
+ setFocusState(updated.state);
348
+ setBuffer(updated.buffer);
349
+ appendEvent(header.sessionId, {
350
+ type: "focus",
351
+ ts: nowIso(),
352
+ topic: result.topic,
353
+ previous_topic: updated.previousTopic ?? undefined,
354
+ });
355
+ pushSystem(updated.previousTopic
356
+ ? `Focus: ${result.topic} (${updated.previousTopic} stashed — /resume-focus ${updated.previousTopic})`
357
+ : `Focus: ${result.topic}`);
358
+ break;
359
+ }
360
+ case "branch": {
361
+ if (buffer.length === 0) {
362
+ pushSystem("Nothing to branch — buffer is empty.");
363
+ break;
364
+ }
365
+ setFocusState(applyBranch(focusState, buffer, nowIso()));
366
+ appendEvent(header.sessionId, {
367
+ type: "branch",
368
+ ts: nowIso(),
369
+ from_session: header.sessionId,
370
+ new_session: header.sessionId, // Phase 7 keeps the same session log
371
+ });
372
+ pushSystem(`Branch saved (${focusState.branches.length + 1} on stack — /resume-focus to pop the latest).`);
373
+ break;
374
+ }
375
+ case "resume-focus": {
376
+ if (result.topic) {
377
+ const restored = applyResumeFocus(focusState, buffer, result.topic, nowIso());
378
+ if (!restored) {
379
+ pushSystem(`No saved focus named "${result.topic}".`);
380
+ break;
381
+ }
382
+ setFocusState(restored.state);
383
+ setBuffer(restored.buffer);
384
+ pushSystem(`Resumed focus: ${result.topic}`);
385
+ }
386
+ else {
387
+ // No arg → pop most recent branch
388
+ const popped = popBranch(focusState);
389
+ if (!popped) {
390
+ pushSystem("No branches on the stack and no topic given.");
391
+ break;
392
+ }
393
+ setFocusState(popped.state);
394
+ setBuffer(popped.buffer);
395
+ pushSystem(`Restored branch (focus: ${popped.topic})`);
396
+ }
397
+ break;
398
+ }
399
+ case "export-session": {
400
+ try {
401
+ const { writeFileSync } = await import("fs");
402
+ const path = await import("path");
403
+ const md = renderSessionAsMarkdown(buffer, header.sessionId);
404
+ const targetPath = path.resolve(result.filePath);
405
+ writeFileSync(targetPath, md, "utf-8");
406
+ pushSystem(`Exported ${buffer.length} turn(s) to ${targetPath}`);
407
+ }
408
+ catch (err) {
409
+ pushSystem(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
410
+ }
411
+ break;
412
+ }
413
+ case "search-chats": {
414
+ const { searchSessions } = await import("./session.js");
415
+ const matches = searchSessions(result.query, 20);
416
+ if (matches.length === 0) {
417
+ pushSystem(`No matches for: ${result.query}`);
418
+ break;
419
+ }
420
+ const lines = [`${matches.length} match(es):`];
421
+ for (const m of matches.slice(0, 15)) {
422
+ const text = (() => {
423
+ const e = m.event;
424
+ switch (e.type) {
425
+ case "user":
426
+ case "assistant":
427
+ return e.text;
428
+ case "command":
429
+ return `${e.name} ${e.args.join(" ")}`;
430
+ case "focus":
431
+ return e.topic;
432
+ case "recall":
433
+ return e.query;
434
+ default:
435
+ return "";
436
+ }
437
+ })();
438
+ const preview = text.length > 80 ? text.slice(0, 77) + "..." : text;
439
+ lines.push(` ${m.sessionId.slice(0, 12)}… [${m.event.type}] ${preview}`);
440
+ }
441
+ pushSystem(lines);
442
+ break;
443
+ }
444
+ case "dream-here": {
445
+ // Run a focused dream cycle scoped to the memories surfaced in this session.
446
+ // Pulls the cited_memory_ids from the session log and uses them as the
447
+ // workset for the dream engine.
448
+ pushSystem("Starting dream cycle for this session…");
449
+ try {
450
+ const { GnosysDB } = await import("../db.js");
451
+ const { GnosysDreamEngine } = await import("../dream.js");
452
+ const { readSession } = await import("./session.js");
453
+ const db = GnosysDB.openCentral();
454
+ if (!db.isAvailable()) {
455
+ pushSystem("Central DB unavailable");
456
+ db.close();
457
+ break;
458
+ }
459
+ try {
460
+ const events = readSession(header.sessionId);
461
+ const cited = new Set();
462
+ for (const e of events) {
463
+ if (e.type === "assistant" && e.cited_memory_ids) {
464
+ for (const id of e.cited_memory_ids)
465
+ cited.add(id);
466
+ }
467
+ if (e.type === "recall") {
468
+ for (const id of e.memory_ids)
469
+ cited.add(id);
470
+ }
471
+ }
472
+ if (cited.size === 0) {
473
+ pushSystem("No memories surfaced this session yet — nothing to dream on.");
474
+ db.close();
475
+ break;
476
+ }
477
+ const engine = new GnosysDreamEngine(db, configRef.current, {
478
+ enabled: true,
479
+ idleMinutes: 0,
480
+ maxRuntimeMinutes: 5,
481
+ selfCritique: true,
482
+ generateSummaries: false,
483
+ discoverRelationships: true,
484
+ minMemories: 1,
485
+ provider: configRef.current.dream?.provider ?? "ollama",
486
+ model: configRef.current.dream?.model,
487
+ });
488
+ const report = await engine.dream();
489
+ pushSystem(`Dream complete — duration ${report.durationMs}ms; surfaced ${cited.size} session memories.`);
490
+ }
491
+ finally {
492
+ db.close();
493
+ }
494
+ }
495
+ catch (err) {
496
+ pushSystem(`Dream-here failed: ${err instanceof Error ? err.message : String(err)}`);
497
+ }
498
+ break;
499
+ }
500
+ case "error":
501
+ pushSystem(`Error: ${result.message}`);
502
+ break;
503
+ }
504
+ return;
505
+ }
506
+ // Free-text intent detection ──────────────────────────────────────
507
+ // Try to map the user's natural-language input to a slash command.
508
+ // If we find a match: auto-accept when the user has confirmed this
509
+ // pattern N times before, or render a [Y/n/edit] confirm prompt.
510
+ {
511
+ const intent = await inferIntent(text, configRef.current);
512
+ if (intent) {
513
+ const auto = shouldAutoAccept(acceptanceLogRef.current, intent.matchedPattern);
514
+ if (auto && !isDestructive(intent.command)) {
515
+ await executeIntent(intent);
516
+ return;
517
+ }
518
+ setPendingIntent(intent);
519
+ appendEvent(header.sessionId, {
520
+ type: "intent_inferred",
521
+ ts: nowIso(),
522
+ pattern: intent.matchedPattern ?? "(llm)",
523
+ intent: intent.command,
524
+ accepted: false, // not yet
525
+ });
526
+ return;
527
+ }
528
+ }
529
+ // Chat turn path
530
+ const userTurn = { role: "user", text, ts: nowIso() };
531
+ setBuffer((b) => [...b, userTurn]);
532
+ appendEvent(header.sessionId, { type: "user", ts: userTurn.ts, text });
533
+ // Auto-promote heuristic — non-blocking hint; user can /save-turn
534
+ // explicitly or ignore. We don't auto-write without consent.
535
+ const hint = detectAutoPromote(text);
536
+ if (hint) {
537
+ pushSystem(`hint: that looks like a ${hint.reason} — type /save-turn after the response to capture it.`);
538
+ }
539
+ // File-path detection — suggest /attach if the user pasted a path
540
+ const detectedPath = detectFilePath(text);
541
+ if (detectedPath) {
542
+ pushSystem(`hint: detected file path "${detectedPath}" — type "/attach ${detectedPath}" to ingest it.`);
543
+ }
544
+ setStatus({ kind: "thinking" });
545
+ // Run recall before the LLM call so the model sees relevant memories.
546
+ let recalled = [];
547
+ try {
548
+ const db = GnosysDB.openCentral();
549
+ if (db.isAvailable()) {
550
+ const query = buildRecallQuery(text, [...buffer, userTurn]);
551
+ const result = runRecall(db, {
552
+ query,
553
+ scope,
554
+ projectId,
555
+ threshold,
556
+ pinnedIds,
557
+ });
558
+ recalled = result.memories;
559
+ appendEvent(header.sessionId, {
560
+ type: "recall",
561
+ ts: nowIso(),
562
+ query,
563
+ memory_ids: recalled.map((m) => m.id),
564
+ scope,
565
+ });
566
+ }
567
+ db.close();
568
+ }
569
+ catch {
570
+ // Recall errors shouldn't block the chat — proceed without recall
571
+ }
572
+ setStatus({ kind: "streaming", partial: "" });
573
+ try {
574
+ let partial = "";
575
+ const result = await runTurn(configRef.current, {
576
+ buffer: [...buffer, userTurn],
577
+ userInput: text,
578
+ recalled,
579
+ onToken: (tok) => {
580
+ partial += tok;
581
+ setStatus({ kind: "streaming", partial });
582
+ },
583
+ });
584
+ // Check for gnosys-choose fence — if present, strip it from the
585
+ // visible turn and surface as an interactive picker.
586
+ const fence = extractChooseFence(result.text);
587
+ let visibleText = result.text;
588
+ let pendingBlock = null;
589
+ if (fence?.kind === "ok") {
590
+ visibleText = [fence.before, fence.after].filter((s) => s.length > 0).join("\n\n");
591
+ pendingBlock = fence.block;
592
+ appendEvent(header.sessionId, {
593
+ type: "choice_offered",
594
+ ts: nowIso(),
595
+ prompt: fence.block.prompt,
596
+ option_ids: fence.block.options.map((o) => o.id),
597
+ });
598
+ }
599
+ else if (fence?.kind === "parse-error") {
600
+ // Fail-soft: leave the raw fence in the visible text so the user can
601
+ // see what the LLM tried to emit. Log the parse error for debugging.
602
+ pushSystem(`malformed gnosys-choose fence: ${fence.reason}`);
603
+ appendEvent(header.sessionId, {
604
+ type: "error",
605
+ ts: nowIso(),
606
+ message: `gnosys-choose parse error: ${fence.reason}`,
607
+ });
608
+ }
609
+ const assistantTurn = {
610
+ role: "assistant",
611
+ text: visibleText,
612
+ ts: nowIso(),
613
+ provider: result.provider,
614
+ model: result.model,
615
+ citedMemoryIds: result.recalledIds,
616
+ };
617
+ setBuffer((b) => [...b, assistantTurn]);
618
+ appendEvent(header.sessionId, {
619
+ type: "assistant",
620
+ ts: assistantTurn.ts,
621
+ text: result.text, // log the FULL text (including fence) for fidelity
622
+ provider: result.provider,
623
+ model: result.model,
624
+ cited_memory_ids: result.recalledIds,
625
+ });
626
+ if (pendingBlock) {
627
+ setPendingChoice(pendingBlock);
628
+ }
629
+ setStatus({ kind: "idle" });
630
+ }
631
+ catch (err) {
632
+ const message = err instanceof Error ? err.message : String(err);
633
+ setStatus({ kind: "error", message });
634
+ appendEvent(header.sessionId, { type: "error", ts: nowIso(), message });
635
+ }
636
+ }
637
+ // Trap Ctrl-C → graceful exit, mirroring /quit
638
+ useEffect(() => {
639
+ const handler = () => {
640
+ appendEvent(header.sessionId, { type: "session_end", ts: nowIso(), reason: "quit" });
641
+ if (onExit)
642
+ onExit();
643
+ exit();
644
+ };
645
+ process.on("SIGINT", handler);
646
+ return () => {
647
+ process.off("SIGINT", handler);
648
+ };
649
+ }, []);
650
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ChatHeader, { info: header, recallScope: scope, threshold: threshold, pinnedCount: pinnedIds.length, focus: focusState.current, branchCount: focusState.branches.length }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [buffer.map((turn, i) => (_jsx(ConversationTurn, { turn: turn }, i))), status.kind === "streaming" && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", children: "assistant:" }), _jsx(Text, { children: status.partial })] }))] }), systemNotice.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: systemNotice.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) })), _jsx(Box, { marginTop: 1, children: _jsx(StatusLine, { status: status }) }), pendingIntent && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "magenta", children: ["inferred: ", describeIntent(pendingIntent), isDestructive(pendingIntent.command) ? " (destructive)" : ""] }), _jsx(Text, { dimColor: true, children: "[Y]es \u00B7 [N]o \u00B7 [E]dit \u00B7 or type a new message" })] })), pendingChoice ? (_jsx(ChoosePicker, { block: pendingChoice, onSelect: async (option) => {
651
+ const selectionText = formatSelection(option);
652
+ appendEvent(header.sessionId, {
653
+ type: "choice_made",
654
+ ts: nowIso(),
655
+ option_id: option.id,
656
+ label: option.label,
657
+ });
658
+ setPendingChoice(null);
659
+ // Fire the selection as a synthetic user turn — runs through
660
+ // the normal handleSubmit path so recall + LLM happen.
661
+ await handleSubmit(selectionText);
662
+ } })) : (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: "> " }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: handleSubmit })] }))] }));
663
+ };
664
+ /** Render the conversation buffer as a human-readable markdown transcript for /export. */
665
+ function renderSessionAsMarkdown(buffer, sessionId) {
666
+ const lines = [
667
+ `# Gnosys chat session ${sessionId}`,
668
+ ``,
669
+ `_Exported ${new Date().toISOString()}_`,
670
+ ``,
671
+ `---`,
672
+ ``,
673
+ ];
674
+ for (const turn of buffer) {
675
+ if (turn.role === "user") {
676
+ lines.push(`## You`);
677
+ lines.push(turn.text);
678
+ lines.push(``);
679
+ }
680
+ else if (turn.role === "assistant") {
681
+ const provider = turn.provider ? ` (${turn.provider}/${turn.model})` : "";
682
+ lines.push(`## Assistant${provider}`);
683
+ lines.push(turn.text);
684
+ if (turn.citedMemoryIds && turn.citedMemoryIds.length > 0) {
685
+ lines.push(``);
686
+ lines.push(`_cited: ${turn.citedMemoryIds.map((id) => `\`${id}\``).join(", ")}_`);
687
+ }
688
+ lines.push(``);
689
+ }
690
+ else if (turn.role === "system") {
691
+ lines.push(`> ${turn.text}`);
692
+ lines.push(``);
693
+ }
694
+ }
695
+ return lines.join("\n");
696
+ }
697
+ /** Detect a file path in user input — returns the path if found, null otherwise. */
698
+ function detectFilePath(text) {
699
+ // Match common file path formats — absolute, ~, or ./relative — with extensions
700
+ const m = text.match(/(?<![\w/])((?:~|\.{1,2})?\/[^\s]+\.(?:pdf|md|txt|docx|png|jpg|jpeg|gif|mp3|wav|m4a|mp4|mov|webm))/i);
701
+ return m ? m[1] : null;
702
+ }
703
+ const ChoosePicker = ({ block, onSelect }) => {
704
+ const items = block.options.map((opt) => ({
705
+ label: opt.detail ? `${opt.label} — ${opt.detail}` : opt.label,
706
+ value: opt.id,
707
+ }));
708
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "magenta", children: block.prompt }), _jsx(SelectInput, { items: items, onSelect: (item) => {
709
+ const picked = block.options.find((o) => o.id === item.value);
710
+ if (picked)
711
+ onSelect(picked);
712
+ } })] }));
713
+ };
714
+ const ChatHeader = ({ info, recallScope, threshold, pinnedCount, focus, branchCount }) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", children: "gnosys chat" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["session=", info.sessionId.slice(0, 8), "\u2026"] }), _jsx(Text, { children: " " }), info.projectName && (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["project=", info.projectName] }), _jsx(Text, { children: " " })] })), _jsxs(Text, { dimColor: true, children: [info.provider, "/", info.model] })] }), _jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["recall: scope=", recallScope] }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["threshold=", threshold.toFixed(2)] }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["pinned=", pinnedCount] }), focus && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["focus=", focus] })] })), branchCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: ["branches=", branchCount] })] }))] })] }));
715
+ const ConversationTurn = ({ turn }) => {
716
+ if (turn.role === "user") {
717
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: "yellow", children: "you:" }), _jsx(Text, { children: turn.text })] }));
718
+ }
719
+ if (turn.role === "assistant") {
720
+ const cited = turn.citedMemoryIds ?? [];
721
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: "cyan", children: "assistant:" }), _jsx(Text, { children: turn.text }), cited.length > 0 && (_jsxs(Text, { dimColor: true, children: ["cited: ", cited.map((id) => `[${id}]`).join(" ")] }))] }));
722
+ }
723
+ return (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { dimColor: true, children: ["\u00B7 ", turn.text] }) }));
724
+ };
725
+ const StatusLine = ({ status }) => {
726
+ switch (status.kind) {
727
+ case "idle":
728
+ return _jsx(Text, { dimColor: true, children: "ready \u2014 type /help for commands, /quit to exit" });
729
+ case "thinking":
730
+ return (_jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " thinking\u2026"] }));
731
+ case "streaming":
732
+ return (_jsxs(Text, { color: "cyan", children: [_jsx(Spinner, { type: "dots" }), " streaming\u2026"] }));
733
+ case "error":
734
+ return _jsxs(Text, { color: "red", children: ["error: ", status.message] });
735
+ }
736
+ };
737
+ //# sourceMappingURL=render.js.map