@aexol/spectral 0.3.5 → 0.3.7
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
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
|
|
42
42
|
import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
|
|
43
43
|
import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
|
|
44
|
-
import { handleCreateSession, handleDeleteSession, handleGetSessionDetail, handleUpdateSession, } from "../server/handlers/sessions.js";
|
|
44
|
+
import { handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleUpdateSession, } from "../server/handlers/sessions.js";
|
|
45
45
|
import { shutdownState } from "../server/shutdown.js";
|
|
46
46
|
/**
|
|
47
47
|
* Inline path matcher. Returns `null` for any path/method combination we
|
|
@@ -103,6 +103,14 @@ export function matchRoute(method, path) {
|
|
|
103
103
|
return { route: "delete_session", id };
|
|
104
104
|
return null;
|
|
105
105
|
}
|
|
106
|
+
// /api/sessions/:id/fork
|
|
107
|
+
const forkMatch = /^\/api\/sessions\/([^/]+)\/fork$/.exec(cleanPath);
|
|
108
|
+
if (forkMatch) {
|
|
109
|
+
const id = decodeURIComponent(forkMatch[1]);
|
|
110
|
+
if (method === "POST")
|
|
111
|
+
return { route: "fork_session", id };
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
106
114
|
return null;
|
|
107
115
|
}
|
|
108
116
|
/**
|
|
@@ -275,6 +283,15 @@ function dispatchRoute(match, body, deps) {
|
|
|
275
283
|
}
|
|
276
284
|
return { ok: true };
|
|
277
285
|
}
|
|
286
|
+
case "fork_session": {
|
|
287
|
+
const session = handleForkSession(store, id, asObject(body));
|
|
288
|
+
safePublish(publishMetaEvent, logger, {
|
|
289
|
+
type: "session_created",
|
|
290
|
+
projectId: session.projectId,
|
|
291
|
+
sessionId: session.id,
|
|
292
|
+
});
|
|
293
|
+
return session;
|
|
294
|
+
}
|
|
278
295
|
case "list_path_autocomplete": {
|
|
279
296
|
const prefix = match.query?.get("prefix") ?? "";
|
|
280
297
|
return handlePathAutocomplete(prefix);
|
|
@@ -471,6 +488,7 @@ export function handleClientMessage(frame, deps) {
|
|
|
471
488
|
sessionId,
|
|
472
489
|
history: attachResult.history,
|
|
473
490
|
currentTurn: attachResult.currentTurn,
|
|
491
|
+
forkCompactPending: attachResult.forkCompactPending || undefined,
|
|
474
492
|
},
|
|
475
493
|
});
|
|
476
494
|
// Surface bridge-start failures as `error` events; otherwise the
|
|
@@ -542,6 +560,7 @@ export function handleSubscribe(frame, deps) {
|
|
|
542
560
|
sessionId,
|
|
543
561
|
history: attachResult.history,
|
|
544
562
|
currentTurn: attachResult.currentTurn,
|
|
563
|
+
forkCompactPending: attachResult.forkCompactPending || undefined,
|
|
545
564
|
},
|
|
546
565
|
});
|
|
547
566
|
if (isNewSubscriber) {
|
|
@@ -40,3 +40,15 @@ export function handleDeleteSession(store, id) {
|
|
|
40
40
|
if (!deleted)
|
|
41
41
|
throw new NotFoundError("Session not found");
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Fork a session: create a new session copying all messages from the
|
|
45
|
+
* source, with the `fork_compact_source_id` flag set so the
|
|
46
|
+
* SessionStreamManager compacts after the first assistant turn.
|
|
47
|
+
*/
|
|
48
|
+
export function handleForkSession(store, id, body) {
|
|
49
|
+
const source = store.getSession(id);
|
|
50
|
+
if (!source)
|
|
51
|
+
throw new NotFoundError("Session not found");
|
|
52
|
+
const title = typeof body.title === "string" ? body.title : undefined;
|
|
53
|
+
return store.forkSession(id, { title });
|
|
54
|
+
}
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -740,6 +740,20 @@ export class PiBridge {
|
|
|
740
740
|
this.opts.emit({ type: "error", message: e.message });
|
|
741
741
|
}
|
|
742
742
|
}
|
|
743
|
+
/**
|
|
744
|
+
* Manually compact the session context via pi's built-in compaction.
|
|
745
|
+
* Pi generates a summary of older conversation history, preserving the
|
|
746
|
+
* most recent ~20K tokens verbatim. Compaction events are forwarded to
|
|
747
|
+
* the wire through `handleEvent()`.
|
|
748
|
+
*
|
|
749
|
+
* `customInstructions` can inject guidance into the compaction prompt
|
|
750
|
+
* so the summary captures information relevant to the current task.
|
|
751
|
+
*/
|
|
752
|
+
async compact(customInstructions) {
|
|
753
|
+
if (!this.session)
|
|
754
|
+
throw new Error("PiBridge.start() not called");
|
|
755
|
+
await this.session.compact(customInstructions);
|
|
756
|
+
}
|
|
743
757
|
dispose() {
|
|
744
758
|
if (this.disposed)
|
|
745
759
|
return;
|
|
@@ -975,11 +989,30 @@ export class PiBridge {
|
|
|
975
989
|
this.opts.emit({ type: "agent_end" });
|
|
976
990
|
return;
|
|
977
991
|
}
|
|
992
|
+
case "compaction_start": {
|
|
993
|
+
// Forward compaction lifecycle events to the wire so the browser
|
|
994
|
+
// can surface a "Compacting…" indicator and track compaction
|
|
995
|
+
// activity.
|
|
996
|
+
this.opts.emit({
|
|
997
|
+
type: "compaction_start",
|
|
998
|
+
reason: ev.reason,
|
|
999
|
+
});
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
case "compaction_end": {
|
|
1003
|
+
this.opts.emit({
|
|
1004
|
+
type: "compaction_end",
|
|
1005
|
+
summary: ev.result?.summary ?? "",
|
|
1006
|
+
tokensBefore: ev.result?.tokensBefore ?? 0,
|
|
1007
|
+
aborted: ev.aborted,
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
978
1011
|
default:
|
|
979
|
-
// Other pi-internal events (turn_start, queue_update,
|
|
980
|
-
// auto_retry_*, tool_execution_update) are intentionally not on
|
|
981
|
-
// wire surface for MVP and are NOT persisted — the wire format
|
|
982
|
-
// the source of truth for replay.
|
|
1012
|
+
// Other pi-internal events (turn_start, queue_update,
|
|
1013
|
+
// auto_retry_*, tool_execution_update) are intentionally not on
|
|
1014
|
+
// the wire surface for MVP and are NOT persisted — the wire format
|
|
1015
|
+
// is the source of truth for replay.
|
|
983
1016
|
return;
|
|
984
1017
|
}
|
|
985
1018
|
}
|
|
@@ -105,6 +105,7 @@ export class SessionStreamManager {
|
|
|
105
105
|
history: detail.messages,
|
|
106
106
|
currentTurn: stream.currentTurn ? snapshotTurn(stream.currentTurn) : null,
|
|
107
107
|
ready: stream.ready,
|
|
108
|
+
forkCompactPending: stream.forkCompactSourceId != null,
|
|
108
109
|
};
|
|
109
110
|
}
|
|
110
111
|
/**
|
|
@@ -170,6 +171,16 @@ export class SessionStreamManager {
|
|
|
170
171
|
let stream = this.streams.get(sessionId);
|
|
171
172
|
if (!stream)
|
|
172
173
|
throw new Error(`No active stream for session: ${sessionId}`);
|
|
174
|
+
// Guard: block new prompts while compaction is running. Compaction
|
|
175
|
+
// takes ~1 LLM call (<5 s) and the bridge emits a clear error message
|
|
176
|
+
// so the user knows to wait a moment.
|
|
177
|
+
if (stream.compacting) {
|
|
178
|
+
this.broadcast(stream, {
|
|
179
|
+
type: "error",
|
|
180
|
+
message: "Session is being compacted. Please wait a moment and try again.",
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
173
184
|
// If the bridge was disposed (e.g. by cancelTurn), recreate it before
|
|
174
185
|
// proceeding. We rebuild only the bridge inside the existing stream so
|
|
175
186
|
// subscribers, cwd, and other metadata are preserved.
|
|
@@ -457,6 +468,60 @@ export class SessionStreamManager {
|
|
|
457
468
|
stream.loopIterationCount = 0;
|
|
458
469
|
}
|
|
459
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* Fork & Compact: trigger compaction after the first assistant turn of a
|
|
473
|
+
* forked session. Uses pi's built-in `compact()` which generates a summary
|
|
474
|
+
* of older context, retaining the most recent ~20K tokens (including the
|
|
475
|
+
* user's new message + the assistant's response).
|
|
476
|
+
*
|
|
477
|
+
* Custom instructions reference the most recent user message so the LLM
|
|
478
|
+
* summary prioritizes information relevant to the current task.
|
|
479
|
+
*/
|
|
480
|
+
triggerForkCompact(stream) {
|
|
481
|
+
if (!stream.bridge.compact) {
|
|
482
|
+
console.warn(`[spectral] warn: bridge does not support compact ("Fork & Compact" skipped for ${stream.sessionId})`);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
stream.compacting = true;
|
|
486
|
+
// Read the most recent user message to use as guidance for the
|
|
487
|
+
// compaction summary.
|
|
488
|
+
const detail = this.store.getSession(stream.sessionId);
|
|
489
|
+
const lastUserMsg = detail?.messages
|
|
490
|
+
.filter((m) => m.role === "user")
|
|
491
|
+
.pop();
|
|
492
|
+
const guidanceMessage = lastUserMsg?.content?.trim() || "";
|
|
493
|
+
const customInstructions = guidanceMessage
|
|
494
|
+
? `Focus the context summary on information relevant to: "${guidanceMessage}"`
|
|
495
|
+
: undefined;
|
|
496
|
+
void stream.bridge
|
|
497
|
+
.compact(customInstructions)
|
|
498
|
+
.then(() => {
|
|
499
|
+
stream.compacting = false;
|
|
500
|
+
try {
|
|
501
|
+
this.store.clearForkCompactSource(stream.sessionId);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// best-effort
|
|
505
|
+
}
|
|
506
|
+
console.log(`[spectral] fork-compact completed for ${stream.sessionId}`);
|
|
507
|
+
})
|
|
508
|
+
.catch((err) => {
|
|
509
|
+
stream.compacting = false;
|
|
510
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
511
|
+
console.error(`[spectral] fork-compact failed for ${stream.sessionId}: ${msg}`);
|
|
512
|
+
this.broadcast(stream, {
|
|
513
|
+
type: "error",
|
|
514
|
+
message: `Context compaction failed: ${msg}`,
|
|
515
|
+
});
|
|
516
|
+
// Clear the flag even on failure so it doesn't block forever.
|
|
517
|
+
try {
|
|
518
|
+
this.store.clearForkCompactSource(stream.sessionId);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// best-effort
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
}
|
|
460
525
|
// --- internals ----------------------------------------------------------
|
|
461
526
|
createStream(sessionId, history) {
|
|
462
527
|
// Resolve cwd from the owning project. Sessions without a project
|
|
@@ -472,6 +537,7 @@ export class SessionStreamManager {
|
|
|
472
537
|
}
|
|
473
538
|
// Forward declaration so the bridge factory's emit callback can refer to
|
|
474
539
|
// the stream object that's still being assembled.
|
|
540
|
+
const forkSourceId = this.store.getForkCompactSource(sessionId);
|
|
475
541
|
const stream = {
|
|
476
542
|
sessionId,
|
|
477
543
|
cwd,
|
|
@@ -485,6 +551,8 @@ export class SessionStreamManager {
|
|
|
485
551
|
loopActive: false,
|
|
486
552
|
loopIterationCount: 0,
|
|
487
553
|
loopOriginalPrompt: null,
|
|
554
|
+
forkCompactSourceId: forkSourceId ?? null,
|
|
555
|
+
compacting: false,
|
|
488
556
|
};
|
|
489
557
|
const bridgeOpts = {
|
|
490
558
|
cwd,
|
|
@@ -637,6 +705,14 @@ export class SessionStreamManager {
|
|
|
637
705
|
});
|
|
638
706
|
}
|
|
639
707
|
}
|
|
708
|
+
// Fork & Compact: after the first assistant turn of a forked session,
|
|
709
|
+
// compact the context. The custom instructions reference the user's
|
|
710
|
+
// new message so the summary preserves relevant context.
|
|
711
|
+
if (stream.forkCompactSourceId && !stream.compacting) {
|
|
712
|
+
const sourceId = stream.forkCompactSourceId;
|
|
713
|
+
stream.forkCompactSourceId = null; // one-shot
|
|
714
|
+
this.triggerForkCompact(stream);
|
|
715
|
+
}
|
|
640
716
|
}
|
|
641
717
|
else if (event.type === "error") {
|
|
642
718
|
// An error event arriving outside a turn (or bubbling out of one) —
|
package/dist/server/storage.js
CHANGED
|
@@ -154,6 +154,9 @@ export class SessionStore {
|
|
|
154
154
|
// Phase 3: per-session sticky model.
|
|
155
155
|
stmtGetSessionModel;
|
|
156
156
|
stmtSetSessionModel;
|
|
157
|
+
// Fork & Compact: flag + per-session source tracking.
|
|
158
|
+
stmtSetForkCompactSource;
|
|
159
|
+
stmtGetForkCompactSource;
|
|
157
160
|
constructor(path) {
|
|
158
161
|
this.path = path;
|
|
159
162
|
// Make sure the parent directory exists. mkdirSync with recursive is a
|
|
@@ -208,6 +211,12 @@ export class SessionStore {
|
|
|
208
211
|
if (!msgCols.some((c) => c.name === "images_json")) {
|
|
209
212
|
this.db.exec(`ALTER TABLE messages ADD COLUMN images_json TEXT NOT NULL DEFAULT ''`);
|
|
210
213
|
}
|
|
214
|
+
// Fork & Compact: fork_compact_source_id tracks which session a fork was
|
|
215
|
+
// created from. Set during forkSession(), cleared after first compaction.
|
|
216
|
+
// NULL means no fork-compact pending (normal session).
|
|
217
|
+
if (!sessionCols.some((c) => c.name === "fork_compact_source_id")) {
|
|
218
|
+
this.db.exec(`ALTER TABLE sessions ADD COLUMN fork_compact_source_id TEXT`);
|
|
219
|
+
}
|
|
211
220
|
// ---- one-time cleanup -------------------------------------------------
|
|
212
221
|
// Historical garbage from prior runs of the bridge that persisted every
|
|
213
222
|
// intermediate `message_end` pi emitted, including pure-framing rows
|
|
@@ -277,6 +286,8 @@ export class SessionStore {
|
|
|
277
286
|
`);
|
|
278
287
|
this.stmtGetSessionModel = this.db.prepare(`SELECT model_id FROM sessions WHERE id = ?`);
|
|
279
288
|
this.stmtSetSessionModel = this.db.prepare(`UPDATE sessions SET model_id = ? WHERE id = ?`);
|
|
289
|
+
this.stmtSetForkCompactSource = this.db.prepare(`UPDATE sessions SET fork_compact_source_id = ? WHERE id = ?`);
|
|
290
|
+
this.stmtGetForkCompactSource = this.db.prepare(`SELECT fork_compact_source_id FROM sessions WHERE id = ?`);
|
|
280
291
|
}
|
|
281
292
|
/** Smoke check: returns the names of the tables in the DB. */
|
|
282
293
|
listTables() {
|
|
@@ -406,7 +417,7 @@ export class SessionStore {
|
|
|
406
417
|
if (!row)
|
|
407
418
|
return null;
|
|
408
419
|
const messages = this.getMessages(id);
|
|
409
|
-
|
|
420
|
+
const detail = {
|
|
410
421
|
id: row.id,
|
|
411
422
|
projectId: row.project_id,
|
|
412
423
|
title: row.title,
|
|
@@ -414,6 +425,12 @@ export class SessionStore {
|
|
|
414
425
|
updatedAt: row.updated_at,
|
|
415
426
|
messages,
|
|
416
427
|
};
|
|
428
|
+
// Expose the fork-compact flag so the UI can show a special send
|
|
429
|
+
// button for the first message of a forked session.
|
|
430
|
+
if (this.getForkCompactSource(id)) {
|
|
431
|
+
detail.forkCompactPending = true;
|
|
432
|
+
}
|
|
433
|
+
return detail;
|
|
417
434
|
}
|
|
418
435
|
/** Convenience for routes/managers that just need the projectId. */
|
|
419
436
|
getSessionProjectId(id) {
|
|
@@ -518,6 +535,68 @@ export class SessionStore {
|
|
|
518
535
|
setSessionModel(sessionId, modelId) {
|
|
519
536
|
this.stmtSetSessionModel.run(modelId, sessionId);
|
|
520
537
|
}
|
|
538
|
+
// ----------------------------------------------------------------------
|
|
539
|
+
// Fork & Compact
|
|
540
|
+
// ----------------------------------------------------------------------
|
|
541
|
+
/**
|
|
542
|
+
* Fork a session: create a new session in the same project, copy all
|
|
543
|
+
* messages from the source, and set the `fork_compact_source_id` flag
|
|
544
|
+
* so SessionStreamManager compacts the context after the first assistant
|
|
545
|
+
* turn completes.
|
|
546
|
+
*/
|
|
547
|
+
forkSession(sourceId, opts) {
|
|
548
|
+
const source = this.stmtGetSession.get(sourceId);
|
|
549
|
+
if (!source)
|
|
550
|
+
throw new Error(`Unknown sessionId: ${sourceId}`);
|
|
551
|
+
// Capture project details before the transaction (needed for the touch).
|
|
552
|
+
const project = this.stmtGetProject.get(source.project_id);
|
|
553
|
+
if (!project)
|
|
554
|
+
throw new Error(`Unknown projectId: ${source.project_id}`);
|
|
555
|
+
const newId = opts?.newSessionId ?? randomUUID();
|
|
556
|
+
const title = opts?.title?.trim() || `${source.title} (fork)`;
|
|
557
|
+
const now = Date.now();
|
|
558
|
+
let messageCount = 0;
|
|
559
|
+
const tx = this.db.transaction(() => {
|
|
560
|
+
// Create the new session row.
|
|
561
|
+
this.stmtCreateSession.run(newId, source.project_id, title, now, now);
|
|
562
|
+
this.stmtSetSessionModel.run(null, newId);
|
|
563
|
+
// Copy every message, preserving created_at ordering.
|
|
564
|
+
const messages = this.stmtListMessages.all(sourceId);
|
|
565
|
+
messageCount = messages.length;
|
|
566
|
+
for (const m of messages) {
|
|
567
|
+
this.stmtAppendMessage.run(randomUUID(), newId, m.role, m.content, m.events_jsonl, m.images_json, m.created_at);
|
|
568
|
+
}
|
|
569
|
+
// Set the fork-compact flag — SessionStreamManager reads this to
|
|
570
|
+
// trigger compaction after the first assistant turn.
|
|
571
|
+
this.stmtSetForkCompactSource.run(sourceId, newId);
|
|
572
|
+
// Touch the project so the new session appears at the top.
|
|
573
|
+
this.stmtUpdateProject.run(project.name, project.path, now, project.id);
|
|
574
|
+
});
|
|
575
|
+
tx();
|
|
576
|
+
return {
|
|
577
|
+
id: newId,
|
|
578
|
+
projectId: source.project_id,
|
|
579
|
+
title,
|
|
580
|
+
createdAt: now,
|
|
581
|
+
updatedAt: now,
|
|
582
|
+
messageCount,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Read the fork-compact source id for a session, or null if the session
|
|
587
|
+
* was not forked or has already been compacted.
|
|
588
|
+
*/
|
|
589
|
+
getForkCompactSource(sessionId) {
|
|
590
|
+
const row = this.stmtGetForkCompactSource.get(sessionId);
|
|
591
|
+
return row?.fork_compact_source_id ?? null;
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Clear the fork-compact flag after compaction completes (or fails).
|
|
595
|
+
* Idempotent-safe.
|
|
596
|
+
*/
|
|
597
|
+
clearForkCompactSource(sessionId) {
|
|
598
|
+
this.stmtSetForkCompactSource.run(null, sessionId);
|
|
599
|
+
}
|
|
521
600
|
close() {
|
|
522
601
|
if (this.closed)
|
|
523
602
|
return;
|