@pellux/goodvibes-tui 1.20.1 → 1.21.1

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 (59) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/doctor.ts +7 -1
  6. package/src/cli/package-verification.ts +92 -7
  7. package/src/cli/status.ts +16 -0
  8. package/src/config/read-versioned.ts +115 -2
  9. package/src/core/conversation-event-navigation.ts +42 -0
  10. package/src/core/conversation-search-expansion.ts +67 -0
  11. package/src/core/conversation-splash-state.ts +58 -0
  12. package/src/core/conversation.ts +23 -23
  13. package/src/core/model-routing-chip.ts +38 -18
  14. package/src/core/rewind-turn-anchors.ts +155 -3
  15. package/src/core/stream-event-wiring.ts +141 -20
  16. package/src/core/transcript-journal.ts +145 -7
  17. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  18. package/src/daemon/handlers/inbox/index.ts +14 -1
  19. package/src/daemon/handlers/sqlite-store.ts +160 -6
  20. package/src/input/command-registry.ts +8 -0
  21. package/src/input/commands/local-runtime.ts +6 -1
  22. package/src/input/commands/schedule-runtime.ts +15 -2
  23. package/src/input/commands/shell-core.ts +87 -38
  24. package/src/input/composer-capture.ts +47 -0
  25. package/src/input/handler-command-route.ts +4 -0
  26. package/src/input/handler-content-actions.ts +9 -0
  27. package/src/input/handler-feed-routes.ts +1 -0
  28. package/src/input/handler-modal-routes.ts +12 -1
  29. package/src/input/handler-picker-routes.ts +43 -5
  30. package/src/input/handler-shortcuts.ts +1 -1
  31. package/src/input/handler-ui-state.ts +8 -3
  32. package/src/input/model-picker-items.ts +17 -2
  33. package/src/input/model-picker.ts +19 -2
  34. package/src/input/search.ts +183 -89
  35. package/src/input/settings-modal-types.ts +2 -1
  36. package/src/main.ts +12 -20
  37. package/src/providers/reasoning-effort-surface.ts +399 -0
  38. package/src/renderer/compositor.ts +68 -18
  39. package/src/renderer/markdown-inline.ts +143 -0
  40. package/src/renderer/markdown-table.ts +459 -0
  41. package/src/renderer/markdown.ts +6 -335
  42. package/src/renderer/model-workspace.ts +48 -5
  43. package/src/renderer/settings-modal-helpers.ts +53 -1
  44. package/src/renderer/settings-modal.ts +1 -50
  45. package/src/runtime/bootstrap-command-parts.ts +36 -4
  46. package/src/runtime/bootstrap-core.ts +1 -1
  47. package/src/runtime/durability-housekeeping.ts +183 -0
  48. package/src/runtime/durability-services.ts +68 -11
  49. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  50. package/src/runtime/provider-fallback.ts +16 -2
  51. package/src/runtime/recovery-decisions.ts +180 -11
  52. package/src/runtime/recovery-prompt.ts +11 -1
  53. package/src/runtime/services.ts +9 -4
  54. package/src/runtime/session-liveness-marker.ts +142 -5
  55. package/src/runtime/workstream-draft-store.ts +198 -25
  56. package/src/runtime/workstream-services.ts +17 -2
  57. package/src/shell/ui-openers.ts +1 -1
  58. package/src/version.ts +1 -1
  59. package/src/work-plans/work-plan-store.ts +334 -16
@@ -13,11 +13,80 @@
13
13
  // = max(receivedAt) ever seen. Triage metadata is NOT persisted here: it is
14
14
  // applied downstream at the triage overlay layer (triage/integration.ts) over a
15
15
  // separate store, so this feed store carries only the raw inbound fields.
16
+ //
17
+ // RETENTION. The items table is bounded by BOTH an age TTL and a count cap, and
18
+ // the sweep runs at init() — the recovery point, right after the database file
19
+ // is opened — and then on a timer for the life of the store, so a daemon that
20
+ // stays up for weeks keeps reclaiming. `pruneOlderThan` used to exist with no
21
+ // production caller at all, which meant the table grew without bound in
22
+ // practice. Reclaimed counts are handed to the `onSweep` hook (counts only —
23
+ // message previews and sender ids never reach a log line).
24
+ //
25
+ // Cursors are deliberately NOT reaped: they are monotonic watermarks, so
26
+ // dropping one would re-deliver everything a provider ever sent.
27
+ //
28
+ // Idempotence/concurrency: a sweep re-run immediately reclaims nothing (the
29
+ // DELETEs are set-based over the current contents). Two processes opening the
30
+ // same file each hold their own sql.js snapshot and `save()` writes the whole
31
+ // file via temp+rename, so the last writer wins — that whole-file model is
32
+ // HandlerSqliteStore's, and deletion converging on the same surviving set is
33
+ // what makes concurrent sweeps safe rather than corrupting.
16
34
  // ---------------------------------------------------------------------------
