phewsh 0.15.23 → 0.15.25

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.
@@ -21,6 +21,8 @@ const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../li
21
21
  const { recordDecision, labelOutcome, pendingDecisions, recentDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
22
22
  const { suggest, suggestAll } = require('../lib/suggest');
23
23
  const continuity = require('../lib/continuity');
24
+ const learning = require('../lib/learning');
25
+ const recall = require('../lib/recall');
24
26
  const { closest } = require('../lib/closest');
25
27
  const cmdHistory = require('../lib/history');
26
28
  const { recordSessionEvent } = require('../lib/receipts-data');
@@ -582,7 +584,25 @@ async function main() {
582
584
  const failureTracker = createFailureTracker();
583
585
  let lastTurnFailure = null;
584
586
 
587
+ // The gate looking backward: if this is close to something you already
588
+ // reverted or failed, say so once — quietly, before the turn runs.
589
+ let lastRecallId = null;
590
+ function recallHeadsUp(input) {
591
+ try {
592
+ const past = recentDecisions(300, { project: projectName });
593
+ const hit = recall.closestRegret(past, input, { project: projectName, minSimilarity: 0.5 });
594
+ if (!hit || hit.id === lastRecallId) return;
595
+ lastRecallId = hit.id;
596
+ let s = (hit.summary || '').replace(/\s+/g, ' ');
597
+ if (s.length > 50) s = s.slice(0, 49).trimEnd() + '…';
598
+ const verb = hit.outcome === 'failed' ? 'failed' : 'reverted';
599
+ console.log(` ${peach('↩')} ${sage(`You ${verb} something close before:`)} ${slate('“' + s + '” · via ' + continuity.labelFor(hit.route) + ' · ' + continuity.agoText(hit.ts))}`);
600
+ console.log(` ${slate(' not a block — just so the record doesn\'t let you repeat it blind.')}`);
601
+ } catch { /* recall is advisory, never blocks a turn */ }
602
+ }
603
+
585
604
  async function runHarnessTurn(input, harnessId, fullSystem) {
605
+ recallHeadsUp(input);
586
606
  const decisionId = recordDecision({
587
607
  project: projectName, route: harnessId, mode: sessionMode, summary: input,
588
608
  });
@@ -622,6 +642,7 @@ async function main() {
622
642
  }
623
643
 
624
644
  async function runApiTurn(input, fullSystem) {
645
+ recallHeadsUp(input);
625
646
  const decisionId = recordDecision({
626
647
  project: projectName, route: 'api', mode: sessionMode, summary: input,
627
648
  });
@@ -770,7 +791,7 @@ async function main() {
770
791
  'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
771
792
  'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
772
793
  'agents', 'context', 'gate', 'reload', 'sequence', 'seq', 'setup', 'system', 'watch',
773
- 'next', 'recommend', 'guide', 'thread', 'continuity',
794
+ 'next', 'recommend', 'guide', 'thread', 'continuity', 'learn', 'stats',
774
795
  ]);
775
796
  const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
776
797
  let turnAbort = null; // AbortController while an API turn streams
@@ -1158,6 +1179,46 @@ async function main() {
1158
1179
  return;
1159
1180
  }
1160
1181
 
1182
+ // ── /learn ─────────────────────────────────────────
1183
+ // What the record has learned — kept-rates by tool and by mode, so the
1184
+ // 100th decision is better-informed than the 1st. Honest: stays quiet
1185
+ // until there's real labeled signal.
1186
+ if (cmd === 'learn' || cmd === 'stats') {
1187
+ let stats = null;
1188
+ try { stats = outcomeStats({ project: projectName }); } catch { /* best-effort */ }
1189
+ const labeled = stats ? learning.totalLabeled(stats) : 0;
1190
+ console.log('');
1191
+ if (labeled < 5) {
1192
+ console.log(` ${teal('●')} ${sage(`Not enough labeled decisions yet (${labeled}).`)} ${slate('Label outcomes with /outcomes — the record gets smarter as you do.')}`);
1193
+ console.log('');
1194
+ rl.prompt();
1195
+ return;
1196
+ }
1197
+ console.log(` ${b(cream('What your record has learned'))} ${slate(`— ${labeled} labeled decisions, ${projectName}`)}`);
1198
+ ui.divider('line');
1199
+ console.log(` ${sage('by tool')} ${slate('(kept-rate, best first)')}`);
1200
+ for (const r of learning.routeRates(stats, { minSample: 2 })) {
1201
+ const pct = Math.round(r.keptRate * 100);
1202
+ const bar = '█'.repeat(Math.round(r.keptRate * 10)).padEnd(10, '░');
1203
+ console.log(` ${continuity.labelFor(r.route).padEnd(14)} ${teal(bar)} ${cream(pct + '%')} ${slate(`(${r.kept}/${r.total} kept)`)}`);
1204
+ }
1205
+ const modes = learning.modeRates(stats, { minSample: 2 });
1206
+ if (modes.length) {
1207
+ console.log('');
1208
+ console.log(` ${sage('by kind of work')}`);
1209
+ for (const m of modes) {
1210
+ const pct = Math.round(m.keptRate * 100);
1211
+ console.log(` ${String(m.mode).padEnd(14)} ${cream(pct + '%')} ${slate(`(${m.kept}/${m.total} kept)`)}`);
1212
+ }
1213
+ }
1214
+ const best = learning.bestRoute(stats, { minSample: 3 });
1215
+ ui.divider('line');
1216
+ if (best) console.log(` ${teal('↪')} ${sage(`${continuity.labelFor(best.route)} keeps best for you (${Math.round(best.keptRate * 100)}%).`)} ${slate('/use ' + best.route + ' to lean on it.')}`);
1217
+ console.log('');
1218
+ rl.prompt();
1219
+ return;
1220
+ }
1221
+
1161
1222
  if (cmd === 'help' || cmd === 'h') {
1162
1223
  const wantsAll = /^(all|more|full|everything)$/i.test(cmdArg.trim());
1163
1224
 
@@ -1187,6 +1248,7 @@ async function main() {
1187
1248
  console.log(` ${cream('not sure what to do?')}`);
1188
1249
  console.log(` ${teal('/next')} ${sage("phewsh reads your state and hands back the next step worth taking")}`);
1189
1250
  console.log(` ${teal('/thread')} ${sage('where you left off — your work across every tool, one record')}`);
1251
+ console.log(` ${teal('/learn')} ${sage('what your record taught — which tool keeps best, by kind of work')}`);
1190
1252
  console.log('');
1191
1253
  console.log(` ${cream('author .intent/')}`);
1192
1254
  console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
@@ -1938,6 +2000,9 @@ async function main() {
1938
2000
  console.log('');
1939
2001
  console.log(` ${b(cream('Your AI tools'))} ${slate('— phewsh keeps them all, aligned. You never pick just one.')}`);
1940
2002
  ui.divider('line');
2003
+ // The record feeding back: kept-rate per route, where it's earned.
2004
+ let hStats = null;
2005
+ try { hStats = outcomeStats({ project: projectName }); } catch { /* best-effort */ }
1941
2006
  // Installed first, then the rest so the table also teaches what exists.
1942
2007
  const sorted = [...harnesses].sort((a, b) => (b.installed - a.installed));
1943
2008
  let lastGroup = null;
@@ -1950,9 +2015,13 @@ async function main() {
1950
2015
  const active = route?.type === 'harness' && route.id === h.id ? ` ${teal('● active')}` : '';
1951
2016
  const dot = h.installed ? green('●') : slate('○');
1952
2017
  const mode = h.headless ? '' : slate(' · /work only');
1953
- console.log(` ${dot} ${cream(h.id.padEnd(11))} ${sage((h.role || h.label).padEnd(20))} ${slate(h.label)}${mode}${active}`);
2018
+ const badge = hStats ? learning.keptBadge(hStats, h.id) : '';
2019
+ const rec = badge ? ` ${slate('· ' + badge)}` : '';
2020
+ console.log(` ${dot} ${cream(h.id.padEnd(11))} ${sage((h.role || h.label).padEnd(20))} ${slate(h.label)}${mode}${rec}${active}`);
1954
2021
  }
1955
2022
  ui.divider('line');
2023
+ const learned = hStats ? learning.learningLine(hStats) : null;
2024
+ if (learned) console.log(` ${teal('↪')} ${sage(learned)} ${slate('— route accordingly')}`);
1956
2025
  console.log(` ${sage('keep your tools, keep one record:')}`);
1957
2026
  console.log(` ${teal('/use')} ${slate('<id>')} ${sage('route your typing through that tool')}`);
1958
2027
  console.log(` ${teal('@<id>')} ${slate('<msg>')} ${sage('one message to one tool — context stays shared')}`);
@@ -0,0 +1,62 @@
1
+ // Learning loops — what gets smarter after the 100th decision.
2
+ //
3
+ // The decision record is already labeled (kept / reverted / superseded /
4
+ // failed) per route and per mode. This turns that into insight that feeds
5
+ // BACK into the next decision: which tool actually keeps best for you, and
6
+ // for which kind of work. Honest by construction — nothing surfaces until
7
+ // there's enough labeled signal, so there are no fake gauges.
8
+ //
9
+ // Pure: feed it outcomeStats(), get back rankings + one-line readouts.
10
+
11
+ const { labelFor } = require('./continuity');
12
+
13
+ function rate(r) { return r.total ? r.kept / r.total : 0; }
14
+
15
+ /** Per-route kept-rates, best first. Filters out thin samples. */
16
+ function routeRates(stats, { minSample = 1 } = {}) {
17
+ return Object.entries((stats && stats.byRoute) || {})
18
+ .map(([route, r]) => ({ route, total: r.total, kept: r.kept, keptRate: rate(r) }))
19
+ .filter((r) => r.total >= minSample)
20
+ .sort((a, b) => b.keptRate - a.keptRate || b.total - a.total);
21
+ }
22
+
23
+ /** Per-mode kept-rates, best first. */
24
+ function modeRates(stats, { minSample = 1 } = {}) {
25
+ return Object.entries((stats && stats.byMode) || {})
26
+ .map(([mode, m]) => ({ mode, total: m.total, kept: m.kept, keptRate: rate(m) }))
27
+ .filter((m) => m.total >= minSample)
28
+ .sort((a, b) => b.keptRate - a.keptRate || b.total - a.total);
29
+ }
30
+
31
+ function totalLabeled(stats) {
32
+ if (!stats) return 0;
33
+ return (stats.kept || 0) + (stats.reverted || 0) + (stats.superseded || 0) + (stats.failed || 0);
34
+ }
35
+
36
+ /** The route with the best kept-rate, given enough data, or null. */
37
+ function bestRoute(stats, { minSample = 3 } = {}) {
38
+ const rates = routeRates(stats, { minSample });
39
+ return rates.length ? rates[0] : null;
40
+ }
41
+
42
+ /**
43
+ * One honest line of what the record has learned, or null if too thin.
44
+ * "After 23 labeled: Codex 8/10 · Claude Code 5/9 kept"
45
+ */
46
+ function learningLine(stats, { labeler = null, minLabeled = 5, top = 3 } = {}) {
47
+ const labeled = totalLabeled(stats);
48
+ if (labeled < minLabeled) return null;
49
+ const rates = routeRates(stats, { minSample: 2 }).slice(0, top);
50
+ if (!rates.length) return null;
51
+ const parts = rates.map((r) => `${labelFor(r.route, labeler)} ${r.kept}/${r.total}`);
52
+ return `After ${labeled} labeled: ${parts.join(' · ')} kept`;
53
+ }
54
+
55
+ /** A short percentage badge for one route, or '' if too thin to be honest. */
56
+ function keptBadge(stats, route, { minSample = 2 } = {}) {
57
+ const r = stats && stats.byRoute && stats.byRoute[route];
58
+ if (!r || r.total < minSample) return '';
59
+ return `${r.kept}/${r.total} kept`;
60
+ }
61
+
62
+ module.exports = { routeRates, modeRates, bestRoute, learningLine, keptBadge, totalLabeled };
package/lib/recall.js ADDED
@@ -0,0 +1,59 @@
1
+ // Recall — the record warning you before you repeat a mistake.
2
+ //
3
+ // Every decision is labeled. When you're about to do something close to what
4
+ // you already tried and *reverted* or *failed*, phewsh should say so — once,
5
+ // quietly, before you spend the turn. This is the decision gate looking
6
+ // backward: "you've been here; it didn't hold."
7
+ //
8
+ // Pure: feed it past decisions + the new text, get back the closest prior
9
+ // regret, or null. Similarity is token-overlap (Jaccard) so it matches intent
10
+ // ("add dark mode toggle" ≈ "build the dark-mode switch"), not exact strings.
11
+
12
+ const STOP = new Set([
13
+ 'the', 'a', 'an', 'to', 'of', 'and', 'or', 'for', 'in', 'on', 'at', 'with',
14
+ 'is', 'it', 'this', 'that', 'i', 'we', 'my', 'our', 'me', 'be', 'do', 'can',
15
+ 'will', 'would', 'should', 'let', 'lets', 'please', 'just', 'make', 'add',
16
+ 'use', 'using', 'get', 'set', 'so', 'as', 'by', 'from', 'into', 'up',
17
+ ]);
18
+
19
+ const REGRET = new Set(['reverted', 'failed']);
20
+
21
+ function tokens(s) {
22
+ return new Set(
23
+ String(s || '')
24
+ .toLowerCase()
25
+ .split(/[^a-z0-9]+/)
26
+ .filter((w) => w.length > 2 && !STOP.has(w))
27
+ );
28
+ }
29
+
30
+ /** Jaccard overlap of two strings' meaningful tokens, 0..1. */
31
+ function similarity(a, b) {
32
+ const A = tokens(a), B = tokens(b);
33
+ if (A.size === 0 || B.size === 0) return 0;
34
+ let inter = 0;
35
+ for (const t of A) if (B.has(t)) inter++;
36
+ return inter / (A.size + B.size - inter);
37
+ }
38
+
39
+ /**
40
+ * Prior reverted/failed decisions similar to `text`, most-similar first.
41
+ * @param {object[]} decisions
42
+ * @param {string} text
43
+ * @param {object} [opts] { project, minSimilarity=0.5 }
44
+ */
45
+ function recallSimilar(decisions, text, { project = null, minSimilarity = 0.5 } = {}) {
46
+ return (decisions || [])
47
+ .filter((d) => d && REGRET.has(d.outcome) && (!project || d.project === project))
48
+ .map((d) => ({ ...d, similarity: similarity(text, d.summary) }))
49
+ .filter((d) => d.similarity >= minSimilarity)
50
+ .sort((a, b) => b.similarity - a.similarity);
51
+ }
52
+
53
+ /** The single closest prior regret, or null. */
54
+ function closestRegret(decisions, text, opts = {}) {
55
+ const hits = recallSimilar(decisions, text, opts);
56
+ return hits.length ? hits[0] : null;
57
+ }
58
+
59
+ module.exports = { similarity, recallSimilar, closestRegret, tokens };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.23",
3
+ "version": "0.15.25",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"