@aexol/spectral 0.3.4 → 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.
@@ -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
+ }
@@ -192,7 +192,10 @@ function supportsReasoning(modelId) {
192
192
  }
193
193
  /**
194
194
  * Calculate credits from token usage using per-model credit rates.
195
- * Mirrors backend billing logic for Agent UI telemetry.
195
+ *
196
+ * NOTE: We intentionally keep higher precision here (no 2-decimal rounding)
197
+ * so small turns don't collapse to 0.00 in live UI aggregation. Rendering
198
+ * layers decide final display precision.
196
199
  */
197
200
  function calculateCredits(inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, creditInputPer1M, creditOutputPer1M, creditCachedInputPer1M, creditCacheReadPer1M, creditCacheWritePer1M) {
198
201
  const inputRate = creditInputPer1M ?? 0;
@@ -206,11 +209,11 @@ function calculateCredits(inputTokens, outputTokens, cacheReadTokens = 0, cacheW
206
209
  (cacheWriteTokens / 1_000_000) * cacheWriteRate;
207
210
  if (inputRate === 0 && outputRate === 0 && cacheReadRate === 0 && cacheWriteRate === 0) {
208
211
  if (cachedInputRate > 0) {
209
- return Math.round(((inputTokens + cacheReadTokens + cacheWriteTokens) / 1_000_000) * cachedInputRate * 100) / 100;
212
+ return ((inputTokens + cacheReadTokens + cacheWriteTokens) / 1_000_000) * cachedInputRate;
210
213
  }
211
214
  return 0;
212
215
  }
213
- return Math.round(credits * 100) / 100;
216
+ return credits;
214
217
  }
215
218
  /**
216
219
  * Parse the newline-delimited JSON of wire events persisted alongside an
@@ -737,6 +740,20 @@ export class PiBridge {
737
740
  this.opts.emit({ type: "error", message: e.message });
738
741
  }
739
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
+ }
740
757
  dispose() {
741
758
  if (this.disposed)
742
759
  return;
@@ -972,11 +989,30 @@ export class PiBridge {
972
989
  this.opts.emit({ type: "agent_end" });
973
990
  return;
974
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
+ }
975
1011
  default:
976
- // Other pi-internal events (turn_start, queue_update, compaction_*,
977
- // auto_retry_*, tool_execution_update) are intentionally not on the
978
- // wire surface for MVP and are NOT persisted — the wire format is
979
- // 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.
980
1016
  return;
981
1017
  }
982
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) —
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
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,