@aexol/spectral 0.4.3 → 0.4.8

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/cli.js CHANGED
@@ -75,21 +75,42 @@ function resolvePiBin() {
75
75
  }
76
76
  return resolve(dirname(piPkgJsonPath), binRel);
77
77
  }
78
- /** Absolute path to the bundled aexol-mcp extension, sitting next to this file in dist/. */
78
+ /**
79
+ * Resolve an extension entry-point path, falling back from .js to .ts.
80
+ *
81
+ * In production __dirname is dist/ and compiled .js files exist. In dev
82
+ * (tsx src/cli.ts) __dirname is src/ and only .ts sources are present.
83
+ * pi's extension loader uses jiti which handles .ts transparently.
84
+ */
85
+ function resolveExtensionEntry(relativePath, fileName) {
86
+ const dir = resolve(__dirname, relativePath);
87
+ const jsPath = resolve(dir, fileName + ".js");
88
+ if (existsSync(jsPath))
89
+ return jsPath;
90
+ const tsPath = resolve(dir, fileName + ".ts");
91
+ if (existsSync(tsPath))
92
+ return tsPath;
93
+ return jsPath; // let the missing-file check produce a clear error
94
+ }
95
+ /** Absolute path to the bundled aexol-mcp extension. */
79
96
  function resolveAexolExtensionPath() {
80
- return resolve(__dirname, "extensions", "aexol-mcp.js");
97
+ return resolveExtensionEntry("extensions", "aexol-mcp");
81
98
  }
82
- /** Absolute path to the bundled observational-memory extension, sitting next to this file in dist/. */
99
+ /** Absolute path to the bundled observations-memory extension. */
83
100
  function resolveObservationalMemoryPath() {
84
- return resolve(__dirname, "memory", "index.js");
101
+ return resolveExtensionEntry("memory", "index");
85
102
  }
86
- /** Absolute path to the bundled pi-mcp-adapter extension, sitting next to this file in dist/. */
103
+ /** Absolute path to the bundled pi-mcp-adapter extension. */
87
104
  function resolveMcpExtensionPath() {
88
- return resolve(__dirname, "mcp", "index.js");
105
+ return resolveExtensionEntry("mcp", "index");
89
106
  }
90
- /** Absolute path to the bundled openrouter-attribution extension, sitting next to this file in dist/. */
107
+ /** Absolute path to the bundled openrouter-attribution extension. */
91
108
  function resolveOpenRouterAttributionPath() {
92
- return resolve(__dirname, "extensions", "openrouter-attribution.js");
109
+ return resolveExtensionEntry("extensions", "openrouter-attribution");
110
+ }
111
+ /** Absolute path to the bundled agent subagent extension. */
112
+ function resolveAgentExtensionPath() {
113
+ return resolveExtensionEntry("agent", "index");
93
114
  }
94
115
  // ---- Branded helpers ---------------------------------------------------------
95
116
  function printVersion() {
@@ -228,7 +249,19 @@ async function main() {
228
249
  process.stderr.write(`spectral: bundled OpenRouter attribution extension not found at ${openrouterAttrPath}. This is a packaging bug.\n`);
229
250
  process.exit(1);
230
251
  }
231
- const extFlags = ["--extension", aexolExtPath, "--extension", mcpExtPath, "--extension", obsMemPath, "--extension", openrouterAttrPath];
252
+ // Bundled agent delegation extension for subagent task delegation
253
+ const agentExtPath = resolveAgentExtensionPath();
254
+ if (!existsSync(agentExtPath)) {
255
+ process.stderr.write(`spectral: bundled agent extension not found at ${agentExtPath}. This is a packaging bug.\n`);
256
+ process.exit(1);
257
+ }
258
+ const extFlags = [
259
+ "--extension", aexolExtPath,
260
+ "--extension", mcpExtPath,
261
+ "--extension", obsMemPath,
262
+ "--extension", openrouterAttrPath,
263
+ "--extension", agentExtPath,
264
+ ];
232
265
  const finalArgs = [...extFlags, ...args];
233
266
  delegateToPi(finalArgs);
234
267
  }
@@ -7,15 +7,23 @@ import { estimateStringTokens } from "../tokens.js";
7
7
  import { OBSERVATION_CUSTOM_TYPE, reflectionToPromptLine } from "../types.js";
