phewsh 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/phewsh.js CHANGED
@@ -7,7 +7,7 @@ const command = args[0];
7
7
  const b = (s) => `\x1b[1m${s}\x1b[0m`; // bold
8
8
  const d = (s) => `\x1b[2m${s}\x1b[0m`; // dim
9
9
  const w = (s) => `\x1b[97m${s}\x1b[0m`; // bright white
10
- const g = (s) => `\x1b[38;2;130;142;138m${s}\x1b[0m`; // slate (matches ui.js)
10
+ const g = (s) => `\x1b[38;2;152;164;158m${s}\x1b[0m`; // slate (matches ui.js)
11
11
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
12
12
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
13
13
 
@@ -17,7 +17,7 @@ const { select, refreshSession: refreshSess } = require('../lib/supabase');
17
17
  const { readPPS } = require('../lib/pps');
18
18
  const { push, pull, ensureValidToken } = require('./sync');
19
19
  const { HARNESSES, listHarnesses, runViaHarness } = require('../lib/harnesses');
20
- const { recordDecision, labelOutcome, pendingDecisions, OUTCOMES } = require('../lib/outcomes');
20
+ const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
21
21
  const { recordSessionEvent } = require('../lib/receipts-data');
22
22
 
23
23
  // Brand palette shortcuts
