@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.
- package/CHANGELOG.md +22 -1
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +106 -22
- package/package.json +7 -7
- package/src/cli/doctor.ts +7 -1
- package/src/cli/package-verification.ts +92 -7
- package/src/cli/status.ts +16 -0
- package/src/config/read-versioned.ts +115 -2
- package/src/core/conversation-event-navigation.ts +42 -0
- package/src/core/conversation-search-expansion.ts +67 -0
- package/src/core/conversation-splash-state.ts +58 -0
- package/src/core/conversation.ts +23 -23
- package/src/core/model-routing-chip.ts +38 -18
- package/src/core/rewind-turn-anchors.ts +155 -3
- package/src/core/stream-event-wiring.ts +141 -20
- package/src/core/transcript-journal.ts +145 -7
- package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
- package/src/daemon/handlers/inbox/index.ts +14 -1
- package/src/daemon/handlers/sqlite-store.ts +160 -6
- package/src/input/command-registry.ts +8 -0
- package/src/input/commands/local-runtime.ts +6 -1
- package/src/input/commands/schedule-runtime.ts +15 -2
- package/src/input/commands/shell-core.ts +87 -38
- package/src/input/composer-capture.ts +47 -0
- package/src/input/handler-command-route.ts +4 -0
- package/src/input/handler-content-actions.ts +9 -0
- package/src/input/handler-feed-routes.ts +1 -0
- package/src/input/handler-modal-routes.ts +12 -1
- package/src/input/handler-picker-routes.ts +43 -5
- package/src/input/handler-shortcuts.ts +1 -1
- package/src/input/handler-ui-state.ts +8 -3
- package/src/input/model-picker-items.ts +17 -2
- package/src/input/model-picker.ts +19 -2
- package/src/input/search.ts +183 -89
- package/src/input/settings-modal-types.ts +2 -1
- package/src/main.ts +12 -20
- package/src/providers/reasoning-effort-surface.ts +399 -0
- package/src/renderer/compositor.ts +68 -18
- package/src/renderer/markdown-inline.ts +143 -0
- package/src/renderer/markdown-table.ts +459 -0
- package/src/renderer/markdown.ts +6 -335
- package/src/renderer/model-workspace.ts +48 -5
- package/src/renderer/settings-modal-helpers.ts +53 -1
- package/src/renderer/settings-modal.ts +1 -50
- package/src/runtime/bootstrap-command-parts.ts +36 -4
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/durability-housekeeping.ts +183 -0
- package/src/runtime/durability-services.ts +68 -11
- package/src/runtime/legacy-daemon-reconcile.ts +16 -7
- package/src/runtime/provider-fallback.ts +16 -2
- package/src/runtime/recovery-decisions.ts +180 -11
- package/src/runtime/recovery-prompt.ts +11 -1
- package/src/runtime/services.ts +9 -4
- package/src/runtime/session-liveness-marker.ts +142 -5
- package/src/runtime/workstream-draft-store.ts +198 -25
- package/src/runtime/workstream-services.ts +17 -2
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
- 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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
throw new Error(
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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 (
|
|
352
|
-
ctx.print(
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
421
|
+
const options = { preSelectId: inEffect, allowSearch: false };
|
|
422
|
+
ctx.openSelection('Reasoning Effort', items, options, (result) => {
|
|
369
423
|
if (!result) return;
|
|
370
|
-
|
|
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
|
-
|
|
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
|
|
393
|
-
if (!
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|