@aexol/spectral 0.7.3 → 0.7.5
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
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
|
/**
|
|
@@ -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
|
}
|
|
@@ -1155,6 +1155,29 @@ export class SessionStreamManager {
|
|
|
1155
1155
|
prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
|
|
1156
1156
|
}
|
|
1157
1157
|
persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
|
|
1158
|
+
// After compaction the session context has been reduced; push updated
|
|
1159
|
+
// context-window stats to all subscribers so the frontend's context
|
|
1160
|
+
// bar refreshes immediately instead of waiting for the next turn.
|
|
1161
|
+
const ctx = stream.bridge.getContextUsage?.();
|
|
1162
|
+
if (ctx) {
|
|
1163
|
+
stream.contextWindowUsed = ctx.tokens;
|
|
1164
|
+
stream.contextWindowMax = ctx.contextWindow;
|
|
1165
|
+
this.broadcast(stream, {
|
|
1166
|
+
type: "token_usage",
|
|
1167
|
+
messageId: "",
|
|
1168
|
+
usage: {
|
|
1169
|
+
inputTokens: 0,
|
|
1170
|
+
outputTokens: 0,
|
|
1171
|
+
cacheReadTokens: 0,
|
|
1172
|
+
cacheWriteTokens: 0,
|
|
1173
|
+
totalTokens: 0,
|
|
1174
|
+
cost: null,
|
|
1175
|
+
creditsUsed: 0,
|
|
1176
|
+
},
|
|
1177
|
+
contextWindowUsed: ctx.tokens,
|
|
1178
|
+
contextWindowMax: ctx.contextWindow,
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1158
1181
|
const tokensMsg = typeof event.tokensBefore === "number"
|
|
1159
1182
|
? ` (from ~${event.tokensBefore.toLocaleString()} tokens)`
|
|
1160
1183
|
: "";
|
|
@@ -1242,6 +1265,14 @@ export class SessionStreamManager {
|
|
|
1242
1265
|
stream.forkCompactSourceId = null; // one-shot
|
|
1243
1266
|
this.triggerForkCompact(stream);
|
|
1244
1267
|
}
|
|
1268
|
+
// Auto-dequeue: after the turn finishes and no loop iteration is
|
|
1269
|
+
// pending, check the persistent prompt queue. If there's a queued
|
|
1270
|
+
// prompt, start it immediately without broadcasting agent_end —
|
|
1271
|
+
// the frontend transitions seamlessly to the next turn.
|
|
1272
|
+
if (!stream.loopActive || !stream.loopOriginalPrompt) {
|
|
1273
|
+
if (this.maybeAutoDequeue(stream))
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1245
1276
|
}
|
|
1246
1277
|
else if (event.type === "error") {
|
|
1247
1278
|
// An error event arriving outside a turn (or bubbling out of one) —
|
|
@@ -1307,6 +1338,43 @@ export class SessionStreamManager {
|
|
|
1307
1338
|
for (const sub of dead)
|
|
1308
1339
|
stream.subscribers.delete(sub);
|
|
1309
1340
|
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Push the current prompt queue state to all subscribers of a session.
|
|
1343
|
+
* Called after every queue mutation (enqueue, remove, clear, dequeue).
|
|
1344
|
+
* Safe to call when no stream exists or no subscribers — silently no-ops.
|
|
1345
|
+
*/
|
|
1346
|
+
pushQueueState(sessionId) {
|
|
1347
|
+
const stream = this.streams.get(sessionId);
|
|
1348
|
+
if (!stream || stream.subscribers.size === 0)
|
|
1349
|
+
return;
|
|
1350
|
+
const rows = this.store.getPromptQueue(sessionId);
|
|
1351
|
+
this.broadcast(stream, {
|
|
1352
|
+
type: "queue_changed",
|
|
1353
|
+
queue: rows.map((r) => ({
|
|
1354
|
+
id: r.id,
|
|
1355
|
+
content: r.content,
|
|
1356
|
+
position: r.position,
|
|
1357
|
+
createdAt: r.created_at,
|
|
1358
|
+
})),
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Auto-dequeue the next prompt from the persistent queue and start a
|
|
1363
|
+
* new turn. Called from the `agent_end` handler when no loop is active.
|
|
1364
|
+
* Returns true if a prompt was dequeued and a turn started.
|
|
1365
|
+
*/
|
|
1366
|
+
maybeAutoDequeue(stream) {
|
|
1367
|
+
const next = this.store.dequeuePrompt(stream.sessionId);
|
|
1368
|
+
if (!next)
|
|
1369
|
+
return false;
|
|
1370
|
+
// Push updated queue state so the frontend sees the item was removed.
|
|
1371
|
+
this.pushQueueState(stream.sessionId);
|
|
1372
|
+
// Fire the next prompt. Reuses the sticky model from the just-completed
|
|
1373
|
+
// turn (persisted by `prompt()` into the sessions.model_id column).
|
|
1374
|
+
const lastModelId = this.store.getSessionModel(stream.sessionId);
|
|
1375
|
+
void this.prompt(stream.sessionId, next.content, lastModelId ?? undefined);
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1310
1378
|
}
|
|
1311
1379
|
function isReplayable(event) {
|
|
1312
1380
|
return (event.type === "message_start" ||
|
package/dist/server/storage.js
CHANGED
|
@@ -36,7 +36,7 @@ import { stripJsoncComments } from "../studio-binding.js";
|
|
|
36
36
|
* Since this is local-only conversation history pre-1.0, we explicitly do not
|
|
37
37
|
* preserve user data across schema changes.
|
|
38
38
|
*/
|
|
39
|
-
const SCHEMA_VERSION =
|
|
39
|
+
const SCHEMA_VERSION = 4;
|
|
40
40
|
const SCHEMA_SQL = `
|
|
41
41
|
PRAGMA foreign_keys = ON;
|
|
42
42
|
|
|
@@ -89,6 +89,17 @@ CREATE TABLE IF NOT EXISTS project_observations (
|
|
|
89
89
|
);
|
|
90
90
|
|
|
91
91
|
CREATE INDEX IF NOT EXISTS idx_project_obs_project ON project_observations(project_id, created_at DESC);
|
|
92
|
+
|
|
93
|
+
CREATE TABLE IF NOT EXISTS prompt_queue (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
96
|
+
content TEXT NOT NULL,
|
|
97
|
+
position INTEGER NOT NULL,
|
|
98
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
99
|
+
UNIQUE(session_id, position)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_prompt_queue_session ON prompt_queue(session_id, position);
|
|
92
103
|
`;
|
|
93
104
|
/**
|
|
94
105
|
* Synchronous binding-file reader for a project at the given filesystem path.
|
|
@@ -119,7 +130,7 @@ function applyBindingFields(project) {
|
|
|
119
130
|
};
|
|
120
131
|
}
|
|
121
132
|
/** Tables we own — used by the migration drop step. */
|
|
122
|
-
const KNOWN_TABLES = ["project_observations", "session_memory_snapshots", "messages", "sessions", "projects"];
|
|
133
|
+
const KNOWN_TABLES = ["prompt_queue", "project_observations", "session_memory_snapshots", "messages", "sessions", "projects"];
|
|
123
134
|
/** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
|
|
124
135
|
function parseImagesJson(raw) {
|
|
125
136
|
if (!raw || raw === "")
|
|
@@ -184,6 +195,13 @@ export class SessionStore {
|
|
|
184
195
|
stmtInsertProjectObs;
|
|
185
196
|
stmtSearchProjectObs;
|
|
186
197
|
stmtGetProjectByCwd;
|
|
198
|
+
// Prompt queue statements
|
|
199
|
+
stmtEnqueuePrompt;
|
|
200
|
+
stmtGetPromptQueue;
|
|
201
|
+
stmtDequeuePrompt;
|
|
202
|
+
stmtDeleteQueueItem;
|
|
203
|
+
stmtClearSessionQueue;
|
|
204
|
+
stmtShiftPositions;
|
|
187
205
|
constructor(path) {
|
|
188
206
|
this.path = path;
|
|
189
207
|
// Make sure the parent directory exists. mkdirSync with recursive is a
|
|
@@ -330,6 +348,19 @@ export class SessionStore {
|
|
|
330
348
|
ORDER BY created_at DESC
|
|
331
349
|
LIMIT 20`);
|
|
332
350
|
this.stmtGetProjectByCwd = this.db.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`);
|
|
351
|
+
// ---- prompt queue statements ----------------------------------------
|
|
352
|
+
this.stmtEnqueuePrompt = this.db.prepare(`INSERT INTO prompt_queue (id, session_id, content, position, created_at)
|
|
353
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
354
|
+
this.stmtGetPromptQueue = this.db.prepare(`SELECT id, session_id, content, position, created_at
|
|
355
|
+
FROM prompt_queue WHERE session_id = ?
|
|
356
|
+
ORDER BY position ASC`);
|
|
357
|
+
this.stmtDequeuePrompt = this.db.prepare(`SELECT id, session_id, content, position, created_at
|
|
358
|
+
FROM prompt_queue WHERE session_id = ?
|
|
359
|
+
ORDER BY position ASC LIMIT 1`);
|
|
360
|
+
this.stmtDeleteQueueItem = this.db.prepare(`DELETE FROM prompt_queue WHERE id = ? AND session_id = ?`);
|
|
361
|
+
this.stmtClearSessionQueue = this.db.prepare(`DELETE FROM prompt_queue WHERE session_id = ?`);
|
|
362
|
+
this.stmtShiftPositions = this.db.prepare(`UPDATE prompt_queue SET position = position + ?
|
|
363
|
+
WHERE session_id = ? AND position >= ?`);
|
|
333
364
|
}
|
|
334
365
|
/** Smoke check: returns the names of the tables in the DB. */
|
|
335
366
|
listTables() {
|
|
@@ -714,6 +745,66 @@ export class SessionStore {
|
|
|
714
745
|
const row = this.stmtGetProjectByCwd.get(cwd);
|
|
715
746
|
return row?.id ?? null;
|
|
716
747
|
}
|
|
748
|
+
// ----------------------------------------------------------------------
|
|
749
|
+
// Prompt Queue
|
|
750
|
+
// ----------------------------------------------------------------------
|
|
751
|
+
/**
|
|
752
|
+
* Enqueue a prompt for a session. Returns the created queue item.
|
|
753
|
+
* Position is auto-assigned as (max existing position + 1).
|
|
754
|
+
*/
|
|
755
|
+
enqueuePrompt(sessionId, content) {
|
|
756
|
+
const id = randomUUID();
|
|
757
|
+
const items = this.stmtGetPromptQueue.all(sessionId);
|
|
758
|
+
const nextPosition = items.length > 0
|
|
759
|
+
? Math.max(...items.map((r) => r.position)) + 1
|
|
760
|
+
: 0;
|
|
761
|
+
const createdAt = new Date().toISOString();
|
|
762
|
+
this.stmtEnqueuePrompt.run(id, sessionId, content, nextPosition, createdAt);
|
|
763
|
+
return { id, session_id: sessionId, content, position: nextPosition, created_at: createdAt };
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Dequeue (remove and return) the first prompt for a session.
|
|
767
|
+
* Returns null if the queue is empty.
|
|
768
|
+
* Renumbers remaining items so positions stay contiguous from 0.
|
|
769
|
+
*/
|
|
770
|
+
dequeuePrompt(sessionId) {
|
|
771
|
+
const row = this.stmtDequeuePrompt.get(sessionId);
|
|
772
|
+
if (!row)
|
|
773
|
+
return null;
|
|
774
|
+
const tx = this.db.transaction(() => {
|
|
775
|
+
this.stmtDeleteQueueItem.run(row.id, sessionId);
|
|
776
|
+
// Shift remaining positions down by 1
|
|
777
|
+
this.stmtShiftPositions.run(sessionId, -1, row.position);
|
|
778
|
+
});
|
|
779
|
+
tx();
|
|
780
|
+
return row;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Get the full prompt queue for a session, ordered by position.
|
|
784
|
+
*/
|
|
785
|
+
getPromptQueue(sessionId) {
|
|
786
|
+
return this.stmtGetPromptQueue.all(sessionId);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Remove a specific prompt from the queue by id.
|
|
790
|
+
* Renumbers remaining items so positions stay contiguous.
|
|
791
|
+
*/
|
|
792
|
+
removePrompt(sessionId, itemId) {
|
|
793
|
+
const row = this.stmtGetPromptQueue.all(sessionId).find((r) => r.id === itemId);
|
|
794
|
+
if (!row)
|
|
795
|
+
return;
|
|
796
|
+
const tx = this.db.transaction(() => {
|
|
797
|
+
this.stmtDeleteQueueItem.run(itemId, sessionId);
|
|
798
|
+
this.stmtShiftPositions.run(sessionId, -1, row.position);
|
|
799
|
+
});
|
|
800
|
+
tx();
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Clear the entire prompt queue for a session.
|
|
804
|
+
*/
|
|
805
|
+
clearPromptQueue(sessionId) {
|
|
806
|
+
this.stmtClearSessionQueue.run(sessionId);
|
|
807
|
+
}
|
|
717
808
|
close() {
|
|
718
809
|
if (this.closed)
|
|
719
810
|
return;
|