phewsh 0.15.12 → 0.15.15
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/commands/hook.js +22 -0
- package/commands/session.js +119 -11
- package/lib/closest.js +59 -0
- package/lib/continuity.js +86 -0
- package/lib/history.js +40 -0
- package/package.json +1 -1
package/commands/hook.js
CHANGED
|
@@ -12,10 +12,27 @@ const fs = require('fs');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const os = require('os');
|
|
14
14
|
|
|
15
|
+
const continuity = require('../lib/continuity');
|
|
16
|
+
|
|
15
17
|
const PHEWSH_DIR = path.join(os.homedir(), '.phewsh');
|
|
16
18
|
const AMBIENT_LOG = path.join(PHEWSH_DIR, 'ambient-sessions.jsonl');
|
|
19
|
+
const DECISIONS_FILE = path.join(PHEWSH_DIR, 'outcomes', 'decisions.json');
|
|
17
20
|
const INTENT_DIR = path.join(process.cwd(), '.intent');
|
|
18
21
|
|
|
22
|
+
// "Where you left off, across every tool" — drawn from phewsh's own decision
|
|
23
|
+
// record (not the host transcript). This is what makes opening Claude Code
|
|
24
|
+
// standalone feel like resuming: it sees the thread Codex (or phewsh) left.
|
|
25
|
+
function continuityBrief(project) {
|
|
26
|
+
try {
|
|
27
|
+
const decisions = JSON.parse(fs.readFileSync(DECISIONS_FILE, 'utf-8'));
|
|
28
|
+
const line = continuity.continuityLine(decisions, { project });
|
|
29
|
+
if (!line) return null;
|
|
30
|
+
const tools = continuity.toolsInThread(decisions, { project });
|
|
31
|
+
const span = tools >= 2 ? ` (${tools} tools, one thread)` : '';
|
|
32
|
+
return `Continuity${span}: you were ${line}. Nothing's lost — continue it.`;
|
|
33
|
+
} catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
function readIfExists(p, maxBytes = 16384) {
|
|
20
37
|
try { return fs.readFileSync(p, 'utf-8').slice(0, maxBytes); } catch { return null; }
|
|
21
38
|
}
|
|
@@ -89,6 +106,11 @@ function sessionStart() {
|
|
|
89
106
|
const status = readIfExists(path.join(INTENT_DIR, 'status.md'));
|
|
90
107
|
if (status) parts.push(`\n## Status (excerpt)\n${firstLines(status, 5)}`);
|
|
91
108
|
|
|
109
|
+
// Decisions are tagged with the cwd basename (how recordDecision keys them),
|
|
110
|
+
// not the project.json display name — match on that so the thread connects.
|
|
111
|
+
const cont = continuityBrief(path.basename(process.cwd()));
|
|
112
|
+
if (cont) parts.push(`\n## Continuity (across your tools)\n${cont}`);
|
|
113
|
+
|
|
92
114
|
parts.push(`\n(Brief injected by PHEWSH ambient from .intent/. Honor the constraints above. The human can run \`phewsh\` for mission control — council, outcomes, the decision record.)`);
|
|
93
115
|
|
|
94
116
|
process.stdout.write(parts.join('\n') + '\n');
|
package/commands/session.js
CHANGED
|
@@ -18,8 +18,11 @@ const { select, refreshSession: refreshSess } = require('../lib/supabase');
|
|
|
18
18
|
const { readPPS } = require('../lib/pps');
|
|
19
19
|
const { push, pull, ensureValidToken } = require('./sync');
|
|
20
20
|
const { HARNESSES, listHarnesses, runViaHarness, cancelActive } = require('../lib/harnesses');
|
|
21
|
-
const { recordDecision, labelOutcome, pendingDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
|
|
21
|
+
const { recordDecision, labelOutcome, pendingDecisions, recentDecisions, outcomeStats, OUTCOMES } = require('../lib/outcomes');
|
|
22
22
|
const { suggest, suggestAll } = require('../lib/suggest');
|
|
23
|
+
const continuity = require('../lib/continuity');
|
|
24
|
+
const { closest } = require('../lib/closest');
|
|
25
|
+
const cmdHistory = require('../lib/history');
|
|
23
26
|
const { recordSessionEvent } = require('../lib/receipts-data');
|
|
24
27
|
const configFile = require('../lib/config-file');
|
|
25
28
|
const { createFailureTracker, createLineDispatcher } = require('../lib/session-input');
|
|
@@ -332,6 +335,7 @@ async function main() {
|
|
|
332
335
|
let awaitingFallback = null; // { input, fullSystem, options } after a route failure
|
|
333
336
|
let bootstrapChoices = null; // root-bootstrap menu entries when no project here
|
|
334
337
|
let nextChoices = null; // ranked /next suggestions awaiting a numeric pick
|
|
338
|
+
let pendingDidYouMean = null; // a suggested command; bare Enter accepts + runs it
|
|
335
339
|
let decisionsThisSession = 0;
|
|
336
340
|
|
|
337
341
|
// ── The Exhale: animated brand reveal ──────────────────
|
|
@@ -497,6 +501,19 @@ async function main() {
|
|
|
497
501
|
};
|
|
498
502
|
}
|
|
499
503
|
|
|
504
|
+
// "Nothing lost" — surface where you left off, across every tool, so opening
|
|
505
|
+
// phewsh feels like resuming a thread rather than starting cold.
|
|
506
|
+
function showContinuity() {
|
|
507
|
+
try {
|
|
508
|
+
const decisions = recentDecisions(50, { project: projectName });
|
|
509
|
+
const line = continuity.continuityLine(decisions, { project: projectName });
|
|
510
|
+
if (!line) return;
|
|
511
|
+
const tools = continuity.toolsInThread(decisions, { project: projectName });
|
|
512
|
+
const span = tools >= 2 ? slate(` · ${tools} tools, one thread`) : '';
|
|
513
|
+
console.log(` ${teal('↻')} ${sage('Picking up — ' + line)}${span} ${slate('· /thread')}`);
|
|
514
|
+
} catch { /* continuity is a nicety, never a blocker */ }
|
|
515
|
+
}
|
|
516
|
+
|
|
500
517
|
// One subtle line under the menu — the single highest-leverage nudge, if any.
|
|
501
518
|
function showInlineTip() {
|
|
502
519
|
let tip = null;
|
|
@@ -520,6 +537,7 @@ async function main() {
|
|
|
520
537
|
console.log('');
|
|
521
538
|
console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
|
|
522
539
|
console.log('');
|
|
540
|
+
showContinuity();
|
|
523
541
|
showModeMenu();
|
|
524
542
|
showInlineTip();
|
|
525
543
|
console.log('');
|
|
@@ -553,6 +571,7 @@ async function main() {
|
|
|
553
571
|
} else if (intentFiles.length === 0 && (atHome || recents.length > 0)) {
|
|
554
572
|
showBootstrapMenu(recents);
|
|
555
573
|
} else {
|
|
574
|
+
showContinuity();
|
|
556
575
|
showModeMenu();
|
|
557
576
|
showInlineTip();
|
|
558
577
|
}
|
|
@@ -700,6 +719,7 @@ async function main() {
|
|
|
700
719
|
output: process.stdout,
|
|
701
720
|
prompt: ` ${teal('phewsh')} ${sage('>')} `,
|
|
702
721
|
historySize: 100,
|
|
722
|
+
history: cmdHistory.loadForReadline(100), // up-arrow remembers across sessions
|
|
703
723
|
});
|
|
704
724
|
const promptText = ` phewsh > `;
|
|
705
725
|
let lastPaste = null;
|
|
@@ -750,7 +770,7 @@ async function main() {
|
|
|
750
770
|
'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
|
|
751
771
|
'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
|
|
752
772
|
'agents', 'context', 'gate', 'reload', 'sequence', 'seq', 'setup', 'system', 'watch',
|
|
753
|
-
'next', 'recommend', 'guide',
|
|
773
|
+
'next', 'recommend', 'guide', 'thread', 'continuity',
|
|
754
774
|
]);
|
|
755
775
|
const installedIds = harnesses.filter(h => h.installed).map(h => h.id);
|
|
756
776
|
let turnAbort = null; // AbortController while an API turn streams
|
|
@@ -914,6 +934,9 @@ async function main() {
|
|
|
914
934
|
async function handleInput(input) {
|
|
915
935
|
input = expandPastes(input);
|
|
916
936
|
|
|
937
|
+
// Any typed input supersedes a pending "did you mean" offer.
|
|
938
|
+
if (pendingDidYouMean) pendingDidYouMean = null;
|
|
939
|
+
|
|
917
940
|
// A bare number right after a route failure picks the fallback
|
|
918
941
|
if (awaitingFallback) {
|
|
919
942
|
const af = awaitingFallback;
|
|
@@ -1101,12 +1124,69 @@ async function main() {
|
|
|
1101
1124
|
return;
|
|
1102
1125
|
}
|
|
1103
1126
|
|
|
1127
|
+
// ── /thread ────────────────────────────────────────
|
|
1128
|
+
// The cross-tool thread: one continuous record of your work, whichever
|
|
1129
|
+
// tool ran each step. The "nothing lost" proof, made visible.
|
|
1130
|
+
if (cmd === 'thread' || cmd === 'continuity') {
|
|
1131
|
+
const decisions = recentDecisions(50, { project: projectName });
|
|
1132
|
+
const thread = continuity.threadFor(decisions, { project: projectName });
|
|
1133
|
+
console.log('');
|
|
1134
|
+
if (thread.length === 0) {
|
|
1135
|
+
console.log(` ${teal('●')} ${sage('No thread yet for')} ${cream(projectName)}${sage('.')} ${slate('Do something — every action joins the thread, whichever tool runs it.')}`);
|
|
1136
|
+
console.log('');
|
|
1137
|
+
rl.prompt();
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const tools = continuity.toolsInThread(decisions, { project: projectName });
|
|
1141
|
+
console.log(` ${b(cream('Your thread'))} ${slate('— ' + projectName + ' · phewsh remembers across every tool')}`);
|
|
1142
|
+
ui.divider('line');
|
|
1143
|
+
for (const d of thread.slice(0, 12)) {
|
|
1144
|
+
const ago = continuity.agoText(d.ts).padEnd(9);
|
|
1145
|
+
const via = continuity.labelFor(d.route).padEnd(13);
|
|
1146
|
+
const oc = d.outcome
|
|
1147
|
+
? (d.outcome === 'kept' ? green('kept') : d.outcome === 'superseded' ? peach(d.outcome) : ember(d.outcome))
|
|
1148
|
+
: slate('open');
|
|
1149
|
+
let s = (d.summary || '').replace(/\s+/g, ' ');
|
|
1150
|
+
if (s.length > 46) s = s.slice(0, 45).trimEnd() + '…';
|
|
1151
|
+
console.log(` ${slate(ago)} ${sage(via)} ${cream(s || '—')} ${oc}`);
|
|
1152
|
+
}
|
|
1153
|
+
ui.divider('line');
|
|
1154
|
+
const span = tools >= 2 ? `${tools} tools, one thread` : `${tools} tool`;
|
|
1155
|
+
console.log(` ${sage(thread.length + ' action' + (thread.length !== 1 ? 's' : '') + ' · ' + span + ' · nothing re-explained')}`);
|
|
1156
|
+
console.log('');
|
|
1157
|
+
rl.prompt();
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1104
1161
|
if (cmd === 'help' || cmd === 'h') {
|
|
1162
|
+
const wantsAll = /^(all|more|full|everything)$/i.test(cmdArg.trim());
|
|
1163
|
+
|
|
1164
|
+
// Bare /help → the short essentials a newcomer actually needs.
|
|
1165
|
+
if (!wantsAll) {
|
|
1166
|
+
console.log('');
|
|
1167
|
+
console.log(` ${sage('the loop: define .intent/ → sync → work → evolve → repeat')}`);
|
|
1168
|
+
console.log('');
|
|
1169
|
+
console.log(` ${cream('the essentials')}`);
|
|
1170
|
+
console.log(` ${teal('just type')} ${sage('chat — your .intent/ context travels with every route')}`);
|
|
1171
|
+
console.log(` ${teal('/next')} ${sage('not sure? phewsh names the next step worth taking')}`);
|
|
1172
|
+
console.log(` ${teal('/use')} ${slate('<tool>')} ${sage('route through Claude Code, Codex, Gemini… (their login, no key)')}`);
|
|
1173
|
+
console.log(` ${teal('@name')} ${slate('<msg>')} ${sage('one message to one tool — @codex review this')}`);
|
|
1174
|
+
console.log(` ${teal('/work')} ${slate('[tool]')} ${sage('hand off to the full interactive tool, outcome on return')}`);
|
|
1175
|
+
console.log(` ${teal('/clarify')} ${sage('turn messy thoughts into .intent/ artifacts')}`);
|
|
1176
|
+
console.log(` ${teal('/outcomes')} ${sage('label what you kept — the record that gets smarter')}`);
|
|
1177
|
+
console.log('');
|
|
1178
|
+
console.log(` ${slate('/help all')} ${sage('everything')} ${slate('·')} ${slate('/tour')} ${sage('walkthrough')} ${slate('·')} ${slate('/quit')} ${sage('exit')}`);
|
|
1179
|
+
console.log('');
|
|
1180
|
+
rl.prompt();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1105
1184
|
console.log('');
|
|
1106
1185
|
console.log(` ${sage('the loop: define .intent/ → sync → work → evolve → repeat')}`);
|
|
1107
1186
|
console.log('');
|
|
1108
1187
|
console.log(` ${cream('not sure what to do?')}`);
|
|
1109
1188
|
console.log(` ${teal('/next')} ${sage("phewsh reads your state and hands back the next step worth taking")}`);
|
|
1189
|
+
console.log(` ${teal('/thread')} ${sage('where you left off — your work across every tool, one record')}`);
|
|
1110
1190
|
console.log('');
|
|
1111
1191
|
console.log(` ${cream('author .intent/')}`);
|
|
1112
1192
|
console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
|
|
@@ -1856,14 +1936,27 @@ async function main() {
|
|
|
1856
1936
|
|
|
1857
1937
|
if (cmd === 'harnesses' || cmd === 'agents') {
|
|
1858
1938
|
console.log('');
|
|
1859
|
-
console.log(` ${b(cream('
|
|
1939
|
+
console.log(` ${b(cream('Your AI tools'))} ${slate('— phewsh keeps them all, aligned. You never pick just one.')}`);
|
|
1860
1940
|
ui.divider('line');
|
|
1861
|
-
|
|
1941
|
+
// Installed first, then the rest so the table also teaches what exists.
|
|
1942
|
+
const sorted = [...harnesses].sort((a, b) => (b.installed - a.installed));
|
|
1943
|
+
let lastGroup = null;
|
|
1944
|
+
for (const h of sorted) {
|
|
1945
|
+
const group = h.installed ? 'in' : 'out';
|
|
1946
|
+
if (group !== lastGroup) {
|
|
1947
|
+
console.log(` ${slate(h.installed ? 'on this machine — context routes straight through their login:' : 'available — install any of these and phewsh picks it up:')}`);
|
|
1948
|
+
lastGroup = group;
|
|
1949
|
+
}
|
|
1862
1950
|
const active = route?.type === 'harness' && route.id === h.id ? ` ${teal('● active')}` : '';
|
|
1863
|
-
const
|
|
1864
|
-
const
|
|
1865
|
-
console.log(` ${cream(h.id.padEnd(
|
|
1951
|
+
const dot = h.installed ? green('●') : slate('○');
|
|
1952
|
+
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}`);
|
|
1866
1954
|
}
|
|
1955
|
+
ui.divider('line');
|
|
1956
|
+
console.log(` ${sage('keep your tools, keep one record:')}`);
|
|
1957
|
+
console.log(` ${teal('/use')} ${slate('<id>')} ${sage('route your typing through that tool')}`);
|
|
1958
|
+
console.log(` ${teal('@<id>')} ${slate('<msg>')} ${sage('one message to one tool — context stays shared')}`);
|
|
1959
|
+
console.log(` ${teal('/council')} ${slate('<q>')} ${sage('ask every installed tool at once, keep the best answer')}`);
|
|
1867
1960
|
console.log('');
|
|
1868
1961
|
rl.prompt();
|
|
1869
1962
|
return;
|
|
@@ -2044,8 +2137,14 @@ async function main() {
|
|
|
2044
2137
|
return;
|
|
2045
2138
|
}
|
|
2046
2139
|
|
|
2047
|
-
// Unknown slash command
|
|
2048
|
-
|
|
2140
|
+
// Unknown slash command — suggest the nearest real one instead of a dead end.
|
|
2141
|
+
const guess = closest(cmd, [...KNOWN_COMMANDS]);
|
|
2142
|
+
if (guess) {
|
|
2143
|
+
console.log(` ${sage('No command')} ${cream('/' + cmd)}${sage('. Did you mean')} ${teal('/' + guess)}${sage('?')} ${slate('· enter = run it · /help all for everything')}`);
|
|
2144
|
+
pendingDidYouMean = '/' + guess + (cmdArg ? ' ' + cmdArg : '');
|
|
2145
|
+
} else {
|
|
2146
|
+
console.log(` ${sage('No command')} ${cream('/' + cmd)}${sage('.')} ${slate('Type')} ${teal('/next')} ${slate('for what to do, or')} ${teal('/help')} ${slate('for everything.')}`);
|
|
2147
|
+
}
|
|
2049
2148
|
rl.prompt();
|
|
2050
2149
|
return;
|
|
2051
2150
|
}
|
|
@@ -2098,8 +2197,17 @@ async function main() {
|
|
|
2098
2197
|
}
|
|
2099
2198
|
|
|
2100
2199
|
const lineDispatcher = createLineDispatcher(handleInput, {
|
|
2101
|
-
onBatch: ({ input, lines }) => collapsePastedEcho(lines, input),
|
|
2102
|
-
onNoop: () =>
|
|
2200
|
+
onBatch: ({ input, lines }) => { collapsePastedEcho(lines, input); cmdHistory.append(input); },
|
|
2201
|
+
onNoop: () => {
|
|
2202
|
+
// Bare Enter accepts a pending "did you mean" suggestion.
|
|
2203
|
+
if (pendingDidYouMean) {
|
|
2204
|
+
const cmd = pendingDidYouMean;
|
|
2205
|
+
pendingDidYouMean = null;
|
|
2206
|
+
handleInput(cmd).catch(() => {});
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
rl.prompt();
|
|
2210
|
+
},
|
|
2103
2211
|
onError: (err) => {
|
|
2104
2212
|
console.error(`\n ${ember('!')} ${sage('Input failed:')} ${err.message}`);
|
|
2105
2213
|
rl.prompt();
|
package/lib/closest.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Find the nearest known command to a typo — so an unknown slash command
|
|
2
|
+
// becomes "did you mean /clarify?" instead of a dead end. Pure + deterministic.
|
|
3
|
+
//
|
|
4
|
+
// Strategy, in order of confidence:
|
|
5
|
+
// 1. Exact prefix — user typed a real abbreviation (/cla → /clarify).
|
|
6
|
+
// 2. Small edit distance — a genuine typo (/claify → /clarify).
|
|
7
|
+
// Returns the single best candidate, or null when nothing is close enough.
|
|
8
|
+
|
|
9
|
+
function levenshtein(a, b) {
|
|
10
|
+
if (a === b) return 0;
|
|
11
|
+
if (!a.length) return b.length;
|
|
12
|
+
if (!b.length) return a.length;
|
|
13
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
14
|
+
let curr = new Array(b.length + 1);
|
|
15
|
+
for (let i = 1; i <= a.length; i++) {
|
|
16
|
+
curr[0] = i;
|
|
17
|
+
for (let j = 1; j <= b.length; j++) {
|
|
18
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
19
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
20
|
+
}
|
|
21
|
+
[prev, curr] = [curr, prev];
|
|
22
|
+
}
|
|
23
|
+
return prev[b.length];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} input — the unknown token (no leading slash)
|
|
28
|
+
* @param {string[]} candidates — known command names
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {number} [opts.maxDistance=2] — max edit distance to still suggest
|
|
31
|
+
* @returns {string|null} best candidate or null
|
|
32
|
+
*/
|
|
33
|
+
function closest(input, candidates, opts = {}) {
|
|
34
|
+
const maxDistance = opts.maxDistance ?? 2;
|
|
35
|
+
if (!input || !candidates || candidates.length === 0) return null;
|
|
36
|
+
const q = input.toLowerCase();
|
|
37
|
+
|
|
38
|
+
// 1. Prefix match — shortest matching command wins (most specific abbrev).
|
|
39
|
+
// Require at least 2 chars so a stray "/a" doesn't latch onto everything.
|
|
40
|
+
if (q.length >= 2) {
|
|
41
|
+
const prefixes = candidates
|
|
42
|
+
.filter(c => c.toLowerCase().startsWith(q))
|
|
43
|
+
.sort((a, b) => a.length - b.length);
|
|
44
|
+
if (prefixes.length) return prefixes[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 2. Edit distance — nearest within threshold; scale threshold down for
|
|
48
|
+
// very short inputs so "x" doesn't "match" every 1-2 char command.
|
|
49
|
+
const cap = Math.min(maxDistance, Math.max(1, Math.floor(q.length / 2) + 1));
|
|
50
|
+
let best = null;
|
|
51
|
+
let bestD = Infinity;
|
|
52
|
+
for (const c of candidates) {
|
|
53
|
+
const d = levenshtein(q, c.toLowerCase());
|
|
54
|
+
if (d < bestD) { bestD = d; best = c; }
|
|
55
|
+
}
|
|
56
|
+
return bestD <= cap ? best : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { closest, levenshtein };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Continuity — make "nothing lost across tools" visible.
|
|
2
|
+
//
|
|
3
|
+
// Every routed action phewsh records is tagged with the tool that ran it
|
|
4
|
+
// (claude-code, codex, gemini…). Read newest-first, that list IS a thread of
|
|
5
|
+
// your work across every tool. This module turns it into something a human
|
|
6
|
+
// (front door, /thread) or a standalone harness (ambient brief) can feel:
|
|
7
|
+
// "last you were doing X, via Codex, 3h ago — keep going."
|
|
8
|
+
//
|
|
9
|
+
// Pure + deterministic: feed it the decision records, get back strings.
|
|
10
|
+
|
|
11
|
+
const ROUTE_LABELS = {
|
|
12
|
+
'claude-code': 'Claude Code',
|
|
13
|
+
codex: 'Codex',
|
|
14
|
+
gemini: 'Gemini',
|
|
15
|
+
cursor: 'Cursor',
|
|
16
|
+
opencode: 'OpenCode',
|
|
17
|
+
grok: 'Grok',
|
|
18
|
+
kiro: 'Kiro',
|
|
19
|
+
copilot: 'Copilot',
|
|
20
|
+
hermes: 'Hermes',
|
|
21
|
+
pi: 'Pi',
|
|
22
|
+
aider: 'Aider',
|
|
23
|
+
goose: 'Goose',
|
|
24
|
+
amp: 'Amp',
|
|
25
|
+
droid: 'Droid',
|
|
26
|
+
api: 'direct API',
|
|
27
|
+
council: 'council',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function labelFor(route, labeler) {
|
|
31
|
+
if (labeler) { const l = labeler(route); if (l) return l; }
|
|
32
|
+
return ROUTE_LABELS[route] || route || 'a tool';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function agoText(ts, now = Date.now()) {
|
|
36
|
+
const then = new Date(ts).getTime();
|
|
37
|
+
if (isNaN(then)) return '';
|
|
38
|
+
const s = Math.max(0, Math.floor((now - then) / 1000));
|
|
39
|
+
if (s < 45) return 'just now';
|
|
40
|
+
const m = Math.floor(s / 60);
|
|
41
|
+
if (m < 60) return `${m}m ago`;
|
|
42
|
+
const h = Math.floor(m / 60);
|
|
43
|
+
if (h < 24) return `${h}h ago`;
|
|
44
|
+
const d = Math.floor(h / 24);
|
|
45
|
+
return `${d}d ago`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Decisions for a project (or all), newest first. */
|
|
49
|
+
function threadFor(decisions, { project = null } = {}) {
|
|
50
|
+
return (decisions || [])
|
|
51
|
+
.filter(d => d && d.ts && (!project || d.project === project))
|
|
52
|
+
.sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The single most recent action — "where you left off", or null. */
|
|
56
|
+
function lastLeftOff(decisions, { project = null } = {}) {
|
|
57
|
+
const t = threadFor(decisions, { project });
|
|
58
|
+
if (!t.length) return null;
|
|
59
|
+
const d = t[0];
|
|
60
|
+
return {
|
|
61
|
+
summary: (d.summary || '').trim(),
|
|
62
|
+
route: d.route,
|
|
63
|
+
ts: d.ts,
|
|
64
|
+
outcome: d.outcome || null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A one-line "picking up where you left off" string, or null. */
|
|
69
|
+
function continuityLine(decisions, { project = null, now = Date.now(), labeler = null, maxLen = 52 } = {}) {
|
|
70
|
+
const last = lastLeftOff(decisions, { project });
|
|
71
|
+
if (!last) return null;
|
|
72
|
+
let s = last.summary.replace(/\s+/g, ' ');
|
|
73
|
+
if (s.length > maxLen) s = s.slice(0, maxLen - 1).trimEnd() + '…';
|
|
74
|
+
const via = labelFor(last.route, labeler);
|
|
75
|
+
const ago = agoText(last.ts, now);
|
|
76
|
+
const when = ago ? ` · ${ago}` : '';
|
|
77
|
+
return s ? `last: “${s}” · via ${via}${when}` : `last action via ${via}${when}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How many distinct tools appear in the thread (the "across tools" proof). */
|
|
81
|
+
function toolsInThread(decisions, { project = null } = {}) {
|
|
82
|
+
const set = new Set(threadFor(decisions, { project }).map(d => d.route).filter(Boolean));
|
|
83
|
+
return set.size;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { agoText, threadFor, lastLeftOff, continuityLine, toolsInThread, labelFor, ROUTE_LABELS };
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Persistent command history — so up-arrow remembers across sessions, like
|
|
2
|
+
// every serious shell and harness. Stored at ~/.phewsh/history, newest last.
|
|
3
|
+
//
|
|
4
|
+
// Never persists anything secret: lines that set an API key are skipped, so a
|
|
5
|
+
// key typed at the prompt can't linger on disk.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
const FILE = path.join(os.homedir(), '.phewsh', 'history');
|
|
12
|
+
|
|
13
|
+
const SECRET = /^\/key\b/i; // /key <token> — never write the token to disk
|
|
14
|
+
|
|
15
|
+
/** Load up to `max` recent entries, oldest→newest (file order). */
|
|
16
|
+
function load(max = 100, file = FILE) {
|
|
17
|
+
try {
|
|
18
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim().length);
|
|
19
|
+
return lines.slice(-max);
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Most-recent-first, the order Node's readline `history` option expects. */
|
|
26
|
+
function loadForReadline(max = 100, file = FILE) {
|
|
27
|
+
return load(max, file).reverse();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Append one submitted line. No-ops on blank or secret-bearing input. */
|
|
31
|
+
function append(line, file = FILE) {
|
|
32
|
+
if (!line || !line.trim()) return;
|
|
33
|
+
if (SECRET.test(line.trim())) return;
|
|
34
|
+
try {
|
|
35
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
36
|
+
fs.appendFileSync(file, line.replace(/\r?\n/g, ' ') + '\n');
|
|
37
|
+
} catch { /* read-only home — in-session history still works */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { load, loadForReadline, append, FILE };
|