verbalcoding 0.2.11 → 0.2.13

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 (235) hide show
  1. package/.env.example +98 -2
  2. package/README.es.md +134 -0
  3. package/README.fr.md +134 -0
  4. package/README.ja.md +134 -0
  5. package/README.ko.md +134 -0
  6. package/README.md +118 -74
  7. package/README.ru.md +134 -0
  8. package/README.zh.md +133 -0
  9. package/app-node/agent_adapters.mjs +37 -5
  10. package/app-node/agent_adapters.test.mjs +27 -1
  11. package/app-node/agent_detect.mjs +73 -0
  12. package/app-node/agent_detect.test.mjs +77 -0
  13. package/app-node/agent_routing.mjs +148 -0
  14. package/app-node/agent_routing.test.mjs +138 -0
  15. package/app-node/agent_turn.mjs +86 -0
  16. package/app-node/agent_turn.test.mjs +109 -0
  17. package/app-node/bridge_context.mjs +73 -0
  18. package/app-node/bridge_context.test.mjs +54 -0
  19. package/app-node/bridge_state.mjs +4 -0
  20. package/app-node/bridge_wireup.test.mjs +462 -0
  21. package/app-node/cli_install.test.mjs +31 -0
  22. package/app-node/cross_agent_routing.test.mjs +78 -0
  23. package/app-node/discord_command_router.mjs +204 -0
  24. package/app-node/discord_command_router.test.mjs +311 -0
  25. package/app-node/discord_voice_setup.mjs +251 -0
  26. package/app-node/discord_voice_setup.test.mjs +86 -0
  27. package/app-node/hermes_profiles.test.mjs +12 -1
  28. package/app-node/install_config.mjs +113 -3
  29. package/app-node/install_config.test.mjs +8 -0
  30. package/app-node/instance_doctor.test.mjs +9 -0
  31. package/app-node/instances.test.mjs +8 -1
  32. package/app-node/main.mjs +513 -1058
  33. package/app-node/mcp_tools.test.mjs +7 -0
  34. package/app-node/notification_handler.mjs +89 -0
  35. package/app-node/notification_handler.test.mjs +187 -0
  36. package/app-node/notify.mjs +73 -0
  37. package/app-node/notify.test.mjs +68 -0
  38. package/app-node/plan_dispatcher.mjs +215 -0
  39. package/app-node/plan_dispatcher.test.mjs +101 -0
  40. package/app-node/plan_mode.mjs +203 -0
  41. package/app-node/plan_mode.test.mjs +231 -0
  42. package/app-node/progress_handler.mjs +220 -0
  43. package/app-node/progress_handler.test.mjs +193 -0
  44. package/app-node/progress_speech.mjs +54 -32
  45. package/app-node/progress_speech.test.mjs +12 -3
  46. package/app-node/project_sessions.mjs +5 -2
  47. package/app-node/project_sessions.test.mjs +7 -0
  48. package/app-node/research_mode.mjs +282 -0
  49. package/app-node/research_mode.test.mjs +264 -0
  50. package/app-node/restart_notice.mjs +3 -0
  51. package/app-node/restart_notice.test.mjs +11 -0
  52. package/app-node/session_ontology.mjs +271 -0
  53. package/app-node/session_ontology.test.mjs +130 -0
  54. package/app-node/smart_progress.mjs +94 -0
  55. package/app-node/smart_progress.test.mjs +66 -0
  56. package/app-node/stream_sentencer.mjs +91 -0
  57. package/app-node/stream_sentencer.test.mjs +129 -0
  58. package/app-node/streaming_tts_queue.mjs +52 -0
  59. package/app-node/streaming_tts_queue.test.mjs +64 -0
  60. package/app-node/stt_whisper.mjs +24 -0
  61. package/app-node/stt_whisper.test.mjs +32 -0
  62. package/app-node/text_routing.mjs +22 -0
  63. package/app-node/text_routing.test.mjs +23 -1
  64. package/app-node/tts_backends.mjs +537 -3
  65. package/app-node/tts_backends.test.mjs +454 -0
  66. package/app-node/tts_player.mjs +164 -0
  67. package/app-node/tts_player.test.mjs +202 -0
  68. package/app-node/tts_runtime.mjs +134 -0
  69. package/app-node/tts_runtime.test.mjs +89 -0
  70. package/app-node/tts_settings.mjs +150 -3
  71. package/app-node/tts_settings.test.mjs +204 -0
  72. package/app-node/tts_voice_config.mjs +136 -2
  73. package/app-node/tts_voice_config.test.mjs +94 -0
  74. package/app-node/utterance_router.mjs +216 -0
  75. package/app-node/utterance_router.test.mjs +236 -0
  76. package/app-node/voice_autojoin.mjs +37 -0
  77. package/app-node/voice_autojoin.test.mjs +59 -0
  78. package/app-node/voice_io.mjs +272 -0
  79. package/app-node/voice_io.test.mjs +102 -0
  80. package/app-node/voice_turn_runner.mjs +449 -0
  81. package/app-node/voice_turn_runner.test.mjs +289 -0
  82. package/docs/CONFIGURATION.md +79 -96
  83. package/docs/FRESH_INSTALL.md +105 -63
  84. package/docs/HARNESSES.md +58 -0
  85. package/docs/HARNESS_AIDER.md +50 -0
  86. package/docs/HARNESS_CLAUDE.md +56 -0
  87. package/docs/HARNESS_CODEX.md +56 -0
  88. package/docs/HARNESS_CURSOR.md +45 -0
  89. package/docs/HARNESS_GEMINI.md +45 -0
  90. package/docs/HARNESS_HERMES.md +57 -0
  91. package/docs/HARNESS_OPENCLAW.md +44 -0
  92. package/docs/HARNESS_OPENCODE.md +44 -0
  93. package/docs/HERMES_VOICE.md +65 -0
  94. package/docs/MULTI_INSTANCE.md +16 -0
  95. package/docs/README.md +50 -0
  96. package/docs/RELEASE.md +42 -19
  97. package/docs/ROADMAP.md +53 -0
  98. package/docs/TROUBLESHOOTING.md +126 -0
  99. package/docs/TTS_BACKENDS.md +227 -0
  100. package/docs/USAGE.md +94 -40
  101. package/docs/assets/figures/verbalcoding-flow.svg +1 -1
  102. package/docs/i18n/AGENTS.es.md +34 -0
  103. package/docs/i18n/AGENTS.fr.md +34 -0
  104. package/docs/i18n/AGENTS.ja.md +34 -0
  105. package/docs/i18n/AGENTS.ko.md +34 -0
  106. package/docs/i18n/AGENTS.ru.md +34 -0
  107. package/docs/i18n/AGENTS.zh.md +34 -0
  108. package/docs/i18n/CONFIGURATION.es.md +25 -0
  109. package/docs/i18n/CONFIGURATION.fr.md +25 -0
  110. package/docs/i18n/CONFIGURATION.ja.md +25 -0
  111. package/docs/i18n/CONFIGURATION.ko.md +25 -0
  112. package/docs/i18n/CONFIGURATION.ru.md +25 -0
  113. package/docs/i18n/CONFIGURATION.zh.md +25 -0
  114. package/docs/i18n/FRESH_INSTALL.es.md +27 -2
  115. package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
  116. package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
  117. package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
  118. package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
  119. package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
  120. package/docs/i18n/HARNESSES.es.md +58 -0
  121. package/docs/i18n/HARNESSES.fr.md +58 -0
  122. package/docs/i18n/HARNESSES.ja.md +58 -0
  123. package/docs/i18n/HARNESSES.ko.md +58 -0
  124. package/docs/i18n/HARNESSES.ru.md +58 -0
  125. package/docs/i18n/HARNESSES.zh.md +58 -0
  126. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  127. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  128. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  129. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  130. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  131. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  132. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  133. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  134. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  135. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  136. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  137. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  138. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  139. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  140. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  141. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  142. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  143. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  144. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  145. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  146. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  147. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  148. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  149. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  150. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  151. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  152. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  153. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  154. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  155. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  156. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  157. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  158. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  159. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  160. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  161. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  162. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  163. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  164. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  165. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  166. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  167. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  168. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  169. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  170. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  171. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  172. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  173. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  174. package/docs/i18n/HERMES_VOICE.es.md +46 -0
  175. package/docs/i18n/HERMES_VOICE.fr.md +46 -0
  176. package/docs/i18n/HERMES_VOICE.ja.md +46 -0
  177. package/docs/i18n/HERMES_VOICE.ko.md +65 -0
  178. package/docs/i18n/HERMES_VOICE.ru.md +46 -0
  179. package/docs/i18n/HERMES_VOICE.zh.md +46 -0
  180. package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
  181. package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
  182. package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
  183. package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
  184. package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
  185. package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
  186. package/docs/i18n/README.es.md +20 -134
  187. package/docs/i18n/README.fr.md +20 -134
  188. package/docs/i18n/README.ja.md +20 -134
  189. package/docs/i18n/README.ko.md +20 -133
  190. package/docs/i18n/README.ru.md +20 -134
  191. package/docs/i18n/README.zh.md +20 -133
  192. package/docs/i18n/RELEASE.es.md +26 -1
  193. package/docs/i18n/RELEASE.fr.md +26 -1
  194. package/docs/i18n/RELEASE.ja.md +26 -1
  195. package/docs/i18n/RELEASE.ko.md +26 -1
  196. package/docs/i18n/RELEASE.ru.md +26 -1
  197. package/docs/i18n/RELEASE.zh.md +26 -1
  198. package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
  199. package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
  200. package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
  201. package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
  202. package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
  203. package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
  204. package/docs/i18n/USAGE.es.md +25 -0
  205. package/docs/i18n/USAGE.fr.md +25 -0
  206. package/docs/i18n/USAGE.ja.md +25 -0
  207. package/docs/i18n/USAGE.ko.md +25 -0
  208. package/docs/i18n/USAGE.ru.md +25 -0
  209. package/docs/i18n/USAGE.zh.md +25 -0
  210. package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
  211. package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
  212. package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
  213. package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
  214. package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -0
  215. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  216. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  217. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  218. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  219. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  220. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  221. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  222. package/integrations/fireredtts2/mlx_llm.py +183 -0
  223. package/integrations/fireredtts2/synth.py +156 -0
  224. package/integrations/fireredtts2/synth_mlx.py +196 -0
  225. package/integrations/mlxaudio/synth.py +74 -0
  226. package/integrations/neuttsair/synth.py +104 -0
  227. package/integrations/omnivoice/synth.py +110 -0
  228. package/package.json +7 -1
  229. package/scripts/cli.mjs +88 -3
  230. package/scripts/doctor.mjs +115 -4
  231. package/scripts/install.mjs +20 -2
  232. package/scripts/install_fireredtts2.sh +109 -0
  233. package/scripts/install_mlxaudio.sh +34 -0
  234. package/scripts/install_mossttsnano.sh +46 -0
  235. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,271 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const SCHEMA_VERSION = 1;