17
35
 
18
36
  import { HandlerSqliteStore } from '../sqlite-store.ts';
19
37
  import type { InboundChannelItem } from './provider-adapter.ts';
20
38
 
39
+ /**
40
+ * Age TTL for feed items: rows whose receivedAt is older than this are dropped
41
+ * on every sweep. Long enough that "what did that person say last month" still
42
+ * works, short enough that the table cannot grow indefinitely.
43
+ */
44
+ export const INBOX_ITEM_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
45
+
46
+ /**
47
+ * Count cap for feed items: the newest this many rows survive a sweep, older
48
+ * ones are dropped. Guards the case the TTL cannot — a very chatty month.
49
+ */
50
+ export const INBOX_ITEM_CAP = 5_000;
51
+
52
+ /**
53
+ * Cadence of the background retention sweep. The first sweep happens at init();
54
+ * this timer is what keeps it from being a startup-only reap.
55
+ */
56
+ export const INBOX_SWEEP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
57
+
58
+ const DEFAULT_STORE_FILE_NAME = 'inbox.sqlite';
59
+
60
+ /** Result of one retention sweep. Counts only — never item content. */
61
+ export interface InboxSweepSummary {
62
+ /** Unix ms of the sweep. */
63
+ readonly at: number;
64
+ /** Rows removed by the age TTL. */
65
+ readonly expired: number;
66
+ /** Rows removed by the count cap. */
67
+ readonly capped: number;
68
+ /** Rows left in the items table afterwards. */
69
+ readonly remaining: number;
70
+ }
71
+
72
+ export interface InboxCursorStoreOptions {
73
+ /** Override the age TTL (tests / embedders). Defaults to INBOX_ITEM_TTL_MS. */
74
+ readonly itemTtlMs?: number;
75
+ /** Override the count cap (tests / embedders). Defaults to INBOX_ITEM_CAP. */
76
+ readonly itemCap?: number;
77
+ /** Sweep cadence; 0 or less disables the timer (the init sweep still runs). */
78
+ readonly sweepIntervalMs?: number;
79
+ /** Called after a sweep that reclaimed at least one row. Counts only. */
80
+ readonly onSweep?: (summary: InboxSweepSummary) => void;
81
+ /** Called when a sweep failed, so retention problems are visible rather than swallowed. */
82
+ readonly onSweepError?: (message: string) => void;
83
+ /** Clock seam (tests). Defaults to Date.now. */
84
+ readonly now?: () => number;
85
+ /** Timer seams (tests). Default to the globals. */
86
+ readonly setIntervalImpl?: typeof setInterval;
87
+ readonly clearIntervalImpl?: typeof clearInterval;
88
+ }
89
+
21
90
  const SCHEMA: string[] = [
22
91
  `CREATE TABLE IF NOT EXISTS items (
23
92
  id TEXT PRIMARY KEY,
@@ -62,21 +131,107 @@ export interface InboxQuery {
62
131
  export class InboxCursorStore {
63
132
  private readonly store: HandlerSqliteStore;
64
133
  private dirty = false;
134
+ private readonly itemTtlMs: number;
135
+ private readonly itemCap: number;
136
+ private readonly sweepIntervalMs: number;
137
+ private readonly onSweep: ((summary: InboxSweepSummary) => void) | undefined;
138
+ private readonly onSweepError: ((message: string) => void) | undefined;
139
+ private readonly now: () => number;
140
+ private readonly setIntervalImpl: typeof setInterval;
141
+ private readonly clearIntervalImpl: typeof clearInterval;
142
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
65
143
 
66
- constructor(workingDirectory: string, fileName = 'inbox.sqlite') {
144
+ constructor(
145
+ workingDirectory: string,
146
+ fileName: string = DEFAULT_STORE_FILE_NAME,
147
+ options: InboxCursorStoreOptions = {},
148
+ ) {
67
149
  this.store = new HandlerSqliteStore({
68
150
  workingDirectory,
69
- fileName,
151
+ fileName: fileName || DEFAULT_STORE_FILE_NAME,
70
152
  schema: SCHEMA,
71
153
  });
154
+ this.itemTtlMs = options.itemTtlMs ?? INBOX_ITEM_TTL_MS;
155
+ this.itemCap = options.itemCap ?? INBOX_ITEM_CAP;
156
+ this.sweepIntervalMs = options.sweepIntervalMs ?? INBOX_SWEEP_INTERVAL_MS;
157
+ this.onSweep = options.onSweep;
158
+ this.onSweepError = options.onSweepError;
159
+ this.now = options.now ?? Date.now;
160
+ this.setIntervalImpl = options.setIntervalImpl ?? setInterval;
161
+ this.clearIntervalImpl = options.clearIntervalImpl ?? clearInterval;
72
162
  }
73
163
 
74
164
  get dbPath(): string {
75
165
  return this.store.dbPath;
76
166
  }
77
167
 
168
+ /**
169
+ * Open the database, then immediately reap: recovery is exactly when stale
170
+ * rows from previous runs must go. The periodic timer starts afterwards so
171
+ * retention is not a startup-only event.
172
+ */
78
173
  async init(): Promise<void> {
79
174
  await this.store.init();
175
+ await this.runSweep();
176
+ this.startSweepTimer();
177
+ }
178
+
179
+ /**
180
+ * One retention pass: age TTL first, then the count cap over what is left.
181
+ * Returns counts only. Running it twice in a row reclaims nothing the second
182
+ * time — the pass is a function of the table's current contents.
183
+ */
184
+ sweepRetention(): InboxSweepSummary {
185
+ const at = this.now();
186
+ const expired = this.pruneOlderThan(at - this.itemTtlMs);
187
+ const capped = this.enforceItemCap();
188
+ return { at, expired, capped, remaining: this.countItems() };
189
+ }
190
+
191
+ /** Sweep, persist if anything was reclaimed, and disclose the counts. Never throws. */
192
+ private async runSweep(): Promise<void> {
193
+ try {
194
+ const summary = this.sweepRetention();
195
+ if (summary.expired + summary.capped === 0) return;
196
+ await this.flush();
197
+ this.onSweep?.(summary);
198
+ } catch (error) {
199
+ this.onSweepError?.(error instanceof Error ? error.message : String(error));
200
+ }
201
+ }
202
+
203
+ private startSweepTimer(): void {
204
+ if (this.sweepTimer !== null || this.sweepIntervalMs <= 0) return;
205
+ const handle = this.setIntervalImpl(() => {
206
+ void this.runSweep();
207
+ }, this.sweepIntervalMs);
208
+ // Retention must never be the reason the process stays alive (Bun/Node unref).
209
+ (handle as unknown as { unref?: () => void }).unref?.();
210
+ this.sweepTimer = handle;
211
+ }
212
+
213
+ private stopSweepTimer(): void {
214
+ if (this.sweepTimer === null) return;
215
+ this.clearIntervalImpl(this.sweepTimer);
216
+ this.sweepTimer = null;
217
+ }
218
+
219
+ /**
220
+ * Count cap: keep the newest `itemCap` rows (receivedAt DESC, id ASC — the
221
+ * same order listItems() uses), delete the rest. Returns rows removed.
222
+ */
223
+ private enforceItemCap(): number {
224
+ const before = this.countItems();
225
+ if (before <= this.itemCap) return 0;
226
+ this.store.run(
227
+ `DELETE FROM items WHERE id NOT IN (
228
+ SELECT id FROM items ORDER BY receivedAt DESC, id ASC LIMIT ?
229
+ )`,
230
+ [this.itemCap],
231
+ );
232
+ const removed = before - this.countItems();
233
+ if (removed > 0) this.dirty = true;
234
+ return removed;
80
235
  }
81
236
 
82
237
  /**
@@ -258,8 +413,9 @@ export class InboxCursorStore {
258
413
  this.dirty = false;
259
414
  }
260
415
 
261
- /** Flush (best-effort) then close the underlying database. */
416
+ /** Stop the retention timer, flush (best-effort), then close the database. */
262
417
  async close(): Promise<void> {
418
+ this.stopSweepTimer();
263
419
  try {
264
420
  await this.flush();
265
421
  } finally {
@@ -160,7 +160,20 @@ export function registerInboxMethods(
160
160
  ...(routing ? { resolveRouteId: routeResolverFromRouting(routing) } : {}),
161
161
  };
162
162
  const adapters = buildAdapters(adapterContext);
163
- const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName);
163
+ // Retention runs inside the store (age TTL + count cap, at init and then on a
164
+ // timer). Both hooks carry COUNTS ONLY — no sender ids, subjects or bodies.
165
+ const store = new InboxCursorStore(ctx.workingDirectory, options.storeFileName, {
166
+ onSweep: (summary) => {
167
+ ctx.logger.info('inbox retention sweep reclaimed items', {
168
+ expired: summary.expired,
169
+ capped: summary.capped,
170
+ remaining: summary.remaining,
171
+ });
172
+ },
173
+ onSweepError: (message) => {
174
+ ctx.logger.warn('inbox retention sweep failed', { error: message });
175
+ },
176
+ });
164
177
  const poller = new InboundPoller({ adapters, store, logger: ctx.logger });
165
178
 
166
179
  // Async bootstrap: init store, seed one poll, start loops. Failures are
@@ -1,7 +1,8 @@
1
1
  import { mkdir, rename, writeFile } from 'node:fs/promises';
2
- import { readFileSync } from 'node:fs';
2
+ import { readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs';
3
3
  import { existsSync } from 'node:fs';
4
- import { dirname, join } from 'node:path';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
6
  import initSqlJs from 'sql.js';
6
7
 
7
8
  type SqlJsStatic = Awaited<ReturnType<typeof initSqlJs>>;
@@ -15,6 +16,26 @@ export interface SqliteStoreOptions {
15
16
  schema: string[];
16
17
  }
17
18
 
19
+ /**
20
+ * The 16 bytes every SQLite file begins with ("SQLite format 3" + NUL). A file
21
+ * that does not start with these is not a database, whatever its extension —
22
+ * which is exactly what a crash mid-create, a zero-fill, or a partially
23
+ * restored backup leaves behind.
24
+ */
25
+ const SQLITE_HEADER = Buffer.from('SQLite format 3\0', 'latin1');
26
+
27
+ /**
28
+ * How long a quarantined `.corrupt-<timestamp>` database is kept before it is
29
+ * reclaimed. These files exist so an operator can try to salvage a damaged
30
+ * store by hand; 14 days is long enough for someone to notice a broken daemon
31
+ * and come looking, and short enough that a store that goes bad repeatedly
32
+ * cannot fill the disk with copies of itself. Reaped on the next init.
33
+ */
34
+ const CORRUPT_QUARANTINE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
35
+
36
+ /** At most this many quarantined copies of one store are kept, newest first. */
37
+ const MAX_CORRUPT_QUARANTINES = 3;
38
+
18
39
  let sqlJsStaticPromise: Promise<SqlJsStatic> | null = null;
19
40
 
20
41
  async function loadSqlJs(): Promise<SqlJsStatic> {
@@ -56,14 +77,147 @@ export class HandlerSqliteStore {
56
77
  return this.db;
57
78
  }
58
79
 
80
+ /**
81
+ * Move a database file that cannot be opened aside instead of deleting it, so
82
+ * an operator still has something to salvage, and return the quarantine path.
83
+ * Returns null when the move itself failed — in which case the caller starts
84
+ * fresh in memory and the bad file is left exactly where it was rather than
85
+ * being overwritten on the next save.
86
+ */
87
+ private quarantineUnreadable(): string | null {
88
+ const target = `${this.resolvedPath}.corrupt-${Date.now()}`;
89
+ try {
90
+ renameSync(this.resolvedPath, target);
91
+ return target;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Bound the quarantine directory: drop `.corrupt-*` copies of THIS store that
99
+ * are past the TTL or beyond the keep-newest count. Best-effort and
100
+ * idempotent — a file another process already removed is success, not an
101
+ * error — so two daemons opening the same store at once cannot fight.
102
+ * Returns how many were reclaimed.
103
+ */
104
+ private reapCorruptQuarantines(nowMs: number): number {
105
+ const dir = dirname(this.resolvedPath);
106
+ const prefix = `${basename(this.resolvedPath)}.corrupt-`;
107
+ let candidates: Array<{ path: string; mtimeMs: number }>;
108
+ try {
109
+ candidates = readdirSync(dir)
110
+ .filter((name) => name.startsWith(prefix))
111
+ .map((name) => join(dir, name))
112
+ .flatMap((path) => {
113
+ try {
114
+ return [{ path, mtimeMs: statSync(path).mtimeMs }];
115
+ } catch {
116
+ return [];
117
+ }
118
+ });
119
+ } catch {
120
+ return 0;
121
+ }
122
+ if (candidates.length === 0) return 0;
123
+ // Newest first: the newest MAX_CORRUPT_QUARANTINES survive the count cap,
124
+ // and everything past the TTL goes regardless of rank.
125
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
126
+ let reclaimed = 0;
127
+ for (const [index, candidate] of candidates.entries()) {
128
+ const tooOld = nowMs - candidate.mtimeMs > CORRUPT_QUARANTINE_TTL_MS;
129
+ const overCap = index >= MAX_CORRUPT_QUARANTINES;
130
+ if (!tooOld && !overCap) continue;
131
+ try {
132
+ rmSync(candidate.path, { force: true });
133
+ reclaimed += 1;
134
+ } catch {
135
+ // Another process may have taken it first; that is the outcome we wanted.
136
+ }
137
+ }
138
+ return reclaimed;
139
+ }
140
+
59
141
  async init(): Promise<void> {
60
142
  if (this.db) return;
61
143
  await mkdir(dirname(this.resolvedPath), { recursive: true });
62
144
  const SQL = await loadSqlJs();
63
- const existing = existsSync(this.resolvedPath) ? readFileSync(this.resolvedPath) : undefined;
64
- this.db = existing ? new SQL.Database(existing) : new SQL.Database();
65
- for (const statement of this.options.schema) {
66
- this.db.run(statement);
145
+
146
+ // Validate the file by its CONTENT, not by existsSync. `save()` writes
147
+ // through a pid-and-timestamp temp file and an atomic rename, so it cannot
148
+ // itself leave a half-written database — but a zero-filled file recovered
149
+ // by a filesystem, a truncated restore, or a copy interrupted by something
150
+ // outside this process all produce a path that exists and holds no usable
151
+ // database. Handing those bytes to `new SQL.Database(...)` throws out of
152
+ // init and takes the whole daemon store down with no way back.
153
+ let existing: Buffer | undefined;
154
+ let quarantined: string | null = null;
155
+ let quarantineReason = '';
156
+ if (existsSync(this.resolvedPath)) {
157
+ let raw: Buffer | null = null;
158
+ try {
159
+ raw = readFileSync(this.resolvedPath);
160
+ } catch {
161
+ raw = null;
162
+ }
163
+ if (!raw || raw.length === 0) {
164
+ quarantineReason = raw ? 'zero-byte file (interrupted write)' : 'unreadable file';
165
+ quarantined = this.quarantineUnreadable();
166
+ } else if (raw.length < SQLITE_HEADER.length || !raw.subarray(0, SQLITE_HEADER.length).equals(SQLITE_HEADER)) {
167
+ quarantineReason = 'missing the SQLite file header (not a database)';
168
+ quarantined = this.quarantineUnreadable();
169
+ } else {
170
+ existing = raw;
171
+ }
172
+ }
173
+
174
+ if (existing) {
175
+ // The constructor is NOT where a damaged image surfaces: sql.js accepts
176
+ // the bytes and only reports "database disk image is malformed" when the
177
+ // first statement touches a bad page. So the open is not proved until the
178
+ // schema has actually run against it, and both steps share one catch.
179
+ try {
180
+ const candidate = new SQL.Database(existing);
181
+ try {
182
+ for (const statement of this.options.schema) candidate.run(statement);
183
+ this.db = candidate;
184
+ } catch (error) {
185
+ candidate.close();
186
+ throw error;
187
+ }
188
+ } catch (error) {
189
+ // Header present but the body does not hold up: a truncated or damaged
190
+ // database. Same treatment — set aside, disclose, start clean.
191
+ quarantineReason = `database would not open: ${error instanceof Error ? error.message : String(error)}`;
192
+ this.db = null;
193
+ quarantined = this.quarantineUnreadable();
194
+ }
195
+ }
196
+
197
+ if (!this.db) {
198
+ this.db = new SQL.Database();
199
+ for (const statement of this.options.schema) this.db.run(statement);
200
+ }
201
+
202
+ // Disclosure: starting a store from scratch is data loss from the user's
203
+ // point of view, so it is never allowed to happen quietly.
204
+ if (quarantineReason) {
205
+ logger.warn('daemon store could not be opened — starting a fresh one', {
206
+ store: this.options.fileName,
207
+ path: this.resolvedPath,
208
+ reason: quarantineReason,
209
+ quarantinedTo: quarantined ?? '(could not be moved aside; left in place)',
210
+ });
211
+ }
212
+
213
+ const reclaimed = this.reapCorruptQuarantines(Date.now());
214
+ if (reclaimed > 0) {
215
+ logger.info('daemon store reclaimed quarantined copies', {
216
+ store: this.options.fileName,
217
+ reclaimedFiles: reclaimed,
218
+ ttlDays: Math.round(CORRUPT_QUARANTINE_TTL_MS / (24 * 60 * 60 * 1000)),
219
+ keptNewest: MAX_CORRUPT_QUARANTINES,
220
+ });
67
221
  }
68
222
  }
69
223
 
@@ -121,6 +121,14 @@ export interface CommandUiActions {
121
121
  contextCap?: number | null;
122
122
  /** Which config target to write the selected model to. Defaults to 'main'. */
123
123
  target?: import('./model-picker.ts').ModelPickerTarget;
124
+ /**
125
+ * True only when `effort` came from the picker's effort STEP — an explicit
126
+ * user choice, which is the only thing allowed to update the stored
127
+ * preference `provider.reasoningEffort`. The model-only and context-cap
128
+ * commit routes pass a level merely carried over from the previous model;
129
+ * storing that is how a one-time snap down used to become permanent.
130
+ */
131
+ effortChosenByUser?: boolean;
124
132
  }) => void;
125
133
  /**
126
134
  * Commit an embedding-provider selection from the model picker's
@@ -56,7 +56,12 @@ function toggleBlocks(typeFilter: string, collapsed: boolean, ctx: CommandContex
56
56
  // every member regardless of its own key.
57
57
  if (!collapsed && block.type === 'tool_group' && block.groupMemberIndexes) {
58
58
  for (const memberIdx of block.groupMemberIndexes) {
59
- ctx.session.conversationManager.setCollapsed(`msg_${memberIdx}`, false);
59
+ const memberKey = `msg_${memberIdx}`;
60
+ ctx.session.conversationManager.setCollapsed(memberKey, false);
61
+ // An explicit /expand is a deliberate user action on this key, same
62
+ // as Tab/Ctrl+Y/Ctrl+B — exempts it from search's close-time
63
+ // auto-re-collapse (see ConversationManager.noteUserTouch).
64
+ ctx.session.conversationManager.searchExpansion.noteUserTouch(memberKey);
60
65
  }
61
66
  }
62
67
  count++;
@@ -10,6 +10,7 @@ import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automatio
10
10
  import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
11
11
  import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
12
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
+ import { REASONING_EFFORT_SEVERITY, reasoningEffortRank } from '@pellux/goodvibes-sdk/platform/providers';
13
14
  import { buildAutomationEmptyState } from '@pellux/goodvibes-sdk/platform/runtime/feature-announcements';
14
15
  import { parseNaturalLanguageSchedule } from './schedule-nl.ts';
15
16
  import type {
@@ -50,10 +51,22 @@ function parseAutomationTarget(raw: string | undefined): AutomationSessionTarget
50
51
  return { kind: 'session', sessionId: value, createIfMissing: true };
51
52
  }
52
53
 
54
+ /**
55
+ * Validate a `--effort` argument.
56
+ *
57
+ * Not a fixed four-value list: which levels exist is per-model, so this checks
58
+ * the SDK's severity ladder, narrowed to the current model's own resolved
59
+ * levels whenever the runtime has published them. A scheduled job may well run
60
+ * on a different model than the one in use now, so a level this model does not
61
+ * offer is still accepted when it is a real level — it is re-resolved against
62
+ * whichever model actually serves the run.
63
+ */
53
64
  function parseReasoningEffort(raw: string | undefined): AutomationExecutionPolicy['reasoningEffort'] | undefined {
54
65
  if (!raw) return undefined;
55
- if (raw === 'instant' || raw === 'low' || raw === 'medium' || raw === 'high') return raw;
56
- throw new Error(`Invalid reasoning effort: "${raw}". Use instant, low, medium, or high.`);
66
+ if (reasoningEffortRank(raw) !== -1) return raw;
67
+ throw new Error(
68
+ `Invalid reasoning effort: "${raw}". Known levels: ${REASONING_EFFORT_SEVERITY.join(', ')}.`,
69
+ );
57
70
  }
58
71
 
59
72
  function parseWakeMode(raw: string | undefined): AutomationWakeMode | undefined {
@@ -1,7 +1,16 @@
1
1
  import type { CommandRegistry } from '../command-registry.ts';
2
2
  import type { SelectionItem } from '../selection-modal.ts';
3
- import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
4
- import { REASONING_BUDGET_MAP } from '@pellux/goodvibes-sdk/platform/providers';
3
+ import {
4
+ describeEffortForModel,
5
+ describeServingEffort,
6
+ effortPresentationForModel,
7
+ publishActiveEffortOptions,
8
+ requestedEffortLevel,
9
+ resolveEffortForModelSurface,
10
+ resolveRequestedEffortForServingModel,
11
+ servingEffortForLevel,
12
+ toEffortModel,
13
+ } from '../../providers/reasoning-effort-surface.ts';
5
14
  import { executeWriteQuit } from './quit-shared.ts';
6
15
  import { compactConversation, requireKeybindingsManager, requireProviderApi, requireSessionMemoryStore } from './runtime-services.ts';
7
16
  import { buildCompactionPreview, buildCompactionAfterNotice, buildPinUsageText, buildPinSuccessText } from '../../renderer/compaction-preview.ts';
@@ -40,6 +49,22 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
40
49
  ctx.session.runtime.provider = selected.providerId;
41
50
  ctx.platform.configManager.set('provider.model', selected.registryKey);
42
51
  ctx.print(`Switched to model: ${selected.displayName} (${selected.providerId})`);
52
+ // The REQUESTED level may not exist on the model just selected.
53
+ // Re-resolve it here, exactly as the picker's commit path does, so
54
+ // `/model x` and picking x from the picker cannot disagree about
55
+ // what is actually being sent.
56
+ //
57
+ // The resolution reads config (the requested preference) and writes
58
+ // only the session's effective level. Writing the snapped value back
59
+ // over the preference is what used to make the downgrade permanent.
60
+ const switchedTo = toEffortModel(ctx.provider.providerRegistry.getCurrentModel());
61
+ publishActiveEffortOptions(switchedTo);
62
+ const serving = resolveRequestedEffortForServingModel(ctx.platform.configManager, switchedTo);
63
+ ctx.session.runtime.reasoningEffort = serving.effective ?? '';
64
+ if (serving.requested !== '') {
65
+ ctx.print(`Reasoning effort: ${describeServingEffort(serving, switchedTo)}`);
66
+ if (serving.note) ctx.print(serving.note);
67
+ }
43
68
  void providerApi.recordModelUsage(selected.registryKey).catch((err) => { logger.debug('model usage record failed', { err }); });
44
69
  });
45
70
  } catch (e) {
@@ -343,61 +368,85 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
343
368
  aliases: ['e'],
344
369
  description: 'Show or set reasoning effort level',
345
370
  usage: '[level]',
346
- argsHint: '[instant|low|medium|high]',
371
+ // No fixed list here. The accepted levels belong to the serving model, so
372
+ // the old '[instant|low|medium|high]' hint was wrong for every model that
373
+ // offers 'none', 'minimal', 'xhigh' or 'max' — and for every model that
374
+ // rejects 'instant'. Running `/effort` with no argument prints the real
375
+ // ones for the model currently in use.
376
+ argsHint: '[level]',
347
377
  async handler(args, ctx) {
348
- const currentModel = await requireProviderApi(ctx).getCurrentModel();
349
- const validLevels = currentModel.reasoningEffort ?? [];
378
+ // The ModelDefinition (not the provider-api record) is what carries the
379
+ // structured ReasoningEffortSpec the honest wording is generated from.
380
+ const definition = ctx.provider.providerRegistry.getCurrentModel();
381
+ const model = toEffortModel(definition);
382
+ const presentation = effortPresentationForModel(model);
383
+ // Validation of provider.reasoningEffort now happens against these real
384
+ // levels rather than a fixed four-value list.
385
+ publishActiveEffortOptions(model);
350
386
 
351
- if (validLevels.length === 0) {
352
- ctx.print(`Current model (${currentModel.displayName}) does not support configurable reasoning effort.`);
387
+ if (!presentation.configurable) {
388
+ ctx.print(presentation.headline);
389
+ if (presentation.caveat) ctx.print(presentation.caveat);
353
390
  return;
354
391
  }
355
392
 
393
+ // The user's REQUESTED level, not the session's effective one: this
394
+ // command is where the preference is read back and re-chosen, so it must
395
+ // show what was asked for even while a capped model is serving.
396
+ const current = requestedEffortLevel(ctx.platform.configManager);
397
+ // What the list should open on, though, is the level in EFFECT — the
398
+ // requested level snapped to this model. The requested level may not be
399
+ // in the list at all when the model caps lower, and preselecting a
400
+ // missing id lands the cursor on the lowest level instead of on what is
401
+ // actually running.
402
+ const inEffect = servingEffortForLevel(current, model).effective ?? current;
403
+
404
+ const applyLevel = (level: string): void => {
405
+ // An explicit user choice — the one kind of write that is allowed to
406
+ // change the stored preference.
407
+ ctx.platform.configManager.set('provider.reasoningEffort', level);
408
+ const serving = servingEffortForLevel(level, model);
409
+ ctx.session.runtime.reasoningEffort = serving.effective ?? '';
410
+ ctx.print(`Reasoning effort set to: ${describeServingEffort(serving, model)}`);
411
+ if (serving.note) ctx.print(serving.note);
412
+ };
413
+
356
414
  if (args.length === 0) {
357
- const current = (ctx.session.runtime.reasoningEffort || ctx.platform.configManager.get('provider.reasoningEffort') || 'medium') as string;
358
415
  if (ctx.openSelection) {
359
- const descriptions: Record<string, string> = {
360
- ...EFFORT_DESCRIPTIONS,
361
- medium: 'Balanced speed and quality (default)',
362
- };
363
- const items: SelectionItem[] = validLevels.map((level) => ({
364
- id: level,
365
- label: level,
366
- detail: level === current ? `◉ ${descriptions[level] ?? level}` : (descriptions[level] ?? level),
416
+ const items: SelectionItem[] = presentation.choices.map((choice) => ({
417
+ id: choice.level,
418
+ label: choice.level,
419
+ detail: choice.level === inEffect ? `\u25c9 ${choice.description}` : choice.description,
367
420
  }));
368
- ctx.openSelection('Reasoning Effort', items, { preSelectId: current, allowSearch: false }, (result) => {
421
+ const options = { preSelectId: inEffect, allowSearch: false };
422
+ ctx.openSelection('Reasoning Effort', items, options, (result) => {
369
423
  if (!result) return;
370
- const level = result.item.id as 'instant' | 'low' | 'medium' | 'high';
371
- ctx.session.runtime.reasoningEffort = level;
372
- ctx.platform.configManager.set('provider.reasoningEffort', level);
373
- ctx.print(`Reasoning effort set to: ${level}`);
424
+ applyLevel(result.item.id);
374
425
  ctx.renderRequest();
375
426
  });
376
427
  return;
377
428
  }
378
- const budget = REASONING_BUDGET_MAP[current];
379
- const lines = [
380
- `Reasoning effort: ${current}`,
381
- ` Mercury-2: reasoning_effort = '${current}'`,
382
- ` Claude: thinking.budget_tokens = ${budget}`,
383
- ` Gemini: thinking_config.thinking_budget = ${budget}`,
384
- ` GPT-5: (no-op)`,
385
- '',
386
- `Levels: ${validLevels.join(', ')}`,
387
- ];
388
- ctx.print(lines.join('\n'));
429
+ ctx.print(describeEffortForModel(model, current || undefined).join('\n'));
389
430
  return;
390
431
  }
391
432
 
392
- const level = args[0] as 'instant' | 'low' | 'medium' | 'high';
393
- if (!validLevels.includes(level)) {
394
- ctx.print(`Invalid effort level: ${level}\nValid levels: ${validLevels.join(', ')}`);
433
+ const requested = args[0]!;
434
+ if (!presentation.choices.some((choice) => choice.level === requested)) {
435
+ const offered = presentation.choices.map((choice) => choice.level).join(', ');
436
+ // Name the model: "invalid" alone reads as a typo when the real reason
437
+ // is that this particular model does not offer the level.
438
+ const lines = [
439
+ `${definition.displayName} does not offer reasoning effort '${requested}'.`,
440
+ `Levels it offers: ${offered}`,
441
+ ];
442
+ const resolved = resolveEffortForModelSurface(requested, model);
443
+ if (resolved.note) lines.push(resolved.note);
444
+ if (presentation.caveat) lines.push(presentation.caveat);
445
+ ctx.print(lines.join('\n'));
395
446
  return;
396
447
  }
397
448
 
398
- ctx.session.runtime.reasoningEffort = level;
399
- ctx.platform.configManager.set('provider.reasoningEffort', level);
400
- ctx.print(`Reasoning effort set to: ${level}`);
449
+ applyLevel(requested);
401
450
  },
402
451
  });
403
452