@aexol/spectral 0.4.11 → 0.4.12

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.
@@ -47,8 +47,6 @@ const MAX_LOOP_ITERATIONS = 100;
47
47
  * Aligned with the observational memory extension default (memory/config.ts).
48
48
  */
49
49
  const LOOP_COMPACTION_THRESHOLD_TOKENS = 50_000;
50
- /** Maximum time (ms) to wait for compaction to finish between loop iterations. */
51
- const LOOP_COMPACTION_MAX_WAIT_MS = 30_000;
52
50
  /**
53
51
  * Number of accumulated wire events before flushing the in-flight turn
54
52
  * to SQLite. Batch-persisting means a server crash mid-turn only loses
@@ -546,42 +544,44 @@ export class SessionStreamManager {
546
544
  * A duplicate call from the extension's delayed compaction trigger is
547
545
  * harmless — pi throws "Already compacted" which the extension catches.
548
546
  */
549
- async sendNextLoopIteration(stream) {
550
- // The observational-memory extension's compaction-trigger fires on
551
- // agent_end (via setTimeout, so it's slightly delayed). We wait for
552
- // either:
553
- // (a) compaction to start and finish (stream.compacting becomes
554
- // true then false), or
555
- // (b) a grace period to elapse without compaction starting (context
556
- // was below the threshold, or the extension deferred).
557
- //
558
- // This guarantees the LLM sees the compacted context when the
559
- // compaction threshold was reached, and doesn't delay the loop when
560
- // no compaction is needed.
561
- const GRACE_MS = 500; // give the trigger's setTimeout a chance
562
- const start = Date.now();
563
- // Wait for the trigger to start compaction, or for grace period to pass.
564
- while (!stream.compacting && Date.now() - start < GRACE_MS) {
565
- await new Promise((r) => setTimeout(r, 50));
566
- }
567
- if (stream.compacting) {
568
- console.log("[loop] compaction in progress, waiting...");
569
- await this.waitForCompactionOrTimeout(stream);
570
- console.log("[loop] compaction finished, sending next iteration");
571
- }
572
- await this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined);
573
- }
574
547
  /**
575
- * Poll until stream.compacting becomes false or the timeout elapses.
548
+ * Send the next prompt for an autonomous loop iteration, compacting first
549
+ * if the context window exceeds the threshold.
550
+ *
551
+ * Instead of polling for the extension's delayed compaction-trigger (which
552
+ * fires via setTimeout), we proactively call bridge.compact() directly.
553
+ * bridge.compact() → session.compact() → fires session_before_compact
554
+ * (where the extension's compaction-hook provides the observational-memory
555
+ * summary), then appends the compaction entry, reloads the compacted
556
+ * context into agent.state.messages, and emits compaction_start/end.
557
+ *
558
+ * The extension's trigger still fires (via setTimeout), but by the time
559
+ * its callback runs, bridge.compact() has already appended the compaction
560
+ * entry → prepareCompaction() returns undefined → "Already compacted"
561
+ * → the trigger's onError handler catches it harmlessly.
576
562
  */
577
- async waitForCompactionOrTimeout(stream) {
578
- const start = Date.now();
579
- while (stream.compacting && Date.now() - start < LOOP_COMPACTION_MAX_WAIT_MS) {
580
- await new Promise((r) => setTimeout(r, 200));
581
- }
582
- if (stream.compacting) {
583
- console.warn(`[loop] compaction still in-flight after ${LOOP_COMPACTION_MAX_WAIT_MS}ms, proceeding anyway`);
563
+ async sendNextLoopIteration(stream) {
564
+ const shouldCompact = stream.bridge.compact &&
565
+ typeof stream.contextWindowUsed === "number" &&
566
+ stream.contextWindowUsed > LOOP_COMPACTION_THRESHOLD_TOKENS;
567
+ if (shouldCompact) {
568
+ try {
569
+ console.log(`[loop] compacting context (~${stream.contextWindowUsed.toLocaleString()} tokens > ${LOOP_COMPACTION_THRESHOLD_TOKENS.toLocaleString()} threshold)`);
570
+ await stream.bridge.compact();
571
+ console.log("[loop] compaction complete, sending next iteration");
572
+ }
573
+ catch (err) {
574
+ const msg = err instanceof Error ? err.message : String(err);
575
+ if (msg === "Already compacted" ||
576
+ msg === "Nothing to compact (session too small)") {
577
+ console.log("[loop] compaction already done or not needed, proceeding");
578
+ }
579
+ else {
580
+ console.error(`[loop] compaction failed: ${msg}`);
581
+ }
582
+ }
584
583
  }
584
+ await this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined);
585
585
  }
586
586
  // --- internals ----------------------------------------------------------
587
587
  createStream(sessionId, history) {
@@ -614,7 +614,6 @@ export class SessionStreamManager {
614
614
  loopOriginalPrompt: null,
615
615
  forkCompactSourceId: forkSourceId ?? null,
616
616
  compacting: false,
617
- compactionEndResolve: null,
618
617
  contextWindowUsed: null,
619
618
  contextWindowMax: null,
620
619
  };
@@ -763,17 +762,13 @@ export class SessionStreamManager {
763
762
  if (event.contextWindowMax != null)
764
763
  stream.contextWindowMax = event.contextWindowMax;
765
764
  }
766
- // Track compaction lifecycle so the loop (and prompt() guard) can wait
767
- // for it to complete before starting the next iteration.
765
+ // Track compaction lifecycle so prompt() can block new messages while
766
+ // compaction is running.
768
767
  if (event.type === "compaction_start") {
769
768
  stream.compacting = true;
770
769
  }
771
770
  if (event.type === "compaction_end") {
772
771
  stream.compacting = false;
773
- if (stream.compactionEndResolve) {
774
- stream.compactionEndResolve();
775
- stream.compactionEndResolve = null;
776
- }
777
772
  }
778
773
  this.broadcast(stream, event);
779
774
  if (event.type === "agent_end") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
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,