8
8
  export function registerObserverTrigger(pi, runtime) {
9
9
  pi.on("turn_end", (_event, ctx) => {
10
+ process.stderr.write("[obs-mem] turn_end fired\n");
10
11
  runtime.ensureConfig(ctx.cwd);
11
- if (runtime.config.passive === true)
12
+ if (runtime.config.passive === true) {
13
+ process.stderr.write("[obs-mem] passive=true → skipping\n");
12
14
  return;
13
- if (runtime.observerInFlight)
15
+ }
16
+ if (runtime.observerInFlight) {
17
+ process.stderr.write("[obs-mem] observer already in flight → skipping\n");
14
18
  return;
19
+ }
15
20
  const entries = ctx.sessionManager.getBranch();
16
21
  const tokens = rawTokensSinceLastBound(entries);
17
- if (tokens < runtime.config.observationThresholdTokens)
22
+ process.stderr.write(`[obs-mem] tokens since last bound: ${tokens} (threshold: ${runtime.config.observationThresholdTokens})\n`);
23
+ if (tokens < runtime.config.observationThresholdTokens) {
24
+ process.stderr.write("[obs-mem] below threshold → skipping observer\n");
18
25
  return;
26
+ }
19
27
  const lastBoundIdx = lastObservationCoverEndIdx(entries);
20
28
  const coversFromId = firstRawIdAfter(entries, lastBoundIdx);
21
29
  if (!coversFromId)
@@ -7,6 +7,8 @@ import { Runtime } from "./runtime.js";
7
7
  import { registerRecallTool } from "./tools/recall-observation.js";
8
8
  export default function observationalMemory(pi) {
9
9
  const runtime = new Runtime();
10
+ // Log extension load so we can confirm it's running in serve mode.
11
+ process.stderr.write("[obs-mem] extension loaded\n");
10
12
  registerObserverTrigger(pi, runtime);
11
13
  registerCompactionTrigger(pi, runtime);
12
14
  registerCompactionHook(pi, runtime);
@@ -489,6 +489,8 @@ export function handleClientMessage(frame, deps) {
489
489
  history: attachResult.history,
490
490
  currentTurn: attachResult.currentTurn,
491
491
  forkCompactPending: attachResult.forkCompactPending || undefined,
492
+ contextWindowUsed: attachResult.contextWindowUsed,
493
+ contextWindowMax: attachResult.contextWindowMax,
492
494
  },
493
495
  });
494
496
  // Surface bridge-start failures as `error` events; otherwise the
@@ -561,6 +563,8 @@ export function handleSubscribe(frame, deps) {
561
563
  history: attachResult.history,
562
564
  currentTurn: attachResult.currentTurn,
563
565
  forkCompactPending: attachResult.forkCompactPending || undefined,
566
+ contextWindowUsed: attachResult.contextWindowUsed,
567
+ contextWindowMax: attachResult.contextWindowMax,
564
568
  },
565
569
  });
