@aexol/spectral 0.7.3 → 0.7.6
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/dist/relay/dispatcher.js +32 -2
- package/dist/server/handlers/queue.js +52 -0
- package/dist/server/pi-bridge.js +67 -0
- package/dist/server/session-stream.js +87 -1
- package/dist/server/storage.js +132 -27
- package/package.json +1 -1
package/dist/relay/dispatcher.js
CHANGED
|
@@ -42,6 +42,7 @@ import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
|
|
|
42
42
|
import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
|
|
43
43
|
import { handleBindStudioProject, handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
|
|
44
44
|
import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryDetails, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
|
|
45
|
+
import { handleClearPromptQueue, handleEnqueuePrompt, handleGetPromptQueue, handleRemovePrompt, } from "../server/handlers/queue.js";
|
|
45
46
|
import { shutdownState } from "../server/shutdown.js";
|
|
46
47
|
import { handleAutoResearch } from "./auto-research.js";
|
|
47
48
|
/**
|
|
@@ -133,6 +134,25 @@ export function matchRoute(method, path) {
|
|
|
133
134
|
return { route: "fork_session", id };
|
|
134
135
|
return null;
|
|
135
136
|
}
|
|
137
|
+
// /api/sessions/:id/queue and /api/sessions/:id/queue/:itemId
|
|
138
|
+
const queueMatch = /^\/api\/sessions\/([^/]+)\/queue(\/([^/]+))?$/.exec(cleanPath);
|
|
139
|
+
if (queueMatch) {
|
|
140
|
+
const id = decodeURIComponent(queueMatch[1]);
|
|
141
|
+
const itemId = queueMatch[3] ? decodeURIComponent(queueMatch[3]) : undefined;
|
|
142
|
+
if (!itemId) {
|
|
143
|
+
if (method === "GET")
|
|
144
|
+
return { route: "get_prompt_queue", id };
|
|
145
|
+
if (method === "POST")
|
|
146
|
+
return { route: "enqueue_prompt", id };
|
|
147
|
+
if (method === "DELETE")
|
|
148
|
+
return { route: "clear_prompt_queue", id };
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
if (method === "DELETE")
|
|
152
|
+
return { route: "remove_prompt", id, itemId };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
136
156
|
return null;
|
|
137
157
|
}
|
|
138
158
|
/**
|
|
@@ -334,6 +354,16 @@ async function dispatchRoute(match, body, deps) {
|
|
|
334
354
|
const prefix = match.query?.get("prefix") ?? "";
|
|
335
355
|
return handlePathAutocomplete(prefix);
|
|
336
356
|
}
|
|
357
|
+
case "enqueue_prompt":
|
|
358
|
+
return handleEnqueuePrompt(store, manager, id, asObject(body));
|
|
359
|
+
case "get_prompt_queue":
|
|
360
|
+
return handleGetPromptQueue(store, id);
|
|
361
|
+
case "remove_prompt":
|
|
362
|
+
handleRemovePrompt(store, manager, id, match.itemId ?? "");
|
|
363
|
+
return { ok: true };
|
|
364
|
+
case "clear_prompt_queue":
|
|
365
|
+
handleClearPromptQueue(store, manager, id);
|
|
366
|
+
return { ok: true };
|
|
337
367
|
}
|
|
338
368
|
}
|
|
339
369
|
/**
|
|
@@ -442,7 +472,7 @@ function makeRelaySubscriber(sessionId, relay) {
|
|
|
442
472
|
* an `error` event to subscribers, so we don't double-report.
|
|
443
473
|
*/
|
|
444
474
|
export function handleClientMessage(frame, deps) {
|
|
445
|
-
const { sessionId, message, modelId } = frame;
|
|
475
|
+
const { sessionId, message, modelId, reasoningEffort } = frame;
|
|
446
476
|
const { manager, relay, subscribers } = deps;
|
|
447
477
|
const logger = deps.logger ?? console;
|
|
448
478
|
// 0. Shutdown gate. Once `gracefulShutdown` flips the flag we refuse
|
|
@@ -553,7 +583,7 @@ export function handleClientMessage(frame, deps) {
|
|
|
553
583
|
//
|
|
554
584
|
// When `loop: true`, loop state is set before prompting; the normal
|
|
555
585
|
// model is used — loop replay is handled by session-stream on agent_end.
|
|
556
|
-
manager.prompt(sessionId, content, modelId, validImages).catch((err) => {
|
|
586
|
+
manager.prompt(sessionId, content, modelId, validImages, reasoningEffort).catch((err) => {
|
|
557
587
|
logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
|
|
558
588
|
});
|
|
559
589
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure REST handlers for `/api/sessions/:sessionId/queue/*`.
|
|
3
|
+
*
|
|
4
|
+
* Same contract as sessions/projects handlers — pure functions that take
|
|
5
|
+
* `store` + `manager` dependencies, throw `BadRequestError`/`NotFoundError`
|
|
6
|
+
* on invalid input, and return typed responses.
|
|
7
|
+
*
|
|
8
|
+
* After mutating the queue, each handler calls `manager.pushQueueState()`
|
|
9
|
+
* to broadcast the updated queue to all subscribers via `queue_changed`.
|
|
10
|
+
*/
|
|
11
|
+
import { BadRequestError, NotFoundError } from "./errors.js";
|
|
12
|
+
export function handleEnqueuePrompt(store, manager, sessionId, body) {
|
|
13
|
+
if (typeof body.content !== "string" || !body.content.trim()) {
|
|
14
|
+
throw new BadRequestError("content (non-empty string) is required");
|
|
15
|
+
}
|
|
16
|
+
const session = store.getSession(sessionId);
|
|
17
|
+
if (!session)
|
|
18
|
+
throw new NotFoundError("Session not found");
|
|
19
|
+
const row = store.enqueuePrompt(sessionId, body.content);
|
|
20
|
+
manager.pushQueueState(sessionId);
|
|
21
|
+
return {
|
|
22
|
+
id: row.id,
|
|
23
|
+
content: row.content,
|
|
24
|
+
position: row.position,
|
|
25
|
+
createdAt: row.created_at,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function handleGetPromptQueue(store, sessionId) {
|
|
29
|
+
const session = store.getSession(sessionId);
|
|
30
|
+
if (!session)
|
|
31
|
+
throw new NotFoundError("Session not found");
|
|
32
|
+
return store.getPromptQueue(sessionId).map((r) => ({
|
|
33
|
+
id: r.id,
|
|
34
|
+
content: r.content,
|
|
35
|
+
position: r.position,
|
|
36
|
+
createdAt: r.created_at,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
export function handleRemovePrompt(store, manager, sessionId, itemId) {
|
|
40
|
+
const session = store.getSession(sessionId);
|
|
41
|
+
if (!session)
|
|
42
|
+
throw new NotFoundError("Session not found");
|
|
43
|
+
store.removePrompt(sessionId, itemId);
|
|
44
|
+
manager.pushQueueState(sessionId);
|
|
45
|
+
}
|
|
46
|
+
export function handleClearPromptQueue(store, manager, sessionId) {
|
|
47
|
+
const session = store.getSession(sessionId);
|
|
48
|
+
if (!session)
|
|
49
|
+
throw new NotFoundError("Session not found");
|
|
50
|
+
store.clearPromptQueue(sessionId);
|
|
51
|
+
manager.pushQueueState(sessionId);
|
|
52
|
+
}
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -750,6 +750,14 @@ export class PiBridge {
|
|
|
750
750
|
getFirstAvailableModelId() {
|
|
751
751
|
return this.allowedModels?.[0]?.modelId;
|
|
752
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* Return current session context usage from pi's built-in estimator.
|
|
755
|
+
* Used after compaction and session start to push updated context-window
|
|
756
|
+
* stats to the frontend without waiting for the next assistant turn.
|
|
757
|
+
*/
|
|
758
|
+
getContextUsage() {
|
|
759
|
+
return this.session?.getContextUsage();
|
|
760
|
+
}
|
|
753
761
|
getSessionBranch() {
|
|
754
762
|
return (this.sessionManager?.getBranch() ?? []);
|
|
755
763
|
}
|
|
@@ -834,6 +842,65 @@ export class PiBridge {
|
|
|
834
842
|
return false;
|
|
835
843
|
}
|
|
836
844
|
}
|
|
845
|
+
/**
|
|
846
|
+
* Map a frontend reasoning-effort string to pi's ThinkingLevel.
|
|
847
|
+
* Frontend sends: xhigh | high | medium | low | minimal | none | undefined
|
|
848
|
+
* Pi expects: "high" | "medium" | "low" | "minimal" | "off"
|
|
849
|
+
*
|
|
850
|
+
* Mapping:
|
|
851
|
+
* xhigh → high (pi doesn't have xhigh, default to max)
|
|
852
|
+
* high → high
|
|
853
|
+
* medium → medium
|
|
854
|
+
* low → low
|
|
855
|
+
* minimal → minimal
|
|
856
|
+
* none → off
|
|
857
|
+
* undefined → no-op (pi keeps whatever it has currently)
|
|
858
|
+
*/
|
|
859
|
+
mapReasoningEffortToThinkingLevel(effort) {
|
|
860
|
+
if (effort === undefined)
|
|
861
|
+
return undefined;
|
|
862
|
+
switch (effort) {
|
|
863
|
+
case "xhigh":
|
|
864
|
+
return "high";
|
|
865
|
+
case "high":
|
|
866
|
+
case "medium":
|
|
867
|
+
case "low":
|
|
868
|
+
case "minimal":
|
|
869
|
+
return effort;
|
|
870
|
+
case "none":
|
|
871
|
+
return "off";
|
|
872
|
+
default:
|
|
873
|
+
return undefined;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Set the reasoning/thinking effort level for the next prompt.
|
|
878
|
+
* Pass `undefined` to leave pi's current level unchanged.
|
|
879
|
+
*
|
|
880
|
+
* The caller (SessionStreamManager) is responsible for persisting the
|
|
881
|
+
* value to SQLite; this method only applies it to pi's in-memory session.
|
|
882
|
+
*/
|
|
883
|
+
setReasoningEffort(effort) {
|
|
884
|
+
const level = this.mapReasoningEffortToThinkingLevel(effort);
|
|
885
|
+
if (level === undefined)
|
|
886
|
+
return;
|
|
887
|
+
if (!this.session) {
|
|
888
|
+
// Bridge hasn't started yet — the reasoning effort will be picked
|
|
889
|
+
// up from storage on the next prompt via session-stream's persistence.
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
this.session.setThinkingLevel(level);
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
897
|
+
this.opts.onError?.(e);
|
|
898
|
+
this.opts.emit({
|
|
899
|
+
type: "error",
|
|
900
|
+
message: `Failed to set reasoning effort: ${e.message}`,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
}
|
|
837
904
|
/**
|
|
838
905
|
* Forward a user message to pi. Resolves when the full turn ends.
|
|
839
906
|
* The caller is responsible for persisting the user message to SQLite
|
|
@@ -483,7 +483,7 @@ export class SessionStreamManager {
|
|
|
483
483
|
* - When neither envelope nor SQLite have a value, we leave model
|
|
484
484
|
* selection to pi's own settings file (pre-Phase-3 behaviour).
|
|
485
485
|
*/
|
|
486
|
-
async prompt(sessionId, content, modelId, images) {
|
|
486
|
+
async prompt(sessionId, content, modelId, images, reasoningEffort) {
|
|
487
487
|
if (this.disposed)
|
|
488
488
|
throw new Error("SessionStreamManager disposed");
|
|
489
489
|
let stream = this.streams.get(sessionId);
|
|
@@ -599,6 +599,24 @@ export class SessionStreamManager {
|
|
|
599
599
|
console.warn(`[spectral] warn: failed to persist sticky model for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
|
+
// Sticky reasoning-effort resolution. Same order as modelId:
|
|
603
|
+
// a) Envelope-supplied value → apply + persist.
|
|
604
|
+
// b) Otherwise, look up the persisted value from SQLite.
|
|
605
|
+
// c) When neither is present, leave pi's current thinking level unchanged.
|
|
606
|
+
// We apply BEFORE persisting the user message so the turn runs with the
|
|
607
|
+
// correct reasoning level.
|
|
608
|
+
const effectiveReasoningEffort = reasoningEffort ?? this.store.getSessionReasoningEffort(sessionId) ?? undefined;
|
|
609
|
+
if (effectiveReasoningEffort && stream.bridge.setReasoningEffort) {
|
|
610
|
+
stream.bridge.setReasoningEffort(effectiveReasoningEffort);
|
|
611
|
+
}
|
|
612
|
+
if (reasoningEffort !== undefined) {
|
|
613
|
+
try {
|
|
614
|
+
this.store.setSessionReasoningEffort(sessionId, reasoningEffort || null);
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
console.warn(`[spectral] warn: failed to persist reasoning effort for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
602
620
|
// 1. Persist user message first (survives mid-prompt failures).
|
|
603
621
|
let stored;
|
|
604
622
|
try {
|
|
@@ -1155,6 +1173,29 @@ export class SessionStreamManager {
|
|
|
1155
1173
|
prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
|
|
1156
1174
|
}
|
|
1157
1175
|
persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
|
|
1176
|
+
// After compaction the session context has been reduced; push updated
|
|
1177
|
+
// context-window stats to all subscribers so the frontend's context
|
|
1178
|
+
// bar refreshes immediately instead of waiting for the next turn.
|
|
1179
|
+
const ctx = stream.bridge.getContextUsage?.();
|
|
1180
|
+
if (ctx) {
|
|
1181
|
+
stream.contextWindowUsed = ctx.tokens;
|
|
1182
|
+
stream.contextWindowMax = ctx.contextWindow;
|
|
1183
|
+
this.broadcast(stream, {
|
|
1184
|
+
type: "token_usage",
|
|
1185
|
+
messageId: "",
|
|
1186
|
+
usage: {
|
|
1187
|
+
inputTokens: 0,
|
|
1188
|
+
outputTokens: 0,
|
|
1189
|
+
cacheReadTokens: 0,
|
|
1190
|
+
cacheWriteTokens: 0,
|
|
1191
|
+
totalTokens: 0,
|
|
1192
|
+
cost: null,
|
|
1193
|
+
creditsUsed: 0,
|
|
1194
|
+
},
|
|
1195
|
+
contextWindowUsed: ctx.tokens,
|
|
1196
|
+
contextWindowMax: ctx.contextWindow,
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1158
1199
|
const tokensMsg = typeof event.tokensBefore === "number"
|
|
1159
1200
|
? ` (from ~${event.tokensBefore.toLocaleString()} tokens)`
|
|
1160
1201
|
: "";
|
|
@@ -1242,6 +1283,14 @@ export class SessionStreamManager {
|
|
|
1242
1283
|
stream.forkCompactSourceId = null; // one-shot
|
|
1243
1284
|
this.triggerForkCompact(stream);
|
|
1244
1285
|
}
|
|
1286
|
+
// Auto-dequeue: after the turn finishes and no loop iteration is
|
|
1287
|
+
// pending, check the persistent prompt queue. If there's a queued
|
|
1288
|
+
// prompt, start it immediately without broadcasting agent_end —
|
|
1289
|
+
// the frontend transitions seamlessly to the next turn.
|
|
1290
|
+
if (!stream.loopActive || !stream.loopOriginalPrompt) {
|
|
1291
|
+
if (this.maybeAutoDequeue(stream))
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1245
1294
|
}
|
|
1246
1295
|
else if (event.type === "error") {
|
|
1247
1296
|
// An error event arriving outside a turn (or bubbling out of one) —
|
|
@@ -1307,6 +1356,43 @@ export class SessionStreamManager {
|
|
|
1307
1356
|
for (const sub of dead)
|
|
1308
1357
|
stream.subscribers.delete(sub);
|
|
1309
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Push the current prompt queue state to all subscribers of a session.
|
|
1361
|
+
* Called after every queue mutation (enqueue, remove, clear, dequeue).
|
|
1362
|
+
* Safe to call when no stream exists or no subscribers — silently no-ops.
|
|
1363
|
+
*/
|
|
1364
|
+
pushQueueState(sessionId) {
|
|
1365
|
+
const stream = this.streams.get(sessionId);
|
|
1366
|
+
if (!stream || stream.subscribers.size === 0)
|
|
1367
|
+
return;
|
|
1368
|
+
const rows = this.store.getPromptQueue(sessionId);
|
|
1369
|
+
this.broadcast(stream, {
|
|
1370
|
+
type: "queue_changed",
|
|
1371
|
+
queue: rows.map((r) => ({
|
|
1372
|
+
id: r.id,
|
|
1373
|
+
content: r.content,
|
|
1374
|
+
position: r.position,
|
|
1375
|
+
createdAt: r.created_at,
|
|
1376
|
+
})),
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Auto-dequeue the next prompt from the persistent queue and start a
|
|
1381
|
+
* new turn. Called from the `agent_end` handler when no loop is active.
|
|
1382
|
+
* Returns true if a prompt was dequeued and a turn started.
|
|
1383
|
+
*/
|
|
1384
|
+
maybeAutoDequeue(stream) {
|
|
1385
|
+
const next = this.store.dequeuePrompt(stream.sessionId);
|
|
1386
|
+
if (!next)
|
|
1387
|
+
return false;
|
|
1388
|
+
// Push updated queue state so the frontend sees the item was removed.
|
|
1389
|
+
this.pushQueueState(stream.sessionId);
|
|
1390
|
+
// Fire the next prompt. Reuses the sticky model from the just-completed
|
|
1391
|
+
// turn (persisted by `prompt()` into the sessions.model_id column).
|
|
1392
|
+
const lastModelId = this.store.getSessionModel(stream.sessionId);
|
|
1393
|
+
void this.prompt(stream.sessionId, next.content, lastModelId ?? undefined);
|
|
1394
|
+
return true;
|
|
1395
|
+
}
|
|
1310
1396
|
}
|
|
1311
1397
|
function isReplayable(event) {
|
|
1312
1398
|
return (event.type === "message_start" ||
|
package/dist/server/storage.js
CHANGED
|
@@ -32,11 +32,12 @@ import { dirname, join } from "node:path";
|
|
|
32
32
|
import { getConfigDir } from "../config.js";
|
|
33
33
|
import { stripJsoncComments } from "../studio-binding.js";
|
|
34
34
|
/**
|
|
35
|
-
* Schema version
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Schema version — informational, stamped into every new database.
|
|
36
|
+
*
|
|
37
|
+
* ALL schema changes are additive (ALTER TABLE ADD COLUMN). The version is
|
|
38
|
+
* never used to trigger destructive drops; it is purely for troubleshooting.
|
|
38
39
|
*/
|
|
39
|
-
const SCHEMA_VERSION =
|
|
40
|
+
const SCHEMA_VERSION = 4;
|
|
40
41
|
const SCHEMA_SQL = `
|
|
41
42
|
PRAGMA foreign_keys = ON;
|
|
42
43
|
|
|
@@ -54,7 +55,8 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
54
55
|
title TEXT NOT NULL,
|
|
55
56
|
created_at INTEGER NOT NULL,
|
|
56
57
|
updated_at INTEGER NOT NULL,
|
|
57
|
-
model_id TEXT
|
|
58
|
+
model_id TEXT,
|
|
59
|
+
reasoning_effort TEXT
|
|
58
60
|
);
|
|
59
61
|
|
|
60
62
|
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
|
|
@@ -89,6 +91,17 @@ CREATE TABLE IF NOT EXISTS project_observations (
|
|
|
89
91
|
);
|
|
90
92
|
|
|
91
93
|
CREATE INDEX IF NOT EXISTS idx_project_obs_project ON project_observations(project_id, created_at DESC);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS prompt_queue (
|
|
96
|
+
id TEXT PRIMARY KEY,
|
|
97
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
98
|
+
content TEXT NOT NULL,
|
|
99
|
+
position INTEGER NOT NULL,
|
|
100
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
101
|
+
UNIQUE(session_id, position)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_prompt_queue_session ON prompt_queue(session_id, position);
|
|
92
105
|
`;
|
|
93
106
|
/**
|
|
94
107
|
* Synchronous binding-file reader for a project at the given filesystem path.
|
|
@@ -119,7 +132,6 @@ function applyBindingFields(project) {
|
|
|
119
132
|
};
|
|
120
133
|
}
|
|
121
134
|
/** Tables we own — used by the migration drop step. */
|
|
122
|
-
const KNOWN_TABLES = ["project_observations", "session_memory_snapshots", "messages", "sessions", "projects"];
|
|
123
135
|
/** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
|
|
124
136
|
function parseImagesJson(raw) {
|
|
125
137
|
if (!raw || raw === "")
|
|
@@ -177,6 +189,8 @@ export class SessionStore {
|
|
|
177
189
|
// Phase 3: per-session sticky model.
|
|
178
190
|
stmtGetSessionModel;
|
|
179
191
|
stmtSetSessionModel;
|
|
192
|
+
stmtGetSessionReasoningEffort;
|
|
193
|
+
stmtSetSessionReasoningEffort;
|
|
180
194
|
// Fork & Compact: flag + per-session source tracking.
|
|
181
195
|
stmtSetForkCompactSource;
|
|
182
196
|
stmtGetForkCompactSource;
|
|
@@ -184,6 +198,13 @@ export class SessionStore {
|
|
|
184
198
|
stmtInsertProjectObs;
|
|
185
199
|
stmtSearchProjectObs;
|
|
186
200
|
stmtGetProjectByCwd;
|
|
201
|
+
// Prompt queue statements
|
|
202
|
+
stmtEnqueuePrompt;
|
|
203
|
+
stmtGetPromptQueue;
|
|
204
|
+
stmtDequeuePrompt;
|
|
205
|
+
stmtDeleteQueueItem;
|
|
206
|
+
stmtClearSessionQueue;
|
|
207
|
+
stmtShiftPositions;
|
|
187
208
|
constructor(path) {
|
|
188
209
|
this.path = path;
|
|
189
210
|
// Make sure the parent directory exists. mkdirSync with recursive is a
|
|
@@ -192,28 +213,17 @@ export class SessionStore {
|
|
|
192
213
|
this.db = new Database(path);
|
|
193
214
|
this.db.pragma("journal_mode = WAL");
|
|
194
215
|
this.db.pragma("foreign_keys = ON");
|
|
195
|
-
// ----
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.db.pragma("foreign_keys = OFF");
|
|
206
|
-
const tx = this.db.transaction(() => {
|
|
207
|
-
for (const t of KNOWN_TABLES) {
|
|
208
|
-
this.db.exec(`DROP TABLE IF EXISTS ${t}`);
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
tx();
|
|
212
|
-
this.db.pragma("foreign_keys = ON");
|
|
213
|
-
}
|
|
216
|
+
// ---- schema creation -------------------------------------------------
|
|
217
|
+
// SCHEMA_SQL uses CREATE TABLE IF NOT EXISTS for every table, so it is
|
|
218
|
+
// always safe to run — on a fresh database all tables are created;
|
|
219
|
+
// on an existing one every statement is a no-op. Schema changes that
|
|
220
|
+
// add columns are handled by the additive migration block below.
|
|
221
|
+
//
|
|
222
|
+
// We NO LONGER drop tables when the stored user_version differs from
|
|
223
|
+
// SCHEMA_VERSION. The previous destructive behaviour caused permanent
|
|
224
|
+
// data loss on version bumps. SCHEMA_VERSION is now informational — it
|
|
225
|
+
// is stamped into every new database purely for troubleshooting.
|
|
214
226
|
this.db.exec(SCHEMA_SQL);
|
|
215
|
-
// Stamp the version AFTER schema creation so a crash mid-create is
|
|
216
|
-
// detected next open and re-runs the (idempotent) create.
|
|
217
227
|
this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
218
228
|
// ---- additive column migration ---------------------------------------
|
|
219
229
|
// Phase 3 (Available Models): per-session sticky model selection lives in
|
|
@@ -230,6 +240,9 @@ export class SessionStore {
|
|
|
230
240
|
if (!sessionCols.some((c) => c.name === "model_id")) {
|
|
231
241
|
this.db.exec(`ALTER TABLE sessions ADD COLUMN model_id TEXT`);
|
|
232
242
|
}
|
|
243
|
+
if (!sessionCols.some((c) => c.name === "reasoning_effort")) {
|
|
244
|
+
this.db.exec(`ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT`);
|
|
245
|
+
}
|
|
233
246
|
// Additive migration: images_json column for base64 image attachments on
|
|
234
247
|
// user messages. Older rows default to '' (no images).
|
|
235
248
|
const msgCols = this.db
|
|
@@ -320,6 +333,8 @@ export class SessionStore {
|
|
|
320
333
|
this.stmtDeleteSessionMemorySnapshot = this.db.prepare(`DELETE FROM session_memory_snapshots WHERE session_id = ?`);
|
|
321
334
|
this.stmtGetSessionModel = this.db.prepare(`SELECT model_id FROM sessions WHERE id = ?`);
|
|
322
335
|
this.stmtSetSessionModel = this.db.prepare(`UPDATE sessions SET model_id = ? WHERE id = ?`);
|
|
336
|
+
this.stmtGetSessionReasoningEffort = this.db.prepare(`SELECT reasoning_effort FROM sessions WHERE id = ?`);
|
|
337
|
+
this.stmtSetSessionReasoningEffort = this.db.prepare(`UPDATE sessions SET reasoning_effort = ? WHERE id = ?`);
|
|
323
338
|
this.stmtSetForkCompactSource = this.db.prepare(`UPDATE sessions SET fork_compact_source_id = ? WHERE id = ?`);
|
|
324
339
|
this.stmtGetForkCompactSource = this.db.prepare(`SELECT fork_compact_source_id FROM sessions WHERE id = ?`);
|
|
325
340
|
this.stmtInsertProjectObs = this.db.prepare(`INSERT OR REPLACE INTO project_observations (id, project_id, session_id, content, relevance, created_at)
|
|
@@ -330,6 +345,19 @@ export class SessionStore {
|
|
|
330
345
|
ORDER BY created_at DESC
|
|
331
346
|
LIMIT 20`);
|
|
332
347
|
this.stmtGetProjectByCwd = this.db.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`);
|
|
348
|
+
// ---- prompt queue statements ----------------------------------------
|
|
349
|
+
this.stmtEnqueuePrompt = this.db.prepare(`INSERT INTO prompt_queue (id, session_id, content, position, created_at)
|
|
350
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
351
|
+
this.stmtGetPromptQueue = this.db.prepare(`SELECT id, session_id, content, position, created_at
|
|
352
|
+
FROM prompt_queue WHERE session_id = ?
|
|
353
|
+
ORDER BY position ASC`);
|
|
354
|
+
this.stmtDequeuePrompt = this.db.prepare(`SELECT id, session_id, content, position, created_at
|
|
355
|
+
FROM prompt_queue WHERE session_id = ?
|
|
356
|
+
ORDER BY position ASC LIMIT 1`);
|
|
357
|
+
this.stmtDeleteQueueItem = this.db.prepare(`DELETE FROM prompt_queue WHERE id = ? AND session_id = ?`);
|
|
358
|
+
this.stmtClearSessionQueue = this.db.prepare(`DELETE FROM prompt_queue WHERE session_id = ?`);
|
|
359
|
+
this.stmtShiftPositions = this.db.prepare(`UPDATE prompt_queue SET position = position + ?
|
|
360
|
+
WHERE session_id = ? AND position >= ?`);
|
|
333
361
|
}
|
|
334
362
|
/** Smoke check: returns the names of the tables in the DB. */
|
|
335
363
|
listTables() {
|
|
@@ -614,6 +642,23 @@ export class SessionStore {
|
|
|
614
642
|
setSessionModel(sessionId, modelId) {
|
|
615
643
|
this.stmtSetSessionModel.run(modelId, sessionId);
|
|
616
644
|
}
|
|
645
|
+
/**
|
|
646
|
+
* Get the persisted reasoningEffort for a session, or null if none
|
|
647
|
+
* was ever set. Returns null for unknown sessions (consistent with
|
|
648
|
+
* getSessionModel's contract).
|
|
649
|
+
*/
|
|
650
|
+
getSessionReasoningEffort(sessionId) {
|
|
651
|
+
const row = this.stmtGetSessionReasoningEffort.get(sessionId);
|
|
652
|
+
return row?.reasoning_effort ?? null;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Persist the reasoningEffort for a session. Pass `null` to clear.
|
|
656
|
+
* Same semantics as setSessionModel — best-effort metadata, does
|
|
657
|
+
* not bump `updated_at`.
|
|
658
|
+
*/
|
|
659
|
+
setSessionReasoningEffort(sessionId, effort) {
|
|
660
|
+
this.stmtSetSessionReasoningEffort.run(effort, sessionId);
|
|
661
|
+
}
|
|
617
662
|
// ----------------------------------------------------------------------
|
|
618
663
|
// Fork & Compact
|
|
619
664
|
// ----------------------------------------------------------------------
|
|
@@ -714,6 +759,66 @@ export class SessionStore {
|
|
|
714
759
|
const row = this.stmtGetProjectByCwd.get(cwd);
|
|
715
760
|
return row?.id ?? null;
|
|
716
761
|
}
|
|
762
|
+
// ----------------------------------------------------------------------
|
|
763
|
+
// Prompt Queue
|
|
764
|
+
// ----------------------------------------------------------------------
|
|
765
|
+
/**
|
|
766
|
+
* Enqueue a prompt for a session. Returns the created queue item.
|
|
767
|
+
* Position is auto-assigned as (max existing position + 1).
|
|
768
|
+
*/
|
|
769
|
+
enqueuePrompt(sessionId, content) {
|
|
770
|
+
const id = randomUUID();
|
|
771
|
+
const items = this.stmtGetPromptQueue.all(sessionId);
|
|
772
|
+
const nextPosition = items.length > 0
|
|
773
|
+
? Math.max(...items.map((r) => r.position)) + 1
|
|
774
|
+
: 0;
|
|
775
|
+
const createdAt = new Date().toISOString();
|
|
776
|
+
this.stmtEnqueuePrompt.run(id, sessionId, content, nextPosition, createdAt);
|
|
777
|
+
return { id, session_id: sessionId, content, position: nextPosition, created_at: createdAt };
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Dequeue (remove and return) the first prompt for a session.
|
|
781
|
+
* Returns null if the queue is empty.
|
|
782
|
+
* Renumbers remaining items so positions stay contiguous from 0.
|
|
783
|
+
*/
|
|
784
|
+
dequeuePrompt(sessionId) {
|
|
785
|
+
const row = this.stmtDequeuePrompt.get(sessionId);
|
|
786
|
+
if (!row)
|
|
787
|
+
return null;
|
|
788
|
+
const tx = this.db.transaction(() => {
|
|
789
|
+
this.stmtDeleteQueueItem.run(row.id, sessionId);
|
|
790
|
+
// Shift remaining positions down by 1
|
|
791
|
+
this.stmtShiftPositions.run(sessionId, -1, row.position);
|
|
792
|
+
});
|
|
793
|
+
tx();
|
|
794
|
+
return row;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Get the full prompt queue for a session, ordered by position.
|
|
798
|
+
*/
|
|
799
|
+
getPromptQueue(sessionId) {
|
|
800
|
+
return this.stmtGetPromptQueue.all(sessionId);
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Remove a specific prompt from the queue by id.
|
|
804
|
+
* Renumbers remaining items so positions stay contiguous.
|
|
805
|
+
*/
|
|
806
|
+
removePrompt(sessionId, itemId) {
|
|
807
|
+
const row = this.stmtGetPromptQueue.all(sessionId).find((r) => r.id === itemId);
|
|
808
|
+
if (!row)
|
|
809
|
+
return;
|
|
810
|
+
const tx = this.db.transaction(() => {
|
|
811
|
+
this.stmtDeleteQueueItem.run(itemId, sessionId);
|
|
812
|
+
this.stmtShiftPositions.run(sessionId, -1, row.position);
|
|
813
|
+
});
|
|
814
|
+
tx();
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Clear the entire prompt queue for a session.
|
|
818
|
+
*/
|
|
819
|
+
clearPromptQueue(sessionId) {
|
|
820
|
+
this.stmtClearSessionQueue.run(sessionId);
|
|
821
|
+
}
|
|
717
822
|
close() {
|
|
718
823
|
if (this.closed)
|
|
719
824
|
return;
|