6
+ const NODE_TYPES = new Set(['D', 'F', 'T', 'C', 'A', 'R']);
7
+ const EDGE_PREDICATES = new Set(['d', 't', 'u', 'p', 'r', 's']);
8
+ const TYPE_LABELS = {
9
+ D: { en: 'Decision', ko: '결정' },
10
+ F: { en: 'File', ko: '파일' },
11
+ T: { en: 'Tool', ko: '도구' },
12
+ C: { en: 'Concept', ko: '개념' },
13
+ A: { en: 'Agent', ko: '에이전트' },
14
+ R: { en: 'Result', ko: '결과' },
15
+ };
16
+
17
+ const FILE_RE = /\b[a-zA-Z0-9_./-]+\.(?:mjs|js|ts|tsx|jsx|py|md|json|yaml|yml|toml|sh|sql|go|rs|java|kt|swift|c|cpp|h|hpp)\b/g;
18
+ const TOOL_RE = /\b(?:tool|command|cli|npm|yarn|pnpm|pip|cargo|docker|kubectl|gh|git|ffmpeg|whisper-cli|claude|codex|gemini|aider|cursor)\b/gi;
19
+ const DECISION_RE = /\b[a-z][a-z0-9_]{2,}=[\w./-]{1,40}\b/g;
20
+
21
+ function lc(value) { return String(value || '').trim().toLowerCase(); }
22
+ function nowSec() { return Math.floor(Date.now() / 1000); }
23
+ function safeChannelKey(value) { return String(value || 'default').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); }
24
+ function defaultRootDir() { return path.join(os.homedir(), '.verbalcoding', 'memory'); }
25
+ function emptyState(channelKey) {
26
+ return {
27
+ v: SCHEMA_VERSION,
28
+ channelKey,
29
+ nodes: [],
30
+ edges: [],
31
+ meta: { updatedAt: nowSec(), nodeCount: 0, edgeCount: 0 },
32
+ };
33
+ }
34
+
35
+ export function buildExtractionPrompt({ text, language = 'en', knownNames = [] } = {}) {
36
+ const en = /^en/i.test(String(language || ''));
37
+ const lines = [];
38
+ lines.push(en
39
+ ? 'Extract a tiny knowledge graph from the message below. Reply ONLY with JSON of shape {"nodes":[{"t":"D|F|T|C|A|R","n":"name"}],"edges":[{"s":"name","p":"d|t|u|p|r|s","o":"name"}]}.'
40
+ : '아래 메시지에서 작은 지식 그래프를 추출해. JSON만 응답: {"nodes":[{"t":"D|F|T|C|A|R","n":"name"}],"edges":[{"s":"name","p":"d|t|u|p|r|s","o":"name"}]}.');
41
+ lines.push(en
42
+ ? 'Types: D=Decision (slot=value), F=File (path), T=Tool (command), C=Concept (noun), A=Agent (backend), R=Result (short summary).'
43
+ : '타입: D=결정(slot=value), F=파일, T=도구, C=개념, A=에이전트, R=결과 요약.');
44
+ lines.push(en
45
+ ? 'Predicates: d=decided, t=touched, u=used, p=produced, r=referenced, s=superseded_by.'
46
+ : '엣지: d=결정, t=수정, u=사용, p=생성, r=참조, s=덮어쓰기.');
47
+ if (knownNames.length) {
48
+ lines.push(en ? `Already known names (prefer these for dedup): ${knownNames.slice(0, 20).join(', ')}` : `이미 등록된 이름(중복 방지에 우선): ${knownNames.slice(0, 20).join(', ')}`);
49
+ }
50
+ lines.push(en ? `Message:\n${text}` : `메시지:\n${text}`);
51
+ lines.push(en ? 'Return ONLY the JSON object, no markdown.' : 'JSON 객체만 반환, 마크다운 없이.');
52
+ return lines.join('\n\n');
53
+ }
54
+
55
+ export function parseExtractionJson(raw) {
56
+ const t = String(raw || '').trim();
57
+ const fenceMatch = t.match(/```(?:json)?\s*([\s\S]+?)```/);
58
+ const body = fenceMatch ? fenceMatch[1] : t;
59
+ const firstBrace = body.indexOf('{');
60
+ const lastBrace = body.lastIndexOf('}');
61
+ if (firstBrace === -1 || lastBrace <= firstBrace) return { nodes: [], edges: [] };
62
+ try {
63
+ const parsed = JSON.parse(body.slice(firstBrace, lastBrace + 1));
64
+ const nodes = Array.isArray(parsed?.nodes) ? parsed.nodes : [];
65
+ const edges = Array.isArray(parsed?.edges) ? parsed.edges : [];
66
+ const nameToId = new Map();
67
+ const parsedNodes = [];
68
+ for (const n of nodes) {
69
+ if (!n || !n.t || !n.n) continue;
70
+ const id = `e_${n.t}_${String(n.n).toLowerCase()}`;
71
+ nameToId.set(String(n.n).toLowerCase(), id);
72
+ parsedNodes.push({ id, t: n.t, n: n.n });
73
+ }
74
+ const parsedEdges = [];
75
+ for (const e of edges) {
76
+ if (!e || !e.p) continue;
77
+ const s = nameToId.get(String(e.s || '').toLowerCase());
78
+ const o = nameToId.get(String(e.o || '').toLowerCase());
79
+ if (!s || !o) continue;
80
+ parsedEdges.push({ s, p: e.p, o });
81
+ }
82
+ return { nodes: parsedNodes, edges: parsedEdges };
83
+ } catch { return { nodes: [], edges: [] }; }
84
+ }
85
+
86
+ export function createSessionOntology({ rootDir, channelKey, maxNodes = 40, maxEdges = 80, fsApi = fs } = {}) {
87
+ const root = rootDir || defaultRootDir();
88
+ const channel = safeChannelKey(channelKey);
89
+ const filePath = path.join(root, `${channel}.json`);
90
+ let state = emptyState(channel);
91
+ let nextId = 1;
92
+
93
+ function nodeKey(node) { return `${node.t}::${lc(node.n)}`; }
94
+ function findNodeId(t, name) {
95
+ const key = `${t}::${lc(name)}`;
96
+ for (const node of state.nodes) if (nodeKey(node) === key) return node.id;
97
+ return null;
98
+ }
99
+ function evictIfNeeded() {
100
+ while (state.nodes.length > maxNodes) {
101
+ const idx = state.nodes.findIndex(n => n.t !== 'D');
102
+ if (idx === -1) break;
103
+ const evictedId = state.nodes[idx].id;
104
+ state.nodes.splice(idx, 1);
105
+ state.edges = state.edges.filter(e => e.s !== evictedId && e.o !== evictedId);
106
+ }
107
+ while (state.edges.length > maxEdges) state.edges.shift();
108
+ }
109
+
110
+ function add({ nodes = [], edges = [], supersedes = [] } = {}) {
111
+ const idMap = new Map();
112
+ const ts = nowSec();
113
+ const addedNodes = [];
114
+ for (const raw of nodes) {
115
+ if (!raw || !NODE_TYPES.has(raw.t)) continue;
116
+ const name = String(raw.n || '').trim();
117
+ if (!name) continue;
118
+ const existing = findNodeId(raw.t, name);
119
+ if (existing) { idMap.set(raw.id || existing, existing); continue; }
120
+ const node = { id: `n${nextId++}`, t: raw.t, n: name.slice(0, 80), ts: raw.ts || ts };
121
+ if (raw.by) node.by = String(raw.by).slice(0, 24);
122
+ state.nodes.push(node);
123
+ idMap.set(raw.id || node.id, node.id);
124
+ addedNodes.push(node.id);
125
+ }
126
+ for (const raw of edges) {
127
+ if (!raw || !EDGE_PREDICATES.has(raw.p)) continue;
128
+ const s = idMap.get(raw.s) || raw.s;
129
+ const o = idMap.get(raw.o) || raw.o;
130
+ if (!s || !o) continue;
131
+ const exists = state.edges.some(e => e.s === s && e.p === raw.p && e.o === o);
132
+ if (exists) continue;
133
+ state.edges.push({ s, p: raw.p, o, ts: raw.ts || ts });
134
+ }
135
+ for (const sup of supersedes) {
136
+ if (!sup) continue;
137
+ const oldId = idMap.get(sup.old) || sup.old;
138
+ const newId = idMap.get(sup.new) || sup.new;
139
+ if (!oldId || !newId) continue;
140
+ state.edges.push({ s: newId, p: 's', o: oldId, ts });
141
+ }
142
+ evictIfNeeded();
143
+ state.meta = { updatedAt: ts, nodeCount: state.nodes.length, edgeCount: state.edges.length };
144
+ return addedNodes;
145
+ }
146
+
147
+ function supersededIds() {
148
+ const sup = new Set();
149
+ for (const edge of state.edges) if (edge.p === 's') sup.add(edge.o);
150
+ return sup;
151
+ }
152
+ function neighborsOf(nodeIds) {
153
+ const out = new Set();
154
+ const ids = nodeIds instanceof Set ? nodeIds : new Set(nodeIds);
155
+ for (const edge of state.edges) {
156
+ if (ids.has(edge.s)) out.add(edge.o);
157
+ if (ids.has(edge.o)) out.add(edge.s);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ function serializeForHandoff({ language = 'en', maxBytes = 1500 } = {}) {
163
+ const en = /^en/i.test(String(language || ''));
164
+ const labels = en
165
+ ? { decisions: 'Active decisions', touched: 'Relevant files', tools: 'Tools in play', results: 'Recent results', empty: '(no prior session context)' }
166
+ : { decisions: '활성 결정', touched: '관련 파일', tools: '사용 도구', results: '최근 결과', empty: '(이전 세션 컨텍스트 없음)' };
167
+ const sup = supersededIds();
168
+ const live = state.nodes.filter(n => !sup.has(n.id));
169
+ if (!live.length) return labels.empty;
170
+ const decisionNodes = live.filter(n => n.t === 'D');
171
+ const decisionIds = new Set(decisionNodes.map(n => n.id));
172
+ const nearby = neighborsOf(decisionIds);
173
+ const fileNodes = live.filter(n => n.t === 'F' && nearby.has(n.id));
174
+ const toolNodes = live.filter(n => n.t === 'T' && nearby.has(n.id));
175
+ const resultNodes = live.filter(n => n.t === 'R').sort((a, b) => b.ts - a.ts).slice(0, 3);
176
+ const lines = [];
177
+ if (decisionNodes.length) {
178
+ lines.push(`### ${labels.decisions}`);
179
+ for (const n of decisionNodes.slice(-8)) lines.push(`- ${n.n}${n.by ? ` _(by ${n.by})_` : ''}`);
180
+ }
181
+ if (fileNodes.length) {
182
+ lines.push(`### ${labels.touched}`);
183
+ for (const n of fileNodes.slice(-6)) lines.push(`- ${n.n}`);
184
+ }
185
+ if (toolNodes.length) {
186
+ lines.push(`### ${labels.tools}`);
187
+ for (const n of toolNodes.slice(-6)) lines.push(`- ${n.n}`);
188
+ }
189
+ if (resultNodes.length) {
190
+ lines.push(`### ${labels.results}`);
191
+ for (const n of resultNodes) lines.push(`- ${n.n}${n.by ? ` _(${n.by})_` : ''}`);
192
+ }
193
+ let out = lines.join('\n');
194
+ if (Buffer.byteLength(out, 'utf8') > maxBytes) out = out.slice(0, maxBytes - 4) + '\n...';
195
+ return out;
196
+ }
197
+
198
+ function entitiesFromText(text, { by = '', kind = 'utterance' } = {}) {
199
+ const t = String(text || '');
200
+ if (!t.trim()) return { nodes: [], edges: [] };
201
+ const nodes = [];
202
+ const edges = [];
203
+ const seenFile = new Set();
204
+ const seenTool = new Set();
205
+ const seenDecision = new Set();
206
+ for (const match of t.matchAll(FILE_RE)) {
207
+ const name = match[0];
208
+ if (seenFile.has(name)) continue;
209
+ seenFile.add(name);
210
+ nodes.push({ id: `f_${name}`, t: 'F', n: name, by });
211
+ }
212
+ for (const match of t.matchAll(TOOL_RE)) {
213
+ const tool = match[0].toLowerCase();
214
+ if (seenTool.has(tool)) continue;
215
+ seenTool.add(tool);
216
+ nodes.push({ id: `t_${tool}`, t: 'T', n: tool, by });
217
+ }
218
+ for (const match of t.matchAll(DECISION_RE)) {
219
+ const decision = match[0];
220
+ if (seenDecision.has(decision)) continue;
221
+ seenDecision.add(decision);
222
+ const id = `d_${decision}`;
223
+ nodes.push({ id, t: 'D', n: decision, by });
224
+ for (const fn of seenFile) edges.push({ s: id, p: 't', o: `f_${fn}` });
225
+ for (const tn of seenTool) edges.push({ s: id, p: 'u', o: `t_${tn}` });
226
+ }
227
+ if (kind === 'result' && t.trim()) {
228
+ const summary = t.trim().replace(/\s+/g, ' ').slice(0, 80);
229
+ nodes.push({ id: `r_${nowSec()}_${Math.random().toString(16).slice(2, 8)}`, t: 'R', n: summary, by });
230
+ }
231
+ return { nodes, edges };
232
+ }
233
+
234
+ function save() {
235
+ try {
236
+ fsApi.mkdirSync(root, { recursive: true });
237
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
238
+ fsApi.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
239
+ fsApi.renameSync(tmp, filePath);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+ function load() {
246
+ try {
247
+ const raw = fsApi.readFileSync(filePath, 'utf8');
248
+ const parsed = JSON.parse(raw);
249
+ if (parsed && parsed.v === SCHEMA_VERSION && parsed.channelKey === channel) {
250
+ state = parsed;
251
+ for (const node of state.nodes) {
252
+ const m = String(node.id || '').match(/^n(\d+)$/);
253
+ if (m) nextId = Math.max(nextId, Number(m[1]) + 1);
254
+ }
255
+ return true;
256
+ }
257
+ } catch {}
258
+ state = emptyState(channel);
259
+ return false;
260
+ }
261
+ function snapshot() { return JSON.parse(JSON.stringify(state)); }
262
+ function reset() { state = emptyState(channel); nextId = 1; }
263
+
264
+ return {
265
+ add, entitiesFromText, serializeForHandoff, save, load, snapshot, reset,
266
+ get nodeCount() { return state.nodes.length; },
267
+ get edgeCount() { return state.edges.length; },
268
+ get filePath() { return filePath; },
269
+ get typeLabels() { return TYPE_LABELS; },
270
+ };
271
+ }
@@ -0,0 +1,130 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { createSessionOntology, buildExtractionPrompt, parseExtractionJson } from './session_ontology.mjs';
7
+
8
+ const __tempRoots = [];
9
+ test.after(() => {
10
+ for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
11
+ });
12
+
13
+ function tmpDir(label) {
14
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `vc-onto-${label}-`));
15
+ __tempRoots.push(root);
16
+ return root;
17
+ }
18
+
19
+ test('add inserts nodes and dedupes by (type, lowercase name)', () => {
20
+ const ontology = createSessionOntology({ rootDir: tmpDir('dedup'), channelKey: 'voice/1' });
21
+ ontology.add({ nodes: [{ t: 'D', n: 'OAUTH_PROVIDER=github' }, { t: 'D', n: 'oauth_provider=github' }] });
22
+ assert.equal(ontology.nodeCount, 1);
23
+ });
24
+
25
+ test('add applies supersede edges without deleting old node', () => {
26
+ const ontology = createSessionOntology({ rootDir: tmpDir('supersede'), channelKey: 'voice/2' });
27
+ ontology.add({
28
+ nodes: [
29
+ { id: 'a', t: 'D', n: 'session_store=memory' },
30
+ { id: 'b', t: 'D', n: 'session_store=redis' },
31
+ ],
32
+ supersedes: [{ old: 'a', new: 'b' }],
33
+ });
34
+ assert.equal(ontology.nodeCount, 2);
35
+ const md = ontology.serializeForHandoff({ language: 'en' });
36
+ assert.match(md, /session_store=redis/);
37
+ assert.doesNotMatch(md, /session_store=memory/);
38
+ });
39
+
40
+ test('LRU eviction keeps decisions sticky', () => {
41
+ const ontology = createSessionOntology({ rootDir: tmpDir('lru'), channelKey: 'voice/3', maxNodes: 3 });
42
+ ontology.add({ nodes: [{ t: 'D', n: 'a=1' }, { t: 'F', n: 'app.mjs' }, { t: 'F', n: 'lib.mjs' }] });
43
+ assert.equal(ontology.nodeCount, 3);
44
+ ontology.add({ nodes: [{ t: 'F', n: 'main.mjs' }] });
45
+ assert.equal(ontology.nodeCount, 3);
46
+ const snap = ontology.snapshot();
47
+ assert.ok(snap.nodes.some(n => n.t === 'D'), 'decision survives eviction');
48
+ });
49
+
50
+ test('entitiesFromText extracts files, tools, decisions', () => {
51
+ const ontology = createSessionOntology({ rootDir: tmpDir('extract'), channelKey: 'voice/4' });
52
+ const out = ontology.entitiesFromText(
53
+ 'Updated app/main.mjs and ran npm test. We settled oauth_provider=github.',
54
+ { by: 'claude' },
55
+ );
56
+ const types = out.nodes.map(n => n.t).sort();
57
+ assert.ok(types.includes('F'), 'has file node');
58
+ assert.ok(types.includes('T'), 'has tool node');
59
+ assert.ok(types.includes('D'), 'has decision node');
60
+ const decision = out.nodes.find(n => n.t === 'D');
61
+ assert.match(decision.n, /oauth_provider=github/);
62
+ });
63
+
64
+ test('serializeForHandoff returns empty marker on empty ontology', () => {
65
+ const ontology = createSessionOntology({ rootDir: tmpDir('empty'), channelKey: 'voice/5' });
66
+ assert.match(ontology.serializeForHandoff({ language: 'en' }), /no prior session context/);
67
+ assert.match(ontology.serializeForHandoff({ language: 'ko' }), /이전 세션 컨텍스트 없음/);
68
+ });
69
+
70
+ test('serializeForHandoff groups decisions, files, tools, results', () => {
71
+ const ontology = createSessionOntology({ rootDir: tmpDir('serialize'), channelKey: 'voice/6' });
72
+ const { nodes, edges } = ontology.entitiesFromText(
73
+ 'Touched app-node/main.mjs with git commit. oauth_provider=github decided.',
74
+ { by: 'claude' },
75
+ );
76
+ ontology.add({ nodes, edges });
77
+ ontology.add({ nodes: [{ t: 'R', n: 'OAuth wired up successfully', by: 'codex' }] });
78
+ const md = ontology.serializeForHandoff({ language: 'en' });
79
+ assert.match(md, /Active decisions/);
80
+ assert.match(md, /oauth_provider=github/);
81
+ assert.match(md, /Relevant files/);
82
+ assert.match(md, /main\.mjs/);
83
+ assert.match(md, /Recent results/);
84
+ });
85
+
86
+ test('buildExtractionPrompt mentions the schema and the message body', () => {
87
+ const p = buildExtractionPrompt({ text: 'Routed to codex for the auth refactor', language: 'en' });
88
+ assert.match(p, /JSON of shape/);
89
+ assert.match(p, /D=Decision/);
90
+ assert.match(p, /auth refactor/);
91
+ });
92
+
93
+ test('parseExtractionJson recovers JSON from fenced output', () => {
94
+ const raw = '```json\n{"nodes":[{"t":"D","n":"db=postgres"}],"edges":[]}\n```';
95
+ const out = parseExtractionJson(raw);
96
+ assert.equal(out.nodes.length, 1);
97
+ assert.equal(out.nodes[0].t, 'D');
98
+ assert.equal(out.nodes[0].n, 'db=postgres');
99
+ });
100
+
101
+ test('parseExtractionJson tolerates surrounding prose', () => {
102
+ const raw = 'Sure, here is the graph: {"nodes":[{"t":"F","n":"app.mjs"}],"edges":[]} let me know if you need more.';
103
+ const out = parseExtractionJson(raw);
104
+ assert.equal(out.nodes.length, 1);
105
+ });
106
+
107
+ test('parseExtractionJson resolves edge endpoints by name to canonical node ids', () => {
108
+ const raw = '{"nodes":[{"t":"D","n":"db=postgres"},{"t":"F","n":"schema.sql"}],"edges":[{"s":"db=postgres","p":"t","o":"schema.sql"}]}';
109
+ const out = parseExtractionJson(raw);
110
+ assert.equal(out.nodes.length, 2);
111
+ assert.equal(out.edges.length, 1);
112
+ assert.equal(out.edges[0].s, 'e_D_db=postgres');
113
+ assert.equal(out.edges[0].o, 'e_F_schema.sql');
114
+ });
115
+
116
+ test('parseExtractionJson returns empty on garbage input', () => {
117
+ const out = parseExtractionJson('totally not json');
118
+ assert.deepEqual(out, { nodes: [], edges: [] });
119
+ });
120
+
121
+ test('save/load round-trips state to disk', () => {
122
+ const root = tmpDir('persist');
123
+ const ontology = createSessionOntology({ rootDir: root, channelKey: 'voice/7' });
124
+ ontology.add({ nodes: [{ t: 'D', n: 'db=postgres', by: 'claude' }] });
125
+ assert.ok(ontology.save());
126
+ const restored = createSessionOntology({ rootDir: root, channelKey: 'voice/7' });
127
+ assert.ok(restored.load());
128
+ assert.equal(restored.nodeCount, 1);
129
+ assert.match(restored.serializeForHandoff({ language: 'en' }), /db=postgres/);
130
+ });
@@ -0,0 +1,94 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ const DEFAULT_BASE = 'https://api.groq.com/openai/v1';
4
+
5
+ export function createSmartProgressSummarizer({
6
+ apiKey = '',
7
+ baseUrl = DEFAULT_BASE,
8
+ model = 'llama-3.1-8b-instant',
9
+ windowMs = 4000,
10
+ language = 'en',
11
+ fetchImpl = globalThis.fetch,
12
+ timeoutMs = 1500,
13
+ cacheMs = 60_000,
14
+ maxBatch = 8,
15
+ } = {}) {
16
+ const ee = new EventEmitter();
17
+ let buffer = [];
18
+ let timer = null;
19
+ const cache = new Map();
20
+
21
+ function emitRaw(events) {
22
+ for (const e of events) ee.emit('summary', e);
23
+ }
24
+
25
+ async function summarize(events) {
26
+ const key = `${language}::${events.join('|')}`;
27
+ const cached = cache.get(key);
28
+ if (cached && Date.now() - cached.t < cacheMs) return cached.text;
29
+ const ctl = new AbortController();
30
+ let timeoutId;
31
+ const timeoutPromise = new Promise((_, reject) => {
32
+ timeoutId = setTimeout(() => {
33
+ try { ctl.abort(); } catch {}
34
+ reject(new Error('smart_progress timeout'));
35
+ }, timeoutMs);
36
+ });
37
+ const sysLang = language === 'ko' ? 'Korean' : 'English';
38
+ const requestPromise = (async () => {
39
+ const res = await fetchImpl(`${baseUrl}/chat/completions`, {
40
+ method: 'POST',
41
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
42
+ signal: ctl.signal,
43
+ body: JSON.stringify({
44
+ model,
45
+ temperature: 0.2,
46
+ max_tokens: 40,
47
+ messages: [
48
+ { role: 'system', content: `Summarize a coding agent's recent actions in one short ${sysLang} sentence. No file paths unless essential. No quotes.` },
49
+ { role: 'user', content: events.join('\n') },
50
+ ],
51
+ }),
52
+ });
53
+ const data = await res.json();
54
+ return String(data?.choices?.[0]?.message?.content || '').trim();
55
+ })();
56
+ try {
57
+ const text = await Promise.race([requestPromise, timeoutPromise]);
58
+ if (text) cache.set(key, { text, t: Date.now() });
59
+ return text;
60
+ } finally {
61
+ clearTimeout(timeoutId);
62
+ }
63
+ }
64
+
65
+ function flush() {
66
+ timer = null;
67
+ const events = buffer;
68
+ buffer = [];
69
+ if (!events.length) return;
70
+ if (!apiKey || typeof fetchImpl !== 'function') {
71
+ emitRaw(events);
72
+ return;
73
+ }
74
+ summarize(events)
75
+ .then(text => ee.emit('summary', text || events[events.length - 1]))
76
+ .catch(() => emitRaw(events));
77
+ }
78
+
79
+ return {
80
+ on: (event, fn) => ee.on(event, fn),
81
+ ingest(event) {
82
+ const e = String(event || '').trim();
83
+ if (!e) return;
84
+ buffer.push(e);
85
+ if (buffer.length >= maxBatch) {
86
+ if (timer) { clearTimeout(timer); timer = null; }
87
+ flush();
88
+ return;
89
+ }
90
+ if (!timer) timer = setTimeout(flush, windowMs);
91
+ },
92
+ setLanguage(lang) { language = lang; },
93
+ };
94
+ }
@@ -0,0 +1,66 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createSmartProgressSummarizer } from './smart_progress.mjs';
4
+
5
+ test('falls back to raw events when no apiKey', async () => {
6
+ const out = [];
7
+ const s = createSmartProgressSummarizer({ windowMs: 10 });
8
+ s.on('summary', t => out.push(t));
9
+ s.ingest('reading files routes.ts');
10
+ s.ingest('editing files routes.ts');
11
+ await new Promise(r => setTimeout(r, 40));
12
+ assert.ok(out.includes('reading files routes.ts'));
13
+ assert.ok(out.includes('editing files routes.ts'));
14
+ });
15
+
16
+ test('calls fetch with correct headers and body when apiKey is set', async () => {
17
+ const calls = [];
18
+ const fakeFetch = async (url, opts) => {
19
+ calls.push({ url, opts });
20
+ return { json: async () => ({ choices: [{ message: { content: 'Wiring the new login endpoint.' } }] }) };
21
+ };
22
+ const out = [];
23
+ const s = createSmartProgressSummarizer({
24
+ apiKey: 'k',
25
+ fetchImpl: fakeFetch,
26
+ windowMs: 10,
27
+ timeoutMs: 200,
28
+ });
29
+ s.on('summary', t => out.push(t));
30
+ s.ingest('reading auth.ts');
31
+ s.ingest('editing routes.ts');
32
+ await new Promise(r => setTimeout(r, 80));
33
+ assert.equal(calls.length, 1);
34
+ assert.match(calls[0].url, /chat\/completions$/);
35
+ assert.equal(calls[0].opts.headers.authorization, 'Bearer k');
36
+ assert.deepEqual(out, ['Wiring the new login endpoint.']);
37
+ });
38
+
39
+ test('timeout falls back to raw events', async () => {
40
+ const slowFetch = () => new Promise(() => {});
41
+ const out = [];
42
+ const s = createSmartProgressSummarizer({
43
+ apiKey: 'k',
44
+ fetchImpl: slowFetch,
45
+ windowMs: 10,
46
+ timeoutMs: 30,
47
+ });
48
+ s.on('summary', t => out.push(t));
49
+ s.ingest('reading auth.ts');
50
+ s.ingest('editing routes.ts');
51
+ await new Promise(r => setTimeout(r, 120));
52
+ assert.ok(out.length >= 2);
53
+ assert.ok(out.includes('reading auth.ts'));
54
+ });
55
+
56
+ test('maxBatch triggers immediate flush', async () => {
57
+ const out = [];
58
+ const s = createSmartProgressSummarizer({ windowMs: 999999, maxBatch: 3 });
59
+ s.on('summary', t => out.push(t));
60
+ s.ingest('a');
61
+ s.ingest('b');
62
+ assert.equal(out.length, 0);
63
+ s.ingest('c');
64
+ await new Promise(r => setTimeout(r, 5));
65
+ assert.deepEqual(out, ['a', 'b', 'c']);
66
+ });
@@ -0,0 +1,91 @@
1
+ import { EventEmitter } from 'node:events';
2
+
3
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
4
+ const BOX_RE = /[╭╮╰╯│┊─]/g;
5
+ const PROGRESS_LINE_RE = /^VERBALCODING_PROGRESS\s*:.*$/i;
6
+ const TERMINAL_RE = /(?<!\b(?:e\.g|i\.e|etc|cf|Mr|Mrs|Dr|Sr|Jr|St|Mt|vs|approx|al|aka|fig|eqn|inc|ltd|co))[.!?。!?…]+["'\)\]\}」』]*(?=\s|$)/;
7
+
8
+ function clean(text) {
9
+ return String(text || '')
10
+ .replace(ANSI_RE, '')
11
+ .split(/\r?\n/)
12
+ .filter(line => !PROGRESS_LINE_RE.test(line.trim()))
13
+ .join('\n')
14
+ .replace(BOX_RE, '')
15
+ .replace(/[ \t]+/g, ' ');
16
+ }
17
+
18
+ export function createSentencer({ minChars = 40, maxLatencyMs = 800 } = {}) {
19
+ const ee = new EventEmitter();
20
+ let buffer = '';
21
+ let inFence = false;
22
+ let pendingFenceTail = '';
23
+ let lastEmit = Date.now();
24
+
25
+ function emit(text) {
26
+ const trimmed = String(text || '').trim();
27
+ if (!trimmed) return;
28
+ ee.emit('sentence', trimmed);
29
+ lastEmit = Date.now();
30
+ }
31
+
32
+ function trailingBackticks(text) {
33
+ let n = 0;
34
+ for (let i = text.length - 1; i >= 0 && text[i] === '`' && n < 2; i -= 1) n += 1;
35
+ return n;
36
+ }
37
+
38
+ function ingest(text) {
39
+ let remaining = pendingFenceTail + text;
40
+ pendingFenceTail = '';
41
+ while (remaining.length > 0) {
42
+ const fence = remaining.indexOf('```');
43
+ if (fence === -1) {
44
+ const heldCount = trailingBackticks(remaining);
45
+ const safe = heldCount > 0 ? remaining.slice(0, remaining.length - heldCount) : remaining;
46
+ pendingFenceTail = heldCount > 0 ? remaining.slice(remaining.length - heldCount) : '';
47
+ if (!inFence) buffer += safe;
48
+ return;
49
+ }
50
+ const before = remaining.slice(0, fence);
51
+ if (!inFence) buffer += before;
52
+ inFence = !inFence;
53
+ remaining = remaining.slice(fence + 3);
54
+ }
55
+ }
56
+
57
+ function scan() {
58
+ while (true) {
59
+ const match = buffer.match(TERMINAL_RE);
60
+ if (!match) break;
61
+ const end = match.index + match[0].length;
62
+ const sentence = buffer.slice(0, end);
63
+ buffer = buffer.slice(end).replace(/^\s+/, '');
64
+ emit(sentence);
65
+ }
66
+ if (buffer.length >= minChars && Date.now() - lastEmit >= maxLatencyMs) {
67
+ const cut = buffer.lastIndexOf(' ');
68
+ if (cut > Math.floor(minChars / 2)) {
69
+ emit(buffer.slice(0, cut));
70
+ buffer = buffer.slice(cut).trim();
71
+ }
72
+ }
73
+ }
74
+
75
+ return {
76
+ on: (event, fn) => ee.on(event, fn),
77
+ push(text) {
78
+ const cleaned = clean(text);
79
+ if (!cleaned) return;
80
+ ingest(cleaned);
81
+ scan();
82
+ },
83
+ flush() {
84
+ if (!inFence) buffer += pendingFenceTail;
85
+ pendingFenceTail = '';
86
+ emit(buffer);
87
+ buffer = '';
88
+ inFence = false;
89
+ },
90
+ };
91
+ }