@aexol/spectral 0.4.4 → 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/agent/agents.js +117 -0
- package/dist/agent/index.js +951 -0
- package/dist/cli.js +42 -9
- package/dist/memory/hooks/observer-trigger.js +11 -3
- package/dist/memory/index.js +2 -0
- package/dist/relay/dispatcher.js +4 -0
- package/dist/relay/models-fetch.js +5 -1
- package/dist/server/pi-bridge.js +11 -4
- package/dist/server/session-stream.js +55 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -75,21 +75,42 @@ function resolvePiBin() {
|
|
|
75
75
|
}
|
|
76
76
|
return resolve(dirname(piPkgJsonPath), binRel);
|
|
77
77
|
}
|
|
78
|
-
/**
|
|
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
|
|
97
|
+
return resolveExtensionEntry("extensions", "aexol-mcp");
|
|
81
98
|
}
|
|
82
|
-
/** Absolute path to the bundled
|
|
99
|
+
/** Absolute path to the bundled observations-memory extension. */
|
|
83
100
|
function resolveObservationalMemoryPath() {
|
|
84
|
-
return
|
|
101
|
+
return resolveExtensionEntry("memory", "index");
|
|
85
102
|
}
|
|
86
|
-
/** Absolute path to the bundled pi-mcp-adapter extension
|
|
103
|
+
/** Absolute path to the bundled pi-mcp-adapter extension. */
|
|
87
104
|
function resolveMcpExtensionPath() {
|
|
88
|
-
return
|
|
105
|
+
return resolveExtensionEntry("mcp", "index");
|
|
89
106
|
}
|
|
90
|
-
/** Absolute path to the bundled openrouter-attribution extension
|
|
107
|
+
/** Absolute path to the bundled openrouter-attribution extension. */
|
|
91
108
|
function resolveOpenRouterAttributionPath() {
|
|
92
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/memory/index.js
CHANGED
|
@@ -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);
|
package/dist/relay/dispatcher.js
CHANGED
|
@@ -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;
|
package/dist/server/pi-bridge.js
CHANGED
|
@@ -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);
|
|
@@ -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`
|