@aexol/spectral 0.3.5 → 0.3.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
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);
|
|
@@ -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
|
}
|
|
@@ -170,6 +170,16 @@ export class SessionStreamManager {
|
|
|
170
170
|
let stream = this.streams.get(sessionId);
|
|
171
171
|
if (!stream)
|
|
172
172
|
throw new Error(`No active stream for session: ${sessionId}`);
|
|
173
|
+
// Guard: block new prompts while compaction is running. Compaction
|
|
174
|
+
// takes ~1 LLM call (<5 s) and the bridge emits a clear error message
|
|
175
|
+
// so the user knows to wait a moment.
|
|
176
|
+
if (stream.compacting) {
|
|
177
|
+
this.broadcast(stream, {
|
|
178
|
+
type: "error",
|
|
179
|
+
message: "Session is being compacted. Please wait a moment and try again.",
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
173
183
|
// If the bridge was disposed (e.g. by cancelTurn), recreate it before
|
|
174
184
|
// proceeding. We rebuild only the bridge inside the existing stream so
|
|
175
185
|
// subscribers, cwd, and other metadata are preserved.
|
|
@@ -457,6 +467,60 @@ export class SessionStreamManager {
|
|
|
457
467
|
stream.loopIterationCount = 0;
|
|
458
468
|
}
|
|
459
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* Fork & Compact: trigger compaction after the first assistant turn of a
|
|
472
|
+
* forked session. Uses pi's built-in `compact()` which generates a summary
|
|
473
|
+
* of older context, retaining the most recent ~20K tokens (including the
|
|
474
|
+
* user's new message + the assistant's response).
|
|
475
|
+
*
|
|
476
|
+
* Custom instructions reference the most recent user message so the LLM
|
|
477
|
+
* summary prioritizes information relevant to the current task.
|
|
478
|
+
*/
|
|
479
|
+
triggerForkCompact(stream) {
|
|
480
|
+
if (!stream.bridge.compact) {
|
|
481
|
+
console.warn(`[spectral] warn: bridge does not support compact ("Fork & Compact" skipped for ${stream.sessionId})`);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
stream.compacting = true;
|
|
485
|
+
// Read the most recent user message to use as guidance for the
|
|
486
|
+
// compaction summary.
|
|
487
|
+
const detail = this.store.getSession(stream.sessionId);
|
|
488
|
+
const lastUserMsg = detail?.messages
|
|
489
|
+
.filter((m) => m.role === "user")
|
|
490
|
+
.pop();
|
|
491
|
+
const guidanceMessage = lastUserMsg?.content?.trim() || "";
|
|
492
|
+
const customInstructions = guidanceMessage
|
|
493
|
+
? `Focus the context summary on information relevant to: "${guidanceMessage}"`
|
|
494
|
+
: undefined;
|
|
495
|
+
void stream.bridge
|
|
496
|
+
.compact(customInstructions)
|
|
497
|
+
.then(() => {
|
|
498
|
+
stream.compacting = false;
|
|
499
|
+
try {
|
|
500
|
+
this.store.clearForkCompactSource(stream.sessionId);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// best-effort
|
|
504
|
+
}
|
|
505
|
+
console.log(`[spectral] fork-compact completed for ${stream.sessionId}`);
|
|
506
|
+
})
|
|
507
|
+
.catch((err) => {
|
|
508
|
+
stream.compacting = false;
|
|
509
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
510
|
+
console.error(`[spectral] fork-compact failed for ${stream.sessionId}: ${msg}`);
|
|
511
|
+
this.broadcast(stream, {
|
|
512
|
+
type: "error",
|
|
513
|
+
message: `Context compaction failed: ${msg}`,
|
|
514
|
+
});
|
|
515
|
+
// Clear the flag even on failure so it doesn't block forever.
|
|
516
|
+
try {
|
|
517
|
+
this.store.clearForkCompactSource(stream.sessionId);
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
// best-effort
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
460
524
|
// --- internals ----------------------------------------------------------
|
|
461
525
|
createStream(sessionId, history) {
|
|
462
526
|
// Resolve cwd from the owning project. Sessions without a project
|
|
@@ -472,6 +536,7 @@ export class SessionStreamManager {
|
|
|
472
536
|
}
|
|
473
537
|
// Forward declaration so the bridge factory's emit callback can refer to
|
|
474
538
|
// the stream object that's still being assembled.
|
|
539
|
+
const forkSourceId = this.store.getForkCompactSource(sessionId);
|
|
475
540
|
const stream = {
|
|
476
541
|
sessionId,
|
|
477
542
|
cwd,
|
|
@@ -485,6 +550,8 @@ export class SessionStreamManager {
|
|
|
485
550
|
loopActive: false,
|
|
486
551
|
loopIterationCount: 0,
|
|
487
552
|
loopOriginalPrompt: null,
|
|
553
|
+
forkCompactSourceId: forkSourceId ?? null,
|
|
554
|
+
compacting: false,
|
|
488
555
|
};
|
|
489
556
|
const bridgeOpts = {
|
|
490
557
|
cwd,
|
|
@@ -637,6 +704,14 @@ export class SessionStreamManager {
|
|
|
637
704
|
});
|
|
638
705
|
}
|
|
639
706
|
}
|
|
707
|
+
// Fork & Compact: after the first assistant turn of a forked session,
|
|
708
|
+
// compact the context. The custom instructions reference the user's
|
|
709
|
+
// new message so the summary preserves relevant context.
|
|
710
|
+
if (stream.forkCompactSourceId && !stream.compacting) {
|
|
711
|
+
const sourceId = stream.forkCompactSourceId;
|
|
712
|
+
stream.forkCompactSourceId = null; // one-shot
|
|
713
|
+
this.triggerForkCompact(stream);
|
|
714
|
+
}
|
|
640
715
|
}
|
|
641
716
|
else if (event.type === "error") {
|
|
642
717
|
// 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() {
|
|
@@ -518,6 +529,68 @@ export class SessionStore {
|
|
|
518
529
|
setSessionModel(sessionId, modelId) {
|
|
519
530
|
this.stmtSetSessionModel.run(modelId, sessionId);
|
|
520
531
|
}
|
|
532
|
+
// ----------------------------------------------------------------------
|
|
533
|
+
// Fork & Compact
|
|
534
|
+
// ----------------------------------------------------------------------
|
|
535
|
+
/**
|
|
536
|
+
* Fork a session: create a new session in the same project, copy all
|
|
537
|
+
* messages from the source, and set the `fork_compact_source_id` flag
|
|
538
|
+
* so SessionStreamManager compacts the context after the first assistant
|
|
539
|
+
* turn completes.
|
|
540
|
+
*/
|
|
541
|
+
forkSession(sourceId, opts) {
|
|
542
|
+
const source = this.stmtGetSession.get(sourceId);
|
|
543
|
+
if (!source)
|
|
544
|
+
throw new Error(`Unknown sessionId: ${sourceId}`);
|
|
545
|
+
// Capture project details before the transaction (needed for the touch).
|
|
546
|
+
const project = this.stmtGetProject.get(source.project_id);
|
|
547
|
+
if (!project)
|
|
548
|
+
throw new Error(`Unknown projectId: ${source.project_id}`);
|
|
549
|
+
const newId = opts?.newSessionId ?? randomUUID();
|
|
550
|
+
const title = opts?.title?.trim() || `${source.title} (fork)`;
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
let messageCount = 0;
|
|
553
|
+
const tx = this.db.transaction(() => {
|
|
554
|
+
// Create the new session row.
|
|
555
|
+
this.stmtCreateSession.run(newId, source.project_id, title, now, now);
|
|
556
|
+
this.stmtSetSessionModel.run(null, newId);
|
|
557
|
+
// Copy every message, preserving created_at ordering.
|
|
558
|
+
const messages = this.stmtListMessages.all(sourceId);
|
|
559
|
+
messageCount = messages.length;
|
|
560
|
+
for (const m of messages) {
|
|
561
|
+
this.stmtAppendMessage.run(randomUUID(), newId, m.role, m.content, m.events_jsonl, m.images_json, m.created_at);
|
|
562
|
+
}
|
|
563
|
+
// Set the fork-compact flag — SessionStreamManager reads this to
|
|
564
|
+
// trigger compaction after the first assistant turn.
|
|
565
|
+
this.stmtSetForkCompactSource.run(sourceId, newId);
|
|
566
|
+
// Touch the project so the new session appears at the top.
|
|
567
|
+
this.stmtUpdateProject.run(project.name, project.path, now, project.id);
|
|
568
|
+
});
|
|
569
|
+
tx();
|
|
570
|
+
return {
|
|
571
|
+
id: newId,
|
|
572
|
+
projectId: source.project_id,
|
|
573
|
+
title,
|
|
574
|
+
createdAt: now,
|
|
575
|
+
updatedAt: now,
|
|
576
|
+
messageCount,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Read the fork-compact source id for a session, or null if the session
|
|
581
|
+
* was not forked or has already been compacted.
|
|
582
|
+
*/
|
|
583
|
+
getForkCompactSource(sessionId) {
|
|
584
|
+
const row = this.stmtGetForkCompactSource.get(sessionId);
|
|
585
|
+
return row?.fork_compact_source_id ?? null;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Clear the fork-compact flag after compaction completes (or fails).
|
|
589
|
+
* Idempotent-safe.
|
|
590
|
+
*/
|
|
591
|
+
clearForkCompactSource(sessionId) {
|
|
592
|
+
this.stmtSetForkCompactSource.run(null, sessionId);
|
|
593
|
+
}
|
|
521
594
|
close() {
|
|
522
595
|
if (this.closed)
|
|
523
596
|
return;
|