phewsh 0.15.24 → 0.15.26

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.
@@ -22,6 +22,7 @@ const { recordDecision, labelOutcome, pendingDecisions, recentDecisions, outcome
22
22
  const { suggest, suggestAll } = require('../lib/suggest');
23
23
  const continuity = require('../lib/continuity');
24
24
  const learning = require('../lib/learning');
25
+ const recall = require('../lib/recall');
25
26
  const { closest } = require('../lib/closest');
26
27
  const cmdHistory = require('../lib/history');
27
28
  const { recordSessionEvent } = require('../lib/receipts-data');
@@ -333,6 +334,7 @@ async function main() {
333
334
  let route = resolveRoute(config, harnesses);
334
335
  let sessionMode = null; // INTENT_MODES id once picked
335
336
  let awaitingOutcome = null; // decision id eligible for 1-4 labeling
337
+ let awaitingWhy = null; // { id, outcome } — next line is the reason
336
338
  let awaitingFallback = null; // { input, fullSystem, options } after a route failure
337
339
  let bootstrapChoices = null; // root-bootstrap menu entries when no project here
338
340
  let nextChoices = null; // ranked /next suggestions awaiting a numeric pick
@@ -583,7 +585,26 @@ async function main() {
583
585
  const failureTracker = createFailureTracker();
584
586
  let lastTurnFailure = null;
585
587
 
588
+ // The gate looking backward: if this is close to something you already
589
+ // reverted or failed, say so once — quietly, before the turn runs.
590
+ let lastRecallId = null;
591
+ function recallHeadsUp(input) {
592
+ try {
593
+ const past = recentDecisions(300, { project: projectName });
594
+ const hit = recall.closestRegret(past, input, { project: projectName, minSimilarity: 0.5 });
595
+ if (!hit || hit.id === lastRecallId) return;
596
+ lastRecallId = hit.id;
597
+ let s = (hit.summary || '').replace(/\s+/g, ' ');
598
+ if (s.length > 50) s = s.slice(0, 49).trimEnd() + '…';
599
+ const verb = hit.outcome === 'failed' ? 'failed' : 'reverted';
600
+ console.log(` ${peach('↩')} ${sage(`You ${verb} something close before:`)} ${slate('“' + s + '” · via ' + continuity.labelFor(hit.route) + ' · ' + continuity.agoText(hit.ts))}`);
601
+ if (hit.why) console.log(` ${slate(' why it didn\'t hold: ')}${sage(hit.why)}`);
602
+ console.log(` ${slate(' not a block — just so the record doesn\'t let you repeat it blind.')}`);
603
+ } catch { /* recall is advisory, never blocks a turn */ }
604
+ }
605
+
586
606
  async function runHarnessTurn(input, harnessId, fullSystem) {
607
+ recallHeadsUp(input);
587
608
  const decisionId = recordDecision({
588
609
  project: projectName, route: harnessId, mode: sessionMode, summary: input,
589
610
  });
@@ -623,6 +644,7 @@ async function main() {
623
644
  }
624
645
 
625
646
  async function runApiTurn(input, fullSystem) {
647
+ recallHeadsUp(input);
626
648
  const decisionId = recordDecision({
627
649
  project: projectName, route: 'api', mode: sessionMode, summary: input,
628
650
  });
@@ -935,6 +957,16 @@ async function main() {
935
957
  async function handleInput(input) {
936
958
  input = expandPastes(input);
937
959
 
960
+ // Answering the "why?" prompt — the whole line is the reason, not a command.
961
+ if (awaitingWhy) {
962
+ const { id, outcome } = awaitingWhy;
963
+ awaitingWhy = null;
964
+ try { labelOutcome(id, outcome, input); console.log(` ${slate('noted — the record will remember why.')}`); } catch { /* gone */ }
965
+ console.log('');
966
+ rl.prompt();
967
+ return;
968
+ }
969
+
938
970
  // Any typed input supersedes a pending "did you mean" offer.
939
971
  if (pendingDidYouMean) pendingDidYouMean = null;
940
972
 
@@ -959,12 +991,22 @@ async function main() {
959
991
  // A bare 1-4 right after a routed action labels its outcome
960
992
  if (awaitingOutcome && /^[1-4]$/.test(input)) {
961
993
  const outcome = OUTCOMES[parseInt(input, 10) - 1];
994
+ let labeled = null;
962
995
  try {
963
- labelOutcome(awaitingOutcome, outcome);
996
+ labeled = labelOutcome(awaitingOutcome, outcome);
964
997
  const color = outcome === 'kept' ? green : outcome === 'superseded' ? peach : ember;
965
998
  console.log(` ${teal('●')} ${sage('outcome:')} ${color(outcome)}`);
966
999
  } catch { /* decision vanished — nothing to do */ }
1000
+ const id = awaitingOutcome;
967
1001
  awaitingOutcome = null;
1002
+ // For a regret, the reason is the gold — capture one line so /recall can
1003
+ // tell you *why* next time. Kept/superseded stay frictionless.
1004
+ if (labeled && (outcome === 'reverted' || outcome === 'failed')) {
1005
+ awaitingWhy = { id, outcome };
1006
+ console.log(` ${slate('why? one line — feeds /recall next time · enter to skip')}`);
1007
+ rl.prompt();
1008
+ return;
1009
+ }
968
1010
  console.log('');
969
1011
  rl.prompt();
970
1012
  return;
@@ -2248,6 +2290,8 @@ async function main() {
2248
2290
  const lineDispatcher = createLineDispatcher(handleInput, {
2249
2291
  onBatch: ({ input, lines }) => { collapsePastedEcho(lines, input); cmdHistory.append(input); },
2250
2292
  onNoop: () => {
2293
+ // Bare Enter skips the "why?" prompt — the label still stands.
2294
+ if (awaitingWhy) { awaitingWhy = null; console.log(''); rl.prompt(); return; }
2251
2295
  // Bare Enter accepts a pending "did you mean" suggestion.
2252
2296
  if (pendingDidYouMean) {
2253
2297
  const cmd = pendingDidYouMean;
package/lib/outcomes.js CHANGED
@@ -51,7 +51,7 @@ function recordDecision({ project, route, mode, summary }) {
51
51
  }
52
52
 
53
53
  /** Label a decision by id (or unambiguous id prefix). Returns the decision or null. */
54
- function labelOutcome(idOrPrefix, outcome) {
54
+ function labelOutcome(idOrPrefix, outcome, why = null) {
55
55
  if (!OUTCOMES.includes(outcome)) {
56
56
  throw new Error(`Outcome must be one of: ${OUTCOMES.join(', ')}`);
57
57
  }
@@ -60,6 +60,9 @@ function labelOutcome(idOrPrefix, outcome) {
60
60
  if (matches.length !== 1) return null;
61
61
  matches[0].outcome = outcome;
62
62
  matches[0].labeledAt = new Date().toISOString();
63
+ // One-line reason — the most valuable thing about a reverted/failed call.
64
+ // Feeds /recall ("you reverted this before — because …") and /learn.
65
+ if (why && String(why).trim()) matches[0].why = String(why).trim().slice(0, 200);
63
66
  save(decisions);
64
67
  return matches[0];
65
68
  }
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.24",
3
+ "version": "0.15.26",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"