@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.
@@ -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
+ }
@@ -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, compaction_*,
980
- // auto_retry_*, tool_execution_update) are intentionally not on the
981
- // wire surface for MVP and are NOT persisted — the wire format is
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) —
@@ -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
- return {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,