@@ -266,6 +266,7 @@ async function main() {
266
266
  let route = resolveRoute(config, harnesses);
267
267
  let sessionMode = null; // INTENT_MODES id once picked
268
268
  let awaitingOutcome = null; // decision id eligible for 1-4 labeling
269
+ let awaitingFallback = null; // { input, fullSystem, options } after a route failure
269
270
  let decisionsThisSession = 0;
270
271
 
271
272
  // ── The Exhale: animated brand reveal ──────────────────
@@ -278,45 +279,54 @@ async function main() {
278
279
  route = resolveRoute(config, harnesses);
279
280
  }
280
281
 
281
- // ── Project status (compact) ────────────────────────────
282
- const statusParts = [cream(projectName)];
283
- if (intentFiles.length > 0) {
284
- statusParts.push(teal('●') + sage(` .intent/ (${intentFiles.length} files)`));
285
- } else {
286
- statusParts.push(slate('no .intent/ — run /init'));
287
- }
288
- statusParts.push(sage('via ' + routeLabel(route, config)));
289
- console.log(` ${statusParts.join(slate(' · '))}`);
290
-
291
- // Capabilities: what's installed on this machine, no setup required
292
- if (installedHarnesses.length > 0) {
293
- const caps = installedHarnesses.map(h =>
294
- `${teal('✓')} ${sage(h.label)}${h.headless ? '' : slate(' (/work)')}`
295
- ).join(slate(' · '));
296
- console.log(` ${caps}`);
297
- }
298
-
299
- // Decisions from past sessions still waiting on an outcome
300
- const pendingPast = pendingDecisions();
301
- if (pendingPast.length > 0) {
302
- console.log(` ${peach('◌')} ${sage(`${pendingPast.length} decision${pendingPast.length !== 1 ? 's' : ''} awaiting outcome`)} ${slate('— /outcomes to label')}`);
303
- }
304
-
305
- // Sync status (one-line, non-blocking)
282
+ // ── Mission control: the whole state of your AI work, one screen ──────
283
+ // PROJECT what am I in · ROUTE where typing goes · BACKUP what's ready if
284
+ // the route hits a wall · WEB am I mirrored · RECORD what's accumulated
285
+ let syncState = null;
306
286
  if (config?.supabaseUserId && intentFiles.length > 0) {
307
- const syncResult = await Promise.race([
287
+ syncState = await Promise.race([
308
288
  checkSyncStatus(config),
309
289
  new Promise(resolve => setTimeout(() => resolve(null), 3000)),
310
290
  ]);
311
- if (syncResult) {
312
- if (syncResult.status === 'cloud-newer') {
313
- console.log(` ${ember('↓')} ${sage('Cloud newer (' + syncResult.ago + ') — /pull')}`);
314
- } else if (syncResult.status === 'local-newer') {
315
- console.log(` ${ember('')} ${sage('Local changes — /push')}`);
316
- } else if (syncResult.status === 'synced') {
317
- console.log(` ${teal('')} ${slate('synced')}`);
318
- }
319
- }
291
+ }
292
+
293
+ const row = (label, value) => console.log(` ${slate(label.padEnd(9))}${value}`);
294
+
295
+ row('PROJECT', cream(projectName) + (intentFiles.length > 0
296
+ ? slate(' · ') + teal('●') + sage(` .intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''}`)
297
+ : slate(' · no memory yet — ') + sage('/init')));
298
+
299
+ row('ROUTE', route
300
+ ? cream(routeLabel(route, config)) + (route.type === 'harness' ? slate(' · no API key needed') : '')
301
+ : ember('none — /key or install an agent CLI'));
302
+
303
+ const backups = harnesses.filter(h => h.installed && h.headless && !(route?.type === 'harness' && route.id === h.id));
304
+ const workOnly = installedHarnesses.filter(h => !h.headless);
305
+ const backupParts = backups.map(h => `${teal('✓')} ${sage(h.label)}`);
306
+ if (config?.apiKey && route?.type !== 'api') backupParts.push(`${teal('✓')} ${sage('direct API')}`);
307
+ workOnly.forEach(h => backupParts.push(sage(h.label) + slate(' /work')));
308
+ row('BACKUP', backupParts.length > 0
309
+ ? backupParts.join(slate(' · ')) + slate(' — context travels if the route hits a wall')
310
+ : slate('none — install Codex or Gemini to cover usage limits'));
311
+
312
+ if (config?.supabaseUserId) {
313
+ const syncLabel = syncState?.status === 'synced' ? teal('↕ ') + sage('mirrored')
314
+ : syncState?.status === 'cloud-newer' ? ember('↓ ') + sage(`cloud newer (${syncState.ago}) — /pull`)
315
+ : syncState?.status === 'local-newer' ? ember('↑ ') + sage('local ahead — /push')
316
+ : sage('linked');
317
+ row('WEB', cream(config.email || 'logged in') + slate(' · ') + syncLabel);
318
+ } else {
319
+ row('WEB', sage('local-only (works fine)') + slate(' · /login mirrors this at phewsh.com/intent'));
320
+ }
321
+
322
+ const oStats = outcomeStats();
323
+ const oLabeled = oStats.total - oStats.pending;
324
+ if (oStats.total > 0) {
325
+ const keptRate = oLabeled > 0 ? Math.round((oStats.kept / oLabeled) * 100) + '% kept' : 'unlabeled';
326
+ row('RECORD', cream(`${oStats.total} decision${oStats.total !== 1 ? 's' : ''}`) + slate(' · ') + sage(keptRate)
327
+ + (oStats.pending > 0 ? slate(' · ') + peach(`${oStats.pending} awaiting outcome`) + slate(' — /outcomes') : ''));
328
+ } else {
329
+ row('RECORD', slate('empty — decisions and outcomes accumulate as you work'));
320
330
  }
321
331
 
322
332
  console.log('');
@@ -333,10 +343,107 @@ async function main() {
333
343
  } else {
334
344
  console.log(` ${b(cream('What are you trying to do?'))}`);
335
345
  console.log(` ${teal('1')} ${sage('Build')} ${slate('·')} ${teal('2')} ${sage('Research')} ${slate('·')} ${teal('3')} ${sage('Decide')} ${slate('·')} ${teal('4')} ${sage('Review')} ${slate('·')} ${teal('5')} ${sage('Ask another model')}`);
336
- console.log(` ${slate('pick a number, or just type — both work')}`);
346
+ console.log(` ${slate('pick a number, or just type — your context travels with every route')}`);
337
347
  }
338
348
  console.log('');
339
349
 
350
+ // ── Turn runners — every route records a decision, leaves a receipt ────
351
+ // Both return true on success so the fallback flow can chain them.
352
+
353
+ async function runHarnessTurn(input, harnessId, fullSystem) {
354
+ const decisionId = recordDecision({
355
+ project: projectName, route: harnessId, mode: sessionMode, summary: input,
356
+ });
357
+ decisionsThisSession++;
358
+ try {
359
+ const output = await runViaHarness(harnessId, fullSystem, buildHarnessPrompt(messages, input));
360
+ messages.push({ role: 'user', content: input });
361
+ messages.push({ role: 'assistant', content: (output || '').trim() });
362
+ recordSessionEvent(harnessId, projectName, 'task_complete', {
363
+ taskId: decisionId, success: true, summary: input.slice(0, 140),
364
+ });
365
+ awaitingOutcome = decisionId;
366
+ console.log(slate(` via ${HARNESSES[harnessId].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
367
+ return true;
368
+ } catch (err) {
369
+ try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
370
+ recordSessionEvent(harnessId, projectName, 'task_complete', {
371
+ taskId: decisionId, success: false, summary: input.slice(0, 140),
372
+ });
373
+ const limitHit = /limit|quota|rate|usage|exhaust/i.test(err.message);
374
+ console.error(`\n ${ember('!')} ${cream(HARNESSES[harnessId].label)} ${sage(limitHit ? 'hit a usage wall' : 'failed')}${slate(' — ' + err.message.split('\n')[0])}`);
375
+ return false;
376
+ }
377
+ }
378
+
379
+ async function runApiTurn(input, fullSystem) {
380
+ const decisionId = recordDecision({
381
+ project: projectName, route: 'api', mode: sessionMode, summary: input,
382
+ });
383
+ decisionsThisSession++;
384
+ messages.push({ role: 'user', content: input });
385
+ console.log('');
386
+ try {
387
+ const result = await streamChat(config.apiKey, messages, fullSystem, MODELS[currentModel].id);
388
+ messages.push({ role: 'assistant', content: result.content });
389
+ if (result.promptTokens) totalPromptTokens += result.promptTokens;
390
+ if (result.completionTokens) totalCompletionTokens += result.completionTokens;
391
+ if (result.promptTokens || result.completionTokens) {
392
+ console.log(slate(` ${result.promptTokens || '?'}→${result.completionTokens || '?'} tokens · ${MODELS[currentModel].name} · outcome? 1-4 or keep typing`));
393
+ }
394
+ awaitingOutcome = decisionId;
395
+ trackSap({
396
+ userId: config.supabaseUserId,
397
+ source: 'cli',
398
+ model: MODELS[currentModel].id,
399
+ promptTokens: result.promptTokens,
400
+ completionTokens: result.completionTokens,
401
+ accessToken: config.supabaseAccessToken,
402
+ });
403
+ return true;
404
+ } catch (err) {
405
+ try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
406
+ messages.pop();
407
+ console.error(`\n ${ember('!')} ${sage('API route failed')}${slate(' — ' + err.message.split('\n')[0])}`);
408
+ return false;
409
+ }
410
+ }
411
+
412
+ // Fallbacks are a first-class flow: the route changes, the context and
413
+ // record do not. Ask by default; auto-switch only if setup said so.
414
+ async function offerFallbacks(input, fullSystem, failedId) {
415
+ const options = harnesses
416
+ .filter(h => h.installed && h.headless && h.id !== failedId)
417
+ .map(h => h.id);
418
+ if (config?.apiKey && failedId !== 'api') options.push('api');
419
+
420
+ if (options.length === 0) {
421
+ console.log(` ${sage('No fallback ready.')} ${slate('Install Codex or Gemini, or add an API key with /key — context would travel automatically.')}`);
422
+ console.log('');
423
+ return;
424
+ }
425
+
426
+ if (config?.fallback === 'auto') {
427
+ const fb = options[0];
428
+ const fbLabel = fb === 'api' ? 'direct API' : HARNESSES[fb].label;
429
+ console.log(` ${peach('↻')} ${sage('auto-fallback →')} ${cream(fbLabel)} ${slate('· same context, same record')}`);
430
+ const ok = fb === 'api'
431
+ ? await runApiTurn(input, fullSystem)
432
+ : await runHarnessTurn(input, fb, fullSystem);
433
+ if (!ok) console.log(` ${ember('!')} ${sage('Fallback failed too — /provider to inspect routes.')}`);
434
+ console.log('');
435
+ return;
436
+ }
437
+
438
+ const list = options.map((id, i) =>
439
+ `${teal(String(i + 1))} ${sage(id === 'api' ? 'direct API (your key)' : HARNESSES[id].label)}`
440
+ ).join(slate(' · '));
441
+ console.log(` ${sage('Retry with your context intact:')} ${list} ${slate('· enter = skip')}`);
442
+ console.log(` ${slate('prefer auto-switching? phewsh setup sets it once')}`);
443
+ awaitingFallback = { input, fullSystem, options };
444
+ console.log('');
445
+ }
446
+
340
447
  const rl = readline.createInterface({
341
448
  input: process.stdin,
342
449
  output: process.stdout,
@@ -354,6 +461,24 @@ async function main() {
354
461
  return;
355
462
  }
356
463
 
464
+ // A bare number right after a route failure picks the fallback
465
+ if (awaitingFallback) {
466
+ const af = awaitingFallback;
467
+ awaitingFallback = null;
468
+ const n = parseInt(input, 10);
469
+ if (n >= 1 && n <= af.options.length) {
470
+ const fb = af.options[n - 1];
471
+ const ok = fb === 'api'
472
+ ? await runApiTurn(af.input, af.fullSystem)
473
+ : await runHarnessTurn(af.input, fb, af.fullSystem);
474
+ if (!ok) await offerFallbacks(af.input, af.fullSystem, fb);
475
+ console.log('');
476
+ rl.prompt();
477
+ return;
478
+ }
479
+ // anything else: drop the offer and treat it as fresh input
480
+ }
481
+
357
482
  // A bare 1-4 right after a routed action labels its outcome
358
483
  if (awaitingOutcome && /^[1-4]$/.test(input)) {
359
484
  const outcome = OUTCOMES[parseInt(input, 10) - 1];
@@ -834,6 +959,7 @@ async function main() {
834
959
  rows.push([h.label, h.installed ? `installed (${h.auth})` : 'not installed', h.installed ? 'green' : undefined]);
835
960
  }
836
961
  rows.push(['API key', config?.apiKey ? config.apiKey.slice(0, 8) + '... (' + (config.provider || 'anthropic') + ')' : 'not set — optional', config?.apiKey ? 'green' : 'yellow']);
962
+ rows.push(['Fallback', config?.fallback === 'auto' ? 'auto-switch on failure' : 'ask before switching', 'peach']);
837
963
  if (route?.type === 'api') rows.push(['Model', MODELS[currentModel].name, 'cyan']);
838
964
  ui.statusPanel('Provider', rows);
839
965
  console.log(` ${slate('switch:')} ${cream('/use <' + harnesses.filter(h => h.installed).map(h => h.id).concat(config?.apiKey ? ['api'] : []).join('|') + '>')}`);
@@ -1083,68 +1209,12 @@ async function main() {
1083
1209
  : null;
1084
1210
  const fullSystem = modeHint ? `${systemPrompt}\n\n${modeHint}` : systemPrompt;
1085
1211
 
1086
- // Every routed action is a decision — recorded before it runs,
1087
- // labeled (1-4) when the outcome is known.
1088
- const decisionId = recordDecision({
1089
- project: projectName,
1090
- route: route.type === 'api' ? 'api' : route.id,
1091
- mode: sessionMode,
1092
- summary: input,
1093
- });
1094
- decisionsThisSession++;
1095
-
1096
- if (route.type === 'harness') {
1097
- try {
1098
- const output = await runViaHarness(route.id, fullSystem, buildHarnessPrompt(messages, input));
1099
- messages.push({ role: 'user', content: input });
1100
- messages.push({ role: 'assistant', content: (output || '').trim() });
1101
- recordSessionEvent(route.id, projectName, 'task_complete', {
1102
- taskId: decisionId, success: true, summary: input.slice(0, 140),
1103
- });
1104
- awaitingOutcome = decisionId;
1105
- console.log(slate(` via ${HARNESSES[route.id].label} · outcome? 1 kept · 2 reverted · 3 superseded · 4 failed · or keep typing`));
1106
- } catch (err) {
1107
- try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
1108
- recordSessionEvent(route.id, projectName, 'task_complete', {
1109
- taskId: decisionId, success: false, summary: input.slice(0, 140),
1110
- });
1111
- console.error(`\n ${ember('!')} ${err.message}`);
1112
- if (installedHarnesses.length > 1 || config?.apiKey) {
1113
- console.log(` ${slate('switch routes with /use — /provider shows what\'s available')}`);
1114
- }
1115
- }
1116
- console.log('');
1117
- rl.prompt();
1118
- return;
1119
- }
1120
-
1121
- messages.push({ role: 'user', content: input });
1122
- console.log('');
1212
+ const ok = route.type === 'harness'
1213
+ ? await runHarnessTurn(input, route.id, fullSystem)
1214
+ : await runApiTurn(input, fullSystem);
1123
1215
 
1124
- try {
1125
- const result = await streamChat(config.apiKey, messages, fullSystem, MODELS[currentModel].id);
1126
- messages.push({ role: 'assistant', content: result.content });
1127
-
1128
- if (result.promptTokens) totalPromptTokens += result.promptTokens;
1129
- if (result.completionTokens) totalCompletionTokens += result.completionTokens;
1130
-
1131
- if (result.promptTokens || result.completionTokens) {
1132
- console.log(slate(` ${result.promptTokens || '?'}→${result.completionTokens || '?'} tokens · ${MODELS[currentModel].name} · outcome? 1-4 or keep typing`));
1133
- }
1134
- awaitingOutcome = decisionId;
1135
-
1136
- trackSap({
1137
- userId: config.supabaseUserId,
1138
- source: 'cli',
1139
- model: MODELS[currentModel].id,
1140
- promptTokens: result.promptTokens,
1141
- completionTokens: result.completionTokens,
1142
- accessToken: config.supabaseAccessToken,
1143
- });
1144
- } catch (err) {
1145
- try { labelOutcome(decisionId, 'failed'); } catch { /* keep going */ }
1146
- console.error(`\n ${err.message}\n`);
1147
- messages.pop();
1216
+ if (!ok) {
1217
+ await offerFallbacks(input, fullSystem, route.type === 'harness' ? route.id : 'api');
1148
1218
  }
1149
1219
 
1150
1220
  console.log('');
package/commands/setup.js CHANGED
@@ -63,6 +63,7 @@ module.exports = async function setup() {
63
63
  if (!process.stdin.isTTY) {
64
64
  if (chatCapable.length > 0) {
65
65
  config.defaultRoute = chatCapable[0].id;
66
+ if (!config.fallback) config.fallback = 'ask';
66
67
  saveConfig(config);
67
68
  console.log(` ${teal('●')} ${sage('Auto-configured (non-interactive): default route =')} ${cream(chatCapable[0].label)} ${slate('— no API key needed')}`);
68
69
  console.log(` ${slate('Change anytime: run `phewsh setup` in your own terminal, or /use inside a session.')}`);
@@ -128,9 +129,20 @@ module.exports = async function setup() {
128
129
  }
129
130
  }
130
131
 
132
+ // ── 4. Fallback behavior — first-class, not buried config ────────────
133
+ console.log('');
134
+ console.log(` ${b(cream('If your route hits a usage limit or fails:'))}`);
135
+ console.log(` ${teal('1')} ${sage('Ask me before switching')} ${slate('(default — shows what changes, context always travels)')}`);
136
+ console.log(` ${teal('2')} ${sage('Auto-switch to the next available route')}`);
137
+ console.log('');
138
+ const fbAnswer = await ask(rl, ` ${teal('>')} ${slate('1-2, enter = 1: ')}`);
139
+ config.fallback = fbAnswer.trim() === '2' ? 'auto' : 'ask';
140
+ saveConfig(config);
141
+ console.log(` ${teal('●')} ${sage('Fallback:')} ${cream(config.fallback === 'auto' ? 'auto-switch' : 'ask first')} ${slate('— either way, your project context and record stay intact')}`);
142
+
131
143
  rl.close();
132
144
 
133
- // ── 4. Done ───────────────────────────────────────────
145
+ // ── 5. Done ───────────────────────────────────────────
134
146
  console.log('');
135
147
  ui.divider('line');
136
148
  console.log(` ${teal('●')} ${b(cream('Setup complete.'))}`);
package/lib/ui.js CHANGED
@@ -11,8 +11,8 @@ const rgbBg = (r, g, b) => (s) => `\x1b[48;2;${r};${g};${b}m${s}\x1b[0m`;
11
11
  // Brand colors — relief, quiet, future
12
12
  const teal = rgb(100, 215, 195); // cool calm — primary
13
13
  const peach = rgb(255, 195, 145); // warm exhale — accent
14
- const sage = rgb(175, 195, 185); // quiet — secondary text
15
- const slate = rgb(130, 142, 138); // whisper — dim text
14
+ const sage = rgb(190, 208, 198); // quiet — secondary text
15
+ const slate = rgb(152, 164, 158); // whisper — dim text (bright enough for dark terminals)
16
16
  const cream = rgb(240, 235, 225); // clarity — bright text
17
17
  const ember = rgb(220, 140, 90); // glow — warnings/energy
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"