@aexol/spectral 0.4.9 → 0.4.10

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.
@@ -42,6 +42,13 @@ import { generateSessionTitle, isDefaultTitle, } from "./title-generator.js";
42
42
  const DEFAULT_BRIDGE_FACTORY = (args) => new PiBridge(args);
43
43
  /** Safety limit for autonomous loop iterations per session. */
44
44
  const MAX_LOOP_ITERATIONS = 100;
45
+ /**
46
+ * Token threshold above which the loop triggers compaction between iterations.
47
+ * Aligned with the observational memory extension default (memory/config.ts).
48
+ */
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;
45
52
  /**
46
53
  * Number of accumulated wire events before flushing the in-flight turn
47
54
  * to SQLite. Batch-persisting means a server crash mid-turn only loses
@@ -524,6 +531,58 @@ export class SessionStreamManager {
524
531
  }
525
532
  });
526
533
  }
534
+ /**
535
+ * Send the next prompt for an autonomous loop iteration, waiting for any
536
+ * in-flight observational-memory compaction to finish first so the LLM
537
+ * sees the compacted context.
538
+ *
539
+ * The compaction-trigger extension fires on agent_end (via setTimeout),
540
+ * so we poll stream.compacting briefly. If compaction hasn't started after
541
+ * a short grace period, we check whether the context window exceeds the
542
+ * threshold and proactively trigger compaction via the bridge (which
543
+ * invokes pi's full pipeline, including the session_before_compact hook
544
+ * where the observational memory extension provides its summary).
545
+ *
546
+ * A duplicate call from the extension's delayed compaction trigger is
547
+ * harmless — pi throws "Already compacted" which the extension catches.
548
+ */
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
+ /**
575
+ * Poll until stream.compacting becomes false or the timeout elapses.
576
+ */
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`);
584
+ }
585
+ }
527
586
  // --- internals ----------------------------------------------------------
528
587
  createStream(sessionId, history) {
529
588
  // Resolve cwd from the owning project. Sessions without a project
@@ -555,6 +614,7 @@ export class SessionStreamManager {
555
614
  loopOriginalPrompt: null,
556
615
  forkCompactSourceId: forkSourceId ?? null,
557
616
  compacting: false,
617
+ compactionEndResolve: null,
558
618
  contextWindowUsed: null,
559
619
  contextWindowMax: null,
560
620
  };
@@ -703,6 +763,18 @@ export class SessionStreamManager {
703
763
  if (event.contextWindowMax != null)
704
764
  stream.contextWindowMax = event.contextWindowMax;
705
765
  }
766
+ // Track compaction lifecycle so the loop (and prompt() guard) can wait
767
+ // for it to complete before starting the next iteration.
768
+ if (event.type === "compaction_start") {
769
+ stream.compacting = true;
770
+ }
771
+ if (event.type === "compaction_end") {
772
+ stream.compacting = false;
773
+ if (stream.compactionEndResolve) {
774
+ stream.compactionEndResolve();
775
+ stream.compactionEndResolve = null;
776
+ }
777
+ }
706
778
  this.broadcast(stream, event);
707
779
  if (event.type === "agent_end") {
708
780
  // Final flush + clear batch-persist tracking. `onAssistantMessageComplete`
@@ -753,7 +825,7 @@ export class SessionStreamManager {
753
825
  maxIterations: MAX_LOOP_ITERATIONS,
754
826
  prompt: stream.loopOriginalPrompt,
755
827
  });
756
- void this.prompt(stream.sessionId, stream.loopOriginalPrompt, undefined).catch((err) => {
828
+ void this.sendNextLoopIteration(stream).catch((err) => {
757
829
  console.error(`[loop] iteration failed: ${err instanceof Error ? err.message : String(err)}`);
758
830
  stream.loopActive = false;
759
831
  stream.loopOriginalPrompt = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
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,