@aexol/spectral 0.3.7 → 0.3.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.
@@ -0,0 +1,62 @@
1
+ import { DEFAULTS, loadConfig } from "./config.js";
2
+ export class Runtime {
3
+ config = { ...DEFAULTS };
4
+ configLoaded = false;
5
+ observerInFlight = false;
6
+ observerPromise = null;
7
+ compactInFlight = false;
8
+ compactHookInFlight = false;
9
+ resolveFailureNotified = false;
10
+ ensureConfig(cwd) {
11
+ if (this.configLoaded)
12
+ return;
13
+ this.config = loadConfig(cwd);
14
+ this.configLoaded = true;
15
+ }
16
+ async resolveModel(ctx) {
17
+ let model = ctx.model;
18
+ if (this.config.compactionModel) {
19
+ const configured = ctx.modelRegistry.find(this.config.compactionModel.provider, this.config.compactionModel.id);
20
+ if (configured) {
21
+ model = configured;
22
+ }
23
+ else if (ctx.hasUI && ctx.ui) {
24
+ ctx.ui.notify(`Observational memory: configured model ${this.config.compactionModel.provider}/${this.config.compactionModel.id} not found, using session model`, "warning");
25
+ }
26
+ }
27
+ if (!model)
28
+ return { ok: false, reason: "no model available (session has no model and no compactionModel configured)" };
29
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
30
+ if (!auth.ok || !auth.apiKey) {
31
+ const provider = model.provider ?? "unknown";
32
+ return { ok: false, reason: `no API key for provider "${provider}"` };
33
+ }
34
+ return { ok: true, model, apiKey: auth.apiKey, headers: auth.headers };
35
+ }
36
+ launchObserverTask(ctx, label, work) {
37
+ this.observerInFlight = true;
38
+ // Capture ctx properties synchronously — after `await work()` the extension ctx
39
+ // may be stale (e.g. after ctx.newSession/fork/switchSession/reload), and accessing
40
+ // ctx.hasUI or ctx.ui on a stale proxy throws.
41
+ const hasUI = ctx.hasUI;
42
+ const ui = ctx.ui;
43
+ let promise;
44
+ promise = (async () => {
45
+ try {
46
+ await work();
47
+ }
48
+ catch (error) {
49
+ const msg = error instanceof Error ? error.message : String(error);
50
+ if (hasUI && ui)
51
+ ui.notify(`Observational memory: ${label} failed: ${msg}`, "warning");
52
+ }
53
+ finally {
54
+ this.observerInFlight = false;
55
+ if (this.observerPromise === promise)
56
+ this.observerPromise = null;
57
+ }
58
+ })();
59
+ this.observerPromise = promise;
60
+ return promise;
61
+ }
62
+ }
@@ -0,0 +1,199 @@
1
+ function pad(n) {
2
+ return n.toString().padStart(2, "0");
3
+ }
4
+ function fmtLocal(d) {
5
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
6
+ }
7
+ function formatTimestamp(v) {
8
+ if (v === undefined)
9
+ return "????-??-?? ??:??";
10
+ const d = new Date(v);
11
+ return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
12
+ }
13
+ function formatRecallTimestamp(...values) {
14
+ for (const v of values) {
15
+ if (v === undefined)
16
+ continue;
17
+ const d = new Date(v);
18
+ if (!Number.isNaN(d.getTime()))
19
+ return fmtLocal(d);
20
+ }
21
+ return "Unknown time";
22
+ }
23
+ function textAndPlaceholders(content, options = {}) {
24
+ if (typeof content === "string")
25
+ return content;
26
+ if (!Array.isArray(content))
27
+ return "[non-text content omitted]";
28
+ const parts = [];
29
+ for (const block of content) {
30
+ if (!block || typeof block !== "object") {
31
+ parts.push("[non-text content omitted]");
32
+ continue;
33
+ }
34
+ if (block.type === "text" && typeof block.text === "string") {
35
+ parts.push(block.text);
36
+ continue;
37
+ }
38
+ if (block.type === "thinking") {
39
+ if (options.omitRedactedThinking && block.redacted === true)
40
+ continue;
41
+ if (options.includeThinking && typeof block.thinking === "string") {
42
+ parts.push(`[thinking: ${block.thinking}]`);
43
+ continue;
44
+ }
45
+ parts.push("[non-text content omitted]");
46
+ continue;
47
+ }
48
+ if (block.type === "toolCall" && typeof block.name === "string") {
49
+ parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
50
+ continue;
51
+ }
52
+ parts.push("[non-text content omitted]");
53
+ }
54
+ return parts.join("\n");
55
+ }
56
+ function textOnly(content) {
57
+ if (content == null)
58
+ return "";
59
+ if (typeof content === "string")
60
+ return content;
61
+ if (!Array.isArray(content))
62
+ return "";
63
+ return content
64
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
65
+ .map((b) => b.text)
66
+ .join("\n");
67
+ }
68
+ export function serializeConversation(messages) {
69
+ return messages
70
+ .map((msg) => {
71
+ const time = formatTimestamp(msg.timestamp);
72
+ if (msg.role === "user") {
73
+ const text = textOnly(msg.content);
74
+ return `[User @ ${time}]: ${text}`;
75
+ }
76
+ if (msg.role === "assistant") {
77
+ const body = textAndPlaceholders(msg.content, {
78
+ includeThinking: true,
79
+ omitRedactedThinking: true,
80
+ })
81
+ .split("\n")
82
+ .filter(Boolean)
83
+ .join("\n");
84
+ if (!body)
85
+ return null;
86
+ return `[Assistant @ ${time}]: ${body}`;
87
+ }
88
+ const text = textOnly(msg.content);
89
+ return `[Tool result for ${msg.toolName} @ ${time}]: ${text}`;
90
+ })
91
+ .filter((line) => line !== null)
92
+ .join("\n\n");
93
+ }
94
+ export function nowTimestamp() {
95
+ return fmtLocal(new Date());
96
+ }
97
+ export const MAX_RECORD_CONTENT_CHARS = 10_000;
98
+ export function truncateRecordContent(content) {
99
+ if (content.length <= MAX_RECORD_CONTENT_CHARS)
100
+ return content;
101
+ const head = content.slice(0, MAX_RECORD_CONTENT_CHARS);
102
+ const dropped = content.length - MAX_RECORD_CONTENT_CHARS;
103
+ return `${head} … [truncated ${dropped} chars]`;
104
+ }
105
+ function renderCustomMessage(entry, options) {
106
+ const time = options.recallFormat ? formatRecallTimestamp(entry.timestamp) : formatTimestamp(entry.timestamp);
107
+ const text = options.recallFormat
108
+ ? textAndPlaceholders(entry.content)
109
+ : typeof entry.content === "string"
110
+ ? entry.content
111
+ : Array.isArray(entry.content)
112
+ ? entry.content
113
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
114
+ .map((b) => b.text)
115
+ .join("\n")
116
+ : "";
117
+ if (options.recallFormat) {
118
+ const origin = entry.customType ? `Custom message (${entry.customType})` : "Custom message";
119
+ return `[${origin} @ ${time}]: ${text}`;
120
+ }
121
+ const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
122
+ return `[${tag} @ ${time}]: ${text}`;
123
+ }
124
+ export function serializeBranchEntries(entries) {
125
+ const blocks = [];
126
+ for (const entry of entries) {
127
+ if (entry.type === "message" && entry.message) {
128
+ const part = serializeConversation([entry.message]);
129
+ if (part)
130
+ blocks.push(part);
131
+ continue;
132
+ }
133
+ if (entry.type === "custom_message") {
134
+ blocks.push(renderCustomMessage(entry, { recallFormat: false }));
135
+ continue;
136
+ }
137
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
138
+ const time = formatTimestamp(entry.timestamp);
139
+ blocks.push(`[Branch summary @ ${time}]: ${entry.summary}`);
140
+ }
141
+ }
142
+ return blocks.join("\n\n");
143
+ }
144
+ function isSourceRenderableEntry(entry) {
145
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
146
+ }
147
+ export function serializeSourceAddressedBranchEntries(entries) {
148
+ const blocks = [];
149
+ const sourceEntryIds = [];
150
+ for (const entry of entries) {
151
+ if (!entry.id || !isSourceRenderableEntry(entry))
152
+ continue;
153
+ const rendered = serializeBranchEntries([entry]);
154
+ if (!rendered.trim())
155
+ continue;
156
+ sourceEntryIds.push(entry.id);
157
+ blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
158
+ }
159
+ return { text: blocks.join("\n\n"), sourceEntryIds };
160
+ }
161
+ function renderRecallMessage(entry) {
162
+ if (!entry.message || typeof entry.message !== "object")
163
+ return null;
164
+ const msg = entry.message;
165
+ const time = formatRecallTimestamp(msg.timestamp, entry.timestamp);
166
+ if (msg.role === "user") {
167
+ return `[User @ ${time}]: ${textAndPlaceholders(msg.content)}`;
168
+ }
169
+ if (msg.role === "assistant") {
170
+ const body = textAndPlaceholders(msg.content, {
171
+ includeThinking: true,
172
+ omitRedactedThinking: true,
173
+ })
174
+ .split("\n")
175
+ .filter(Boolean)
176
+ .join("\n");
177
+ if (!body)
178
+ return null;
179
+ return `[Assistant @ ${time}]: ${body}`;
180
+ }
181
+ return `[Tool result: ${msg.toolName} @ ${time}]: ${textAndPlaceholders(msg.content)}`;
182
+ }
183
+ export function renderRecallSourceEntry(entry) {
184
+ if (entry.type === "message")
185
+ return renderRecallMessage(entry);
186
+ if (entry.type === "custom_message")
187
+ return renderCustomMessage(entry, { recallFormat: true });
188
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
189
+ const time = formatRecallTimestamp(entry.timestamp);
190
+ return `[Branch summary @ ${time}]: ${entry.summary}`;
191
+ }
192
+ return null;
193
+ }
194
+ export function renderRecallSourceEntries(entries) {
195
+ return entries
196
+ .map(renderRecallSourceEntry)
197
+ .filter((block) => block !== null && block.trim().length > 0)
198
+ .join("\n\n");
199
+ }
@@ -0,0 +1,26 @@
1
+ import { estimateTokens as estimateMessageTokens } from "@mariozechner/pi-coding-agent";
2
+ export function estimateStringTokens(text) {
3
+ return Math.ceil(text.length / 4);
4
+ }
5
+ export function estimateEntryTokens(entry) {
6
+ if (entry.type === "message" && entry.message) {
7
+ return estimateMessageTokens(entry.message);
8
+ }
9
+ if (entry.type === "custom_message" && entry.content) {
10
+ const content = entry.content;
11
+ if (typeof content === "string")
12
+ return estimateStringTokens(content);
13
+ if (Array.isArray(content)) {
14
+ let total = 0;
15
+ for (const block of content) {
16
+ if (block.type === "text" && block.text)
17
+ total += estimateStringTokens(block.text);
18
+ }
19
+ return total;
20
+ }
21
+ }
22
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
23
+ return estimateStringTokens(entry.summary);
24
+ }
25
+ return 0;
26
+ }