566
570
  if (isNewSubscriber) {
@@ -28,7 +28,7 @@ const cache = new Map();
28
28
  export function clearAllowedModelsCache() {
29
29
  cache.clear();
30
30
  }
31
- const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M } }`;
31
+ const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow } }`;
32
32
  /**
33
33
  * Fetch the whitelist of allowed base models. Throws on any failure with a
34
34
  * message tailored for an operator running `spectral serve` — the caller
@@ -98,6 +98,9 @@ export async function fetchAllowedModels(opts) {
98
98
  if (!name || !provider)
99
99
  continue; // skip malformed rows defensively
100
100
  const asOptionalNumber = (v) => typeof v === "number" ? v : v == null ? null : undefined;
101
+ const contextWindow = typeof row?.contextWindow === "number" && row.contextWindow > 0
102
+ ? row.contextWindow
103
+ : null;
101
104
  const model = {
102
105
  modelId: name,
103
106
  displayName: name,
@@ -107,6 +110,7 @@ export async function fetchAllowedModels(opts) {
107
110
  creditCachedInputPer1M: asOptionalNumber(row?.creditCachedInputPer1M),
108
111
  creditCacheReadPer1M: asOptionalNumber(row?.creditCacheReadPer1M),
109
112
  creditCacheWritePer1M: asOptionalNumber(row?.creditCacheWritePer1M),
113
+ contextWindow,
110
114
  };
111
115
  if (typeof row?.userModelId === "string") {
112
116
  model.userModelId = row.userModelId;
@@ -55,6 +55,7 @@ import { existsSync } from "node:fs";
55
55
  import { dirname, resolve } from "node:path";
56
56
  import { fileURLToPath } from "node:url";
57
57
  import aexolMcpExtension from "../extensions/aexol-mcp.js";
58
+ import observationalMemory from "../memory/index.js";
58
59
  import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/models-fetch.js";
59
60
  /**
60
61
  * Synthetic provider names registered with pi's `ModelRegistry`. They route
@@ -331,7 +332,7 @@ export class PiBridge {
331
332
  async start() {
332
333
  if (this.disposed)
333
334
  throw new Error("PiBridge already disposed");
334
- const extensionFactories = [aexolMcpExtension];
335
+ const extensionFactories = [aexolMcpExtension, async (pi) => { observationalMemory(pi); }];
335
336
  // Load pi-mcp-adapter via jiti so tsc never crawls its .ts files in
336
337
  // node_modules. The static `import` was causing tsc to type-check
337
338
  // pi-mcp-adapter's source and fail the build on its type errors.
@@ -570,7 +571,7 @@ export class PiBridge {
570
571
  cost: pricing
571
572
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
572
573
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
573
- contextWindow: 0,
574
+ contextWindow: m.contextWindow ?? 0,
574
575
  maxTokens: 0,
575
576
  };
576
577
  }),
@@ -600,7 +601,7 @@ export class PiBridge {
600
601
  cost: pricing
601
602
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
602
603
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
603
- contextWindow: 0,
604
+ contextWindow: m.contextWindow ?? 0,
604
605
  maxTokens: 0,
605
606
  };
606
607
  }),
@@ -630,7 +631,7 @@ export class PiBridge {
630
631
  cost: pricing
631
632
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
632
633
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
633
- contextWindow: 0,
634
+ contextWindow: m.contextWindow ?? 0,
634
635
  maxTokens: 0,
635
636
  };
636
637
  }),
@@ -964,6 +965,10 @@ export class PiBridge {
964
965
  (usage?.cacheRead ?? 0) +
965
966
  (usage?.cacheWrite ?? 0);
966
967
  if (usage && totalTokens > 0) {
968
+ // Resolve current context window usage from pi's built-in
969
+ // `getContextUsage()`, which handles post-compaction ambiguity
970
+ // and estimates total tokens from the full session tree.
971
+ const ctxUsage = this.session?.getContextUsage();
967
972
  const usageEvent = {
968
973
  type: "token_usage",
969
974
  messageId,
@@ -976,6 +981,8 @@ export class PiBridge {
976
981
  cost: usage.cost?.total ?? null,
977
982
  creditsUsed: calculateCredits(usage.input ?? 0, usage.output ?? 0, usage.cacheRead ?? 0, usage.cacheWrite ?? 0, this.activeCreditRates?.creditInputPer1M, this.activeCreditRates?.creditOutputPer1M, this.activeCreditRates?.creditCachedInputPer1M, this.activeCreditRates?.creditCacheReadPer1M, this.activeCreditRates?.creditCacheWritePer1M),
978
983
  },
984
+ contextWindowUsed: ctxUsage?.tokens ?? null,
985
+ contextWindowMax: ctxUsage?.contextWindow ?? null,
979
986
  };
980
987
  this.pending.wireEvents.push(usageEvent);
981
988
  this.opts.emit(usageEvent);
@@ -1040,11 +1047,12 @@ function detectMemorySystem(message) {
1040
1047
  return "memory_observer";
1041
1048
  if (lower.includes("compaction") || lower.includes("compact"))
1042
1049
  return "memory_compaction";
1043
- if (lower.includes("reflection"))
1050
+ if (lower.includes("reflection") || lower.includes("reflect"))
1044
1051
  return "memory_reflection";
1045
1052
  if (lower.includes("pruner") || lower.includes("prune"))
1046
1053
  return "memory_pruner";
1047
- return "extension";
1054
+ // Keep generic observational-memory messages visible in the landing badge.
1055
+ return "memory_observer";
1048
1056
  }
1049
1057
  /**
1050
1058
  * Create a minimal ExtensionUIContext that forwards `notify()` calls as
@@ -1061,11 +1069,16 @@ function createHeadlessUIContext(emit) {
1061
1069
  get(_target, prop) {
1062
1070
  if (prop === "notify") {
1063
1071
  return (message, type) => {
1072
+ const level = type ?? "info";
1073
+ const system = detectMemorySystem(message);
1074
+ if (system?.startsWith("memory_")) {
1075
+ console.info(`[PiBridge][memory][${level}] ${message}`);
1076
+ }
1064
1077
  emit({
1065
1078
  type: "agent_notification",
1066
1079
  message,
1067
- level: type ?? "info",
1068
- system: detectMemorySystem(message),
1080
+ level,
1081
+ system,
1069
1082
  });
1070
1083
  };
1071
1084
  }
@@ -106,6 +106,8 @@ export class SessionStreamManager {
106
106
  currentTurn: stream.currentTurn ? snapshotTurn(stream.currentTurn) : null,
107
107
  ready: stream.ready,
108
108
  forkCompactPending: stream.forkCompactSourceId != null,
109
+ contextWindowUsed: stream.contextWindowUsed,
110
+ contextWindowMax: stream.contextWindowMax,
109
111
  };
110
112
  }
111
113
  /**
@@ -553,6 +555,8 @@ export class SessionStreamManager {
553
555
  loopOriginalPrompt: null,
554
556
  forkCompactSourceId: forkSourceId ?? null,
555
557
  compacting: false,
558
+ contextWindowUsed: null,
559
+ contextWindowMax: null,
556
560
  };
557
561
  const bridgeOpts = {
558
562
  cwd,
@@ -584,6 +588,46 @@ export class SessionStreamManager {
584
588
  stream.bridge = this.bridgeFactory(bridgeOpts);
585
589
  stream.ready = stream.bridge
586
590
  .start()
591
+ .then(() => {
592
+ // After pi has replayed history into its session manager, populate
593
+ // context window state from its built-in getContextUsage() estimator.
594
+ // Historical sessions need this — without it, the initial
595
+ // session_ready carries null fields and the context-window bar
596
+ // stays invisible until the user sends a new prompt.
597
+ const ctx = stream.bridge.getContextUsage?.();
598
+ if (ctx) {
599
+ stream.contextWindowUsed = ctx.tokens;
600
+ stream.contextWindowMax = ctx.contextWindow;
601
+ // Emit directly to subscribers (not via handleBridgeEvent, which
602
+ // would buffer the synthetic event into currentTurn). The frontend
603
+ // reducer's token_usage fallthrough updates session-level context
604
+ // window fields when no open turn exists.
605
+ for (const subscriber of stream.subscribers) {
606
+ if (!subscriber.isOpen())
607
+ continue;
608
+ try {
609
+ subscriber.send({
610
+ type: "token_usage",
611
+ messageId: "",
612
+ usage: {
613
+ inputTokens: 0,
614
+ outputTokens: 0,
615
+ cacheReadTokens: 0,
616
+ cacheWriteTokens: 0,
617
+ totalTokens: 0,
618
+ cost: null,
619
+ creditsUsed: 0,
620
+ },
621
+ contextWindowUsed: ctx.tokens,
622
+ contextWindowMax: ctx.contextWindow,
623
+ });
624
+ }
625
+ catch {
626
+ // best-effort
627
+ }
628
+ }
629
+ }
630
+ })
587
631
  .catch((err) => {
588
632
  const e = err instanceof Error ? err : new Error(String(err));
589
633
  stream.startError = e;
@@ -648,6 +692,17 @@ export class SessionStreamManager {
648
692
  // buffer because by that point the assistant message is already in
649
693
  // SQLite (PiBridge calls onAssistantMessageComplete on message_end,
650
694
  // which fires before agent_end).
695
+ //
696
+ // Track context window state from token_usage events — the bridge emits
697
+ // cumulative session-wide values from pi's getContextUsage(). This lets
698
+ // late-attaching subsribers (reconnects / multi-tab) get the latest
699
+ // context window state via session_ready.
700
+ if (event.type === "token_usage") {
701
+ if (event.contextWindowUsed != null)
702
+ stream.contextWindowUsed = event.contextWindowUsed;
703
+ if (event.contextWindowMax != null)
704
+ stream.contextWindowMax = event.contextWindowMax;
705
+ }
651
706
  this.broadcast(stream, event);
652
707
  if (event.type === "agent_end") {
653
708
  // Final flush + clear batch-persist tracking. `onAssistantMessageComplete`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.4.3",
3
+ "version": "0.4.8",
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,