verbalcoding 0.2.11 → 0.2.12

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 (93) hide show
  1. package/.env.example +27 -1
  2. package/README.es.md +132 -0
  3. package/README.fr.md +132 -0
  4. package/README.ja.md +132 -0
  5. package/README.ko.md +132 -0
  6. package/README.md +116 -74
  7. package/README.ru.md +132 -0
  8. package/README.zh.md +131 -0
  9. package/app-node/agent_adapters.mjs +37 -5
  10. package/app-node/agent_adapters.test.mjs +13 -1
  11. package/app-node/agent_detect.mjs +73 -0
  12. package/app-node/agent_detect.test.mjs +77 -0
  13. package/app-node/install_config.mjs +3 -0
  14. package/app-node/main.mjs +339 -4
  15. package/app-node/notify.mjs +73 -0
  16. package/app-node/notify.test.mjs +68 -0
  17. package/app-node/plan_mode.mjs +174 -0
  18. package/app-node/plan_mode.test.mjs +153 -0
  19. package/app-node/smart_progress.mjs +94 -0
  20. package/app-node/smart_progress.test.mjs +66 -0
  21. package/app-node/stream_sentencer.mjs +61 -0
  22. package/app-node/stream_sentencer.test.mjs +64 -0
  23. package/app-node/streaming_tts_queue.mjs +48 -0
  24. package/app-node/streaming_tts_queue.test.mjs +58 -0
  25. package/app-node/text_routing.mjs +20 -0
  26. package/app-node/text_routing.test.mjs +23 -1
  27. package/docs/CONFIGURATION.md +69 -96
  28. package/docs/FRESH_INSTALL.md +105 -63
  29. package/docs/HERMES_VOICE.md +65 -0
  30. package/docs/MULTI_INSTANCE.md +16 -0
  31. package/docs/README.md +49 -0
  32. package/docs/RELEASE.md +42 -19
  33. package/docs/ROADMAP.md +38 -0
  34. package/docs/TROUBLESHOOTING.md +126 -0
  35. package/docs/USAGE.md +72 -40
  36. package/docs/assets/figures/verbalcoding-flow.svg +1 -1
  37. package/docs/i18n/CONFIGURATION.es.md +25 -0
  38. package/docs/i18n/CONFIGURATION.fr.md +25 -0
  39. package/docs/i18n/CONFIGURATION.ja.md +25 -0
  40. package/docs/i18n/CONFIGURATION.ko.md +25 -0
  41. package/docs/i18n/CONFIGURATION.ru.md +25 -0
  42. package/docs/i18n/CONFIGURATION.zh.md +25 -0
  43. package/docs/i18n/FRESH_INSTALL.es.md +27 -2
  44. package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
  45. package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
  46. package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
  47. package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
  48. package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
  49. package/docs/i18n/HERMES_VOICE.es.md +46 -0
  50. package/docs/i18n/HERMES_VOICE.fr.md +46 -0
  51. package/docs/i18n/HERMES_VOICE.ja.md +46 -0
  52. package/docs/i18n/HERMES_VOICE.ko.md +65 -0
  53. package/docs/i18n/HERMES_VOICE.ru.md +46 -0
  54. package/docs/i18n/HERMES_VOICE.zh.md +46 -0
  55. package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
  56. package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
  57. package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
  58. package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
  59. package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
  60. package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
  61. package/docs/i18n/README.es.md +20 -134
  62. package/docs/i18n/README.fr.md +20 -134
  63. package/docs/i18n/README.ja.md +20 -134
  64. package/docs/i18n/README.ko.md +20 -133
  65. package/docs/i18n/README.ru.md +20 -134
  66. package/docs/i18n/README.zh.md +20 -133
  67. package/docs/i18n/RELEASE.es.md +26 -1
  68. package/docs/i18n/RELEASE.fr.md +26 -1
  69. package/docs/i18n/RELEASE.ja.md +26 -1
  70. package/docs/i18n/RELEASE.ko.md +26 -1
  71. package/docs/i18n/RELEASE.ru.md +26 -1
  72. package/docs/i18n/RELEASE.zh.md +26 -1
  73. package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
  74. package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
  75. package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
  76. package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
  77. package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
  78. package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
  79. package/docs/i18n/USAGE.es.md +25 -0
  80. package/docs/i18n/USAGE.fr.md +25 -0
  81. package/docs/i18n/USAGE.ja.md +25 -0
  82. package/docs/i18n/USAGE.ko.md +25 -0
  83. package/docs/i18n/USAGE.ru.md +25 -0
  84. package/docs/i18n/USAGE.zh.md +25 -0
  85. package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
  86. package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
  87. package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
  88. package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
  89. package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -0
  90. package/package.json +2 -1
  91. package/scripts/cli.mjs +4 -3
  92. package/scripts/doctor.mjs +11 -0
  93. package/scripts/install.mjs +15 -1
@@ -0,0 +1,73 @@
1
+ const SECRET_RE = /\b(?:token|api[_-]?key|password|secret|authorization|bearer)\b\s*[:=]?\s*\S+/gi;
2
+ const SK_RE = /\bsk-[a-zA-Z0-9_-]{8,}\b/g;
3
+ const NTFY_BASE_DEFAULT = 'https://ntfy.sh';
4
+
5
+ function redact(text) {
6
+ return String(text || '')
7
+ .replace(SECRET_RE, '$& [REDACTED]')
8
+ .replace(/(\[REDACTED\]\s*)+/g, '[REDACTED] ')
9
+ .replace(SK_RE, '[REDACTED]');
10
+ }
11
+
12
+ export function buildDiscordDeepLink({ guildId, channelId } = {}) {
13
+ if (!guildId || !channelId) return '';
14
+ return `https://discord.com/channels/${guildId}/${channelId}`;
15
+ }
16
+
17
+ export function createNotifier({
18
+ provider = 'ntfy',
19
+ topic = '',
20
+ fetchImpl = globalThis.fetch,
21
+ ntfyBase = NTFY_BASE_DEFAULT,
22
+ pushoverUser = '',
23
+ pushoverToken = '',
24
+ } = {}) {
25
+ async function sendNtfy({ title, body, deepLink }) {
26
+ if (!topic) return { skipped: true, reason: 'no topic' };
27
+ const headers = { Title: title };
28
+ if (deepLink) headers.Click = deepLink;
29
+ const res = await fetchImpl(`${ntfyBase}/${encodeURIComponent(topic)}`, {
30
+ method: 'POST',
31
+ headers,
32
+ body,
33
+ });
34
+ return { ok: !!res?.ok, status: res?.status };
35
+ }
36
+
37
+ async function sendPushover({ title, body, deepLink }) {
38
+ if (!pushoverUser || !pushoverToken) return { skipped: true, reason: 'no pushover creds' };
39
+ const payload = {
40
+ user: pushoverUser,
41
+ token: pushoverToken,
42
+ title,
43
+ message: body,
44
+ };
45
+ if (deepLink) {
46
+ payload.url = deepLink;
47
+ payload.url_title = 'Open Discord';
48
+ }
49
+ const res = await fetchImpl('https://api.pushover.net/1/messages.json', {
50
+ method: 'POST',
51
+ headers: { 'content-type': 'application/json' },
52
+ body: JSON.stringify(payload),
53
+ });
54
+ return { ok: !!res?.ok, status: res?.status };
55
+ }
56
+
57
+ async function send({ title = 'VerbalCoding', body = '', deepLink = '' } = {}) {
58
+ const safeBody = redact(body).slice(0, 200);
59
+ const safeTitle = redact(title).slice(0, 80);
60
+ if (provider === 'ntfy') return sendNtfy({ title: safeTitle, body: safeBody, deepLink });
61
+ if (provider === 'pushover') return sendPushover({ title: safeTitle, body: safeBody, deepLink });
62
+ if (provider === 'noop') return { ok: true };
63
+ throw new Error(`unknown notify provider ${provider}`);
64
+ }
65
+
66
+ function shouldNotify({ humanCount = 0, taskMs = 0, minTaskMs = 60_000, userOptIn = false } = {}) {
67
+ if (taskMs < minTaskMs) return false;
68
+ if (userOptIn) return true;
69
+ return humanCount === 0;
70
+ }
71
+
72
+ return { send, shouldNotify };
73
+ }
@@ -0,0 +1,68 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createNotifier, buildDiscordDeepLink } from './notify.mjs';
4
+
5
+ test('ntfy provider posts to topic URL with title body and click headers', async () => {
6
+ const calls = [];
7
+ const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { ok: true, status: 200 }; };
8
+ const n = createNotifier({ provider: 'ntfy', topic: 'verbalcoding-test', fetchImpl });
9
+ const res = await n.send({ title: 'Done', body: 'All green.', deepLink: 'discord://x' });
10
+ assert.equal(res.ok, true);
11
+ assert.equal(calls.length, 1);
12
+ assert.match(calls[0].url, /ntfy\.sh\/verbalcoding-test/);
13
+ assert.equal(calls[0].opts.headers.Title, 'Done');
14
+ assert.equal(calls[0].opts.headers.Click, 'discord://x');
15
+ assert.equal(calls[0].opts.body, 'All green.');
16
+ });
17
+
18
+ test('ntfy returns skipped when topic missing', async () => {
19
+ const n = createNotifier({ provider: 'ntfy', fetchImpl: async () => ({ ok: true }) });
20
+ const res = await n.send({ title: 't', body: 'b' });
21
+ assert.equal(res.skipped, true);
22
+ });
23
+
24
+ test('shouldNotify true when zero humans and task long enough', () => {
25
+ const n = createNotifier({ provider: 'noop' });
26
+ assert.equal(n.shouldNotify({ humanCount: 0, taskMs: 10_000, minTaskMs: 1000 }), true);
27
+ assert.equal(n.shouldNotify({ humanCount: 1, taskMs: 10_000, minTaskMs: 1000 }), false);
28
+ assert.equal(n.shouldNotify({ humanCount: 0, taskMs: 100, minTaskMs: 1000 }), false);
29
+ assert.equal(n.shouldNotify({ humanCount: 1, taskMs: 10_000, minTaskMs: 1000, userOptIn: true }), true);
30
+ });
31
+
32
+ test('redacts secret patterns from body', async () => {
33
+ const calls = [];
34
+ const fetchImpl = async (u, o) => { calls.push(o); return { ok: true }; };
35
+ const n = createNotifier({ provider: 'ntfy', topic: 'x', fetchImpl });
36
+ await n.send({ title: 't', body: 'token=abc123 finished. sk-fooBar12 also.', deepLink: '' });
37
+ assert.match(calls[0].body, /\[REDACTED\]/);
38
+ assert.doesNotMatch(calls[0].body, /sk-fooBar12/);
39
+ });
40
+
41
+ test('truncates long body to 200 chars', async () => {
42
+ const calls = [];
43
+ const fetchImpl = async (u, o) => { calls.push(o); return { ok: true }; };
44
+ const n = createNotifier({ provider: 'ntfy', topic: 'x', fetchImpl });
45
+ await n.send({ title: 't', body: 'x'.repeat(500) });
46
+ assert.equal(calls[0].body.length, 200);
47
+ });
48
+
49
+ test('pushover posts json payload with url', async () => {
50
+ const calls = [];
51
+ const fetchImpl = async (url, opts) => { calls.push({ url, opts }); return { ok: true, status: 200 }; };
52
+ const n = createNotifier({ provider: 'pushover', pushoverUser: 'u', pushoverToken: 'tk', fetchImpl });
53
+ await n.send({ title: 'Done', body: 'all good', deepLink: 'discord://x' });
54
+ assert.match(calls[0].url, /pushover\.net/);
55
+ const body = JSON.parse(calls[0].opts.body);
56
+ assert.equal(body.user, 'u');
57
+ assert.equal(body.url, 'discord://x');
58
+ });
59
+
60
+ test('buildDiscordDeepLink composes web URL', () => {
61
+ assert.equal(buildDiscordDeepLink({ guildId: '1', channelId: '2' }), 'https://discord.com/channels/1/2');
62
+ assert.equal(buildDiscordDeepLink({}), '');
63
+ });
64
+
65
+ test('unknown provider throws', async () => {
66
+ const n = createNotifier({ provider: 'unknown' });
67
+ await assert.rejects(() => n.send({ title: 't', body: 'b' }), /unknown notify provider/);
68
+ });
@@ -0,0 +1,174 @@
1
+ const PLAN_RE = /PLAN_BEGIN\s*\n([\s\S]*?)\nPLAN_END/;
2
+ const DECISIONS_RE = /DECISIONS_BEGIN\s*\n([\s\S]*?)\nDECISIONS_END/;
3
+
4
+ const SKIP_EN = /\bskip\s+step\s+(\d+)\b/i;
5
+ const SKIP_KO = /step\s*(\d+)\s*건너뛰/i;
6
+ const ADD_EN = /\badd\s+(.+?)\s+after\s+step\s+(\d+)\b/i;
7
+ const ADD_KO = /step\s*(\d+)\s*다음에\s+(.+?)\s*추가/i;
8
+ const APPROVE_EN = /\b(approve|go\s*ahead|let'?s\s+go|run\s+it|proceed)\b/i;
9
+ const APPROVE_KO = /(실행|진행|승인)/i;
10
+ const CANCEL_EN = /\b(cancel|stop|nevermind|never\s+mind)\b/i;
11
+ const CANCEL_KO = /(취소|그만)/i;
12
+ const ENTER_EN = /\b(plan\s+(it\s+)?first|make\s+a\s+plan)\b/i;
13
+ const ENTER_KO = /(먼저\s*계획|계획\s*먼저|계획부터)/i;
14
+
15
+ export function parsePlanOutput(text) {
16
+ const planMatch = String(text || '').match(PLAN_RE);
17
+ if (!planMatch) return { steps: [], decisions: [] };
18
+ const steps = planMatch[1]
19
+ .split(/\r?\n/)
20
+ .map(line => line.match(/^\s*(\d+)\.\s*(.+)$/))
21
+ .filter(Boolean)
22
+ .map(m => ({ id: Number(m[1]), text: m[2].trim(), status: 'pending' }));
23
+ const decisions = parseDecisions(String(text || ''));
24
+ return { steps, decisions };
25
+ }
26
+
27
+ export function parseDecisions(text) {
28
+ const match = String(text || '').match(DECISIONS_RE);
29
+ if (!match) return [];
30
+ const out = [];
31
+ let counter = 1;
32
+ for (const raw of match[1].split(/\r?\n/)) {
33
+ const line = raw.replace(/^\s*-\s*/, '').trim();
34
+ if (!line) continue;
35
+ const parts = line.split('|').map(s => s.trim()).filter(Boolean);
36
+ if (parts.length < 3) continue;
37
+ const [slot, question, ...options] = parts;
38
+ out.push({
39
+ slot: slot || `decision_${counter++}`,
40
+ question,
41
+ options: options.filter(Boolean),
42
+ });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ const ORDINAL_EN = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, '1st': 1, '2nd': 2, '3rd': 3 };
48
+ const ORDINAL_KO = { '첫': 1, '첫번째': 1, '첫 번째': 1, '두번째': 2, '두 번째': 2, '세번째': 3, '세 번째': 3, '네번째': 4 };
49
+ const DEFER_RE = /\b(either|whatever|you\s+(decide|pick|choose)|agent\s+(decides|picks)|up\s+to\s+you|no\s+preference)\b|아무거나|네가\s*골라|마음대로|상관없|알아서/i;
50
+
51
+ export function parseDecisionAnswer(utterance, decision, language = 'en') {
52
+ const text = String(utterance || '').trim();
53
+ if (!text || !decision || !Array.isArray(decision.options) || decision.options.length === 0) {
54
+ return { type: 'unknown', choice: null };
55
+ }
56
+ const lower = text.toLowerCase();
57
+ if (DEFER_RE.test(text)) return { type: 'auto', choice: null };
58
+ for (const opt of decision.options) {
59
+ const optLower = String(opt).toLowerCase();
60
+ if (lower.includes(optLower) && optLower.length >= 2) return { type: 'option', choice: opt };
61
+ }
62
+ const numMatch = text.match(/\b(\d+)\b/);
63
+ if (numMatch) {
64
+ const idx = Number(numMatch[1]);
65
+ if (idx >= 1 && idx <= decision.options.length) return { type: 'option', choice: decision.options[idx - 1] };
66
+ }
67
+ for (const [word, idx] of Object.entries(ORDINAL_EN)) {
68
+ if (lower.includes(word)) {
69
+ if (idx <= decision.options.length) return { type: 'option', choice: decision.options[idx - 1] };
70
+ }
71
+ }
72
+ for (const [word, idx] of Object.entries(ORDINAL_KO)) {
73
+ if (text.includes(word)) {
74
+ if (idx <= decision.options.length) return { type: 'option', choice: decision.options[idx - 1] };
75
+ }
76
+ }
77
+ return { type: 'unknown', choice: null };
78
+ }
79
+
80
+ export function renderDecisionPrompt(decision, language = 'en') {
81
+ if (!decision) return '';
82
+ const opts = decision.options.map((o, i) => `${i + 1}) ${o}`).join(' ');
83
+ if (/^en/i.test(String(language || ''))) {
84
+ return `${decision.question} Options: ${opts}. Or say "either" to let me pick.`;
85
+ }
86
+ return `${decision.question} 옵션: ${opts}. "아무거나"라고 하면 내가 고를게.`;
87
+ }
88
+
89
+ export function renderResolvedDecisions(resolved, language = 'en') {
90
+ const keys = Object.keys(resolved || {});
91
+ if (!keys.length) return '';
92
+ const parts = keys.map(k => `${k}=${resolved[k] === null ? '(agent picks)' : resolved[k]}`);
93
+ return /^en/i.test(String(language || ''))
94
+ ? `Resolved decisions: ${parts.join(', ')}.`
95
+ : `결정 사항: ${parts.join(', ')}.`;
96
+ }
97
+
98
+ export function isPlanEntryUtterance(text, language = 'en') {
99
+ const t = String(text || '');
100
+ if (language === 'ko') return ENTER_KO.test(t) || ENTER_EN.test(t);
101
+ return ENTER_EN.test(t) || ENTER_KO.test(t);
102
+ }
103
+
104
+ export function parseVoiceCommand(text, language = 'en') {
105
+ const t = String(text || '').trim();
106
+ let m = t.match(SKIP_EN) || t.match(SKIP_KO);
107
+ if (m) return { type: 'skip', index: Number(m[1]) };
108
+ m = t.match(ADD_EN);
109
+ if (m) return { type: 'insert', after: Number(m[2]), text: m[1].trim() };
110
+ m = t.match(ADD_KO);
111
+ if (m) return { type: 'insert', after: Number(m[1]), text: m[2].trim() };
112
+ if (APPROVE_EN.test(t) || APPROVE_KO.test(t)) return { type: 'approve' };
113
+ if (CANCEL_EN.test(t) || CANCEL_KO.test(t)) return { type: 'cancel' };
114
+ return { type: 'unknown' };
115
+ }
116
+
117
+ export function applyCommand(steps, cmd) {
118
+ if (!Array.isArray(steps)) return [];
119
+ if (cmd.type === 'skip') {
120
+ return steps.map(s => (s.id === cmd.index ? { ...s, status: 'skipped' } : s));
121
+ }
122
+ if (cmd.type === 'insert') {
123
+ const out = [];
124
+ for (const s of steps) {
125
+ out.push(s);
126
+ if (s.id === cmd.after) {
127
+ out.push({ id: s.id + 0.5, text: cmd.text, status: 'added' });
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+ return steps;
133
+ }
134
+
135
+ export function renderFinalPlan(steps) {
136
+ const active = (steps || []).filter(s => s.status !== 'skipped');
137
+ return active.map((s, i) => `${i + 1}. ${s.text}`).join('\n');
138
+ }
139
+
140
+ export function planModePreamble(language = 'en') {
141
+ if (language === 'ko') {
142
+ return [
143
+ '지금은 PLAN MODE다. 파일을 절대 수정하지 마라.',
144
+ '아래 형식으로 짧은 계획을 답하고, 결정이 필요한 분기마다 DECISIONS 블록에 질문을 적어라.',
145
+ 'PLAN_BEGIN',
146
+ '1. ...',
147
+ '2. ...',
148
+ 'PLAN_END',
149
+ 'DECISIONS_BEGIN',
150
+ '- <slot> | <한 문장 질문> | <옵션1> | <옵션2> | ...',
151
+ 'DECISIONS_END',
152
+ '각 단계는 12단어 이하 한국어 한 줄. slot은 oauth_provider, session_store 같은 짧은 영문 키.',
153
+ '결정이 필요 없으면 DECISIONS 블록 자체를 생략해라.',
154
+ ].join('\n');
155
+ }
156
+ return [
157
+ 'You are in PLAN MODE. Do NOT modify any files.',
158
+ 'Reply with a short plan AND list any forks/decisions you would normally pick yourself.',
159
+ 'PLAN_BEGIN',
160
+ '1. ...',
161
+ '2. ...',
162
+ 'PLAN_END',
163
+ 'DECISIONS_BEGIN',
164
+ '- <slot> | <one-sentence question> | <option1> | <option2> | ...',
165
+ 'DECISIONS_END',
166
+ 'Each step under 12 words. slot is a short snake_case key (e.g. oauth_provider).',
167
+ 'Omit the DECISIONS block entirely if there is nothing to ask.',
168
+ ].join('\n');
169
+ }
170
+
171
+ export function planExecutionPreamble(language = 'en') {
172
+ if (language === 'ko') return '아래 계획에 따라 작업을 실행해라. 각 단계가 끝나면 다음으로 진행해라.';
173
+ return 'Execute the following plan. Move to the next step as each one completes.';
174
+ }
@@ -0,0 +1,153 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ parsePlanOutput,
5
+ parseDecisions,
6
+ parseDecisionAnswer,
7
+ renderDecisionPrompt,
8
+ renderResolvedDecisions,
9
+ parseVoiceCommand,
10
+ applyCommand,
11
+ renderFinalPlan,
12
+ isPlanEntryUtterance,
13
+ planModePreamble,
14
+ } from './plan_mode.mjs';
15
+
16
+ test('parsePlanOutput extracts numbered steps between markers', () => {
17
+ const out = parsePlanOutput('intro\nPLAN_BEGIN\n1. Read auth.ts\n2. Add login route\n3. Write test\nPLAN_END\nthanks');
18
+ assert.deepEqual(out.steps.map(s => s.text), ['Read auth.ts', 'Add login route', 'Write test']);
19
+ assert.equal(out.steps[0].status, 'pending');
20
+ assert.equal(out.steps[0].id, 1);
21
+ assert.deepEqual(out.decisions, []);
22
+ });
23
+
24
+ test('parsePlanOutput returns empty steps when markers missing', () => {
25
+ assert.deepEqual(parsePlanOutput('no plan here'), { steps: [], decisions: [] });
26
+ });
27
+
28
+ test('parsePlanOutput extracts decisions block', () => {
29
+ const text = [
30
+ 'PLAN_BEGIN',
31
+ '1. Add OAuth',
32
+ '2. Wire callback',
33
+ 'PLAN_END',
34
+ 'DECISIONS_BEGIN',
35
+ '- oauth_provider | Which OAuth provider? | google | github | both',
36
+ '- session_store | Where to store sessions? | redis | memory',
37
+ 'DECISIONS_END',
38
+ ].join('\n');
39
+ const out = parsePlanOutput(text);
40
+ assert.equal(out.steps.length, 2);
41
+ assert.equal(out.decisions.length, 2);
42
+ assert.equal(out.decisions[0].slot, 'oauth_provider');
43
+ assert.deepEqual(out.decisions[0].options, ['google', 'github', 'both']);
44
+ assert.equal(out.decisions[1].slot, 'session_store');
45
+ });
46
+
47
+ test('parseDecisions skips malformed lines', () => {
48
+ const text = 'DECISIONS_BEGIN\n- bad line\n- ok | question | a | b\nDECISIONS_END';
49
+ const out = parseDecisions(text);
50
+ assert.equal(out.length, 1);
51
+ assert.equal(out[0].slot, 'ok');
52
+ });
53
+
54
+ test('parseDecisionAnswer matches option by name', () => {
55
+ const decision = { slot: 'x', question: 'Pick one', options: ['redis', 'memory'] };
56
+ assert.deepEqual(parseDecisionAnswer('use redis', decision), { type: 'option', choice: 'redis' });
57
+ assert.deepEqual(parseDecisionAnswer('go with memory', decision), { type: 'option', choice: 'memory' });
58
+ });
59
+
60
+ test('parseDecisionAnswer matches by number and ordinal', () => {
61
+ const decision = { slot: 'x', question: 'Pick one', options: ['alpha', 'beta', 'gamma'] };
62
+ assert.deepEqual(parseDecisionAnswer('option 2', decision), { type: 'option', choice: 'beta' });
63
+ assert.deepEqual(parseDecisionAnswer('the first one', decision), { type: 'option', choice: 'alpha' });
64
+ assert.deepEqual(parseDecisionAnswer('세번째', decision), { type: 'option', choice: 'gamma' });
65
+ });
66
+
67
+ test('parseDecisionAnswer detects defer phrases', () => {
68
+ const decision = { slot: 'x', question: 'q', options: ['a', 'b'] };
69
+ assert.equal(parseDecisionAnswer('either is fine', decision).type, 'auto');
70
+ assert.equal(parseDecisionAnswer('you decide', decision).type, 'auto');
71
+ assert.equal(parseDecisionAnswer('아무거나', decision).type, 'auto');
72
+ });
73
+
74
+ test('parseDecisionAnswer returns unknown on no match', () => {
75
+ const decision = { slot: 'x', question: 'q', options: ['alpha', 'beta'] };
76
+ assert.equal(parseDecisionAnswer('hello world', decision).type, 'unknown');
77
+ });
78
+
79
+ test('renderDecisionPrompt formats question with numbered options', () => {
80
+ const decision = { slot: 'x', question: 'Pick auth?', options: ['google', 'github'] };
81
+ assert.match(renderDecisionPrompt(decision, 'en'), /Pick auth\?\s+Options:\s+1\) google\s+2\) github/);
82
+ });
83
+
84
+ test('renderResolvedDecisions handles auto choices', () => {
85
+ const resolved = { provider: 'github', store: null };
86
+ const out = renderResolvedDecisions(resolved, 'en');
87
+ assert.match(out, /provider=github/);
88
+ assert.match(out, /store=\(agent picks\)/);
89
+ });
90
+
91
+ test('parseVoiceCommand recognizes skip in en and ko', () => {
92
+ assert.deepEqual(parseVoiceCommand('skip step 3', 'en'), { type: 'skip', index: 3 });
93
+ assert.deepEqual(parseVoiceCommand('step 2 건너뛰어', 'ko'), { type: 'skip', index: 2 });
94
+ });
95
+
96
+ test('parseVoiceCommand recognizes insert in en', () => {
97
+ assert.deepEqual(parseVoiceCommand('add write a test after step 1', 'en'), { type: 'insert', after: 1, text: 'write a test' });
98
+ });
99
+
100
+ test('parseVoiceCommand recognizes insert in ko', () => {
101
+ assert.deepEqual(parseVoiceCommand('step 2 다음에 테스트 작성 추가', 'ko'), { type: 'insert', after: 2, text: '테스트 작성' });
102
+ });
103
+
104
+ test('parseVoiceCommand recognizes approve in both languages', () => {
105
+ assert.deepEqual(parseVoiceCommand('approve', 'en'), { type: 'approve' });
106
+ assert.deepEqual(parseVoiceCommand('go ahead', 'en'), { type: 'approve' });
107
+ assert.deepEqual(parseVoiceCommand('실행', 'ko'), { type: 'approve' });
108
+ });
109
+
110
+ test('parseVoiceCommand recognizes cancel', () => {
111
+ assert.deepEqual(parseVoiceCommand('cancel', 'en'), { type: 'cancel' });
112
+ assert.deepEqual(parseVoiceCommand('취소', 'ko'), { type: 'cancel' });
113
+ });
114
+
115
+ test('parseVoiceCommand falls through to unknown', () => {
116
+ assert.deepEqual(parseVoiceCommand('what is the meaning of life', 'en'), { type: 'unknown' });
117
+ });
118
+
119
+ test('applyCommand skip marks status', () => {
120
+ const steps = [{ id: 1, text: 'a', status: 'pending' }, { id: 2, text: 'b', status: 'pending' }];
121
+ const after = applyCommand(steps, { type: 'skip', index: 2 });
122
+ assert.equal(after[1].status, 'skipped');
123
+ });
124
+
125
+ test('applyCommand insert places new step after target', () => {
126
+ const steps = [{ id: 1, text: 'a', status: 'pending' }, { id: 2, text: 'b', status: 'pending' }];
127
+ const after = applyCommand(steps, { type: 'insert', after: 1, text: 'extra' });
128
+ assert.equal(after.length, 3);
129
+ assert.equal(after[1].text, 'extra');
130
+ assert.equal(after[1].status, 'added');
131
+ assert.equal(after[2].text, 'b');
132
+ });
133
+
134
+ test('renderFinalPlan skips skipped steps and renumbers', () => {
135
+ const steps = [
136
+ { id: 1, text: 'a', status: 'pending' },
137
+ { id: 2, text: 'b', status: 'skipped' },
138
+ { id: 3, text: 'c', status: 'pending' },
139
+ ];
140
+ assert.equal(renderFinalPlan(steps), '1. a\n2. c');
141
+ });
142
+
143
+ test('isPlanEntryUtterance detects entry phrases', () => {
144
+ assert.equal(isPlanEntryUtterance('plan it first', 'en'), true);
145
+ assert.equal(isPlanEntryUtterance('make a plan', 'en'), true);
146
+ assert.equal(isPlanEntryUtterance('먼저 계획 짜줘', 'ko'), true);
147
+ assert.equal(isPlanEntryUtterance('just do it', 'en'), false);
148
+ });
149
+
150
+ test('planModePreamble contains PLAN_BEGIN marker', () => {
151
+ assert.match(planModePreamble('en'), /PLAN_BEGIN/);
152
+ assert.match(planModePreamble('ko'), /PLAN_BEGIN/);
153
+ });
@@ -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 = 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
+ });