@geravant/sinain 1.15.5 → 1.18.1

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,266 @@
1
+ // OpenRouter injecting proxy for reasoning-model compatibility.
2
+ //
3
+ // Problem it solves:
4
+ // DeepSeek V4 Flash (and similar thinking models) emit `reasoning` in
5
+ // responses and REQUIRE `reasoning_content` echoed back in subsequent
6
+ // assistant-message history. openclaude (Claude-Code-compat CLI) strips
7
+ // the field when reconstructing history -> DeepSeek 400s on every multi-turn
8
+ // MCP flow.
9
+ //
10
+ // How it works:
11
+ // Listens on :11435, forwards to https://openrouter.ai.
12
+ //
13
+ // MODE=preserve (default): intercepts responses (streaming or not),
14
+ // extracts reasoning + tool_call ids, caches (tool_call_id -> reasoning).
15
+ // On subsequent requests, walks messages[] and injects cached
16
+ // reasoning_content into assistant messages that have tool_calls but no
17
+ // reasoning_content. Keeps thinking mode on, preserves model quality.
18
+ //
19
+ // MODE=off: hard-disables thinking by injecting `reasoning:{enabled:false}`
20
+ // into every /chat/completions body. Legacy behavior; use as an escape
21
+ // hatch if preserve mode misbehaves.
22
+ //
23
+ // Fallback: if MODE=preserve but any assistant-with-tool_calls lacks both
24
+ // reasoning_content AND cache hit, this request disables reasoning for
25
+ // itself only. Avoids 400 on cache miss (e.g. proxy restart mid-session).
26
+ //
27
+ // Config:
28
+ // REASONING_MODE=preserve|off (default: preserve)
29
+ // OPENROUTER_PROXY_PORT=11435 (default: 11435)
30
+ // OPENROUTER_PROXY_LOG=/tmp/openrouter-proxy.log
31
+ //
32
+ // Point openclaude at the proxy in .env:
33
+ // OPENAI_BASE_URL=http://localhost:11435/api/v1
34
+
35
+ import http from "http";
36
+ import https from "https";
37
+ import { appendFileSync, writeFileSync } from "fs";
38
+ import { fileURLToPath } from "url";
39
+
40
+ // Exported for testing — default values from env or hardcoded defaults.
41
+ export const DEFAULT_LOG = "/tmp/openrouter-proxy.log";
42
+ export const DEFAULT_UPSTREAM_HOST = "openrouter.ai";
43
+ export const DEFAULT_UPSTREAM_PORT = 443;
44
+ export const DEFAULT_LISTEN_PORT = 11435;
45
+ export const DEFAULT_CACHE_MAX = 1000;
46
+
47
+ const LOG = process.env.OPENROUTER_PROXY_LOG || DEFAULT_LOG;
48
+ const UPSTREAM_HOST = "openrouter.ai";
49
+ const UPSTREAM_PORT = 443;
50
+ const LISTEN_PORT = parseInt(process.env.OPENROUTER_PROXY_PORT || String(DEFAULT_LISTEN_PORT), 10);
51
+ const MODE = (process.env.REASONING_MODE || "preserve").toLowerCase();
52
+ const CACHE_MAX = parseInt(process.env.OPENROUTER_PROXY_CACHE_MAX || String(DEFAULT_CACHE_MAX), 10);
53
+
54
+ // tool_call_id -> reasoning_content. Insertion-order Map = simple LRU.
55
+ const cache = new Map();
56
+
57
+ function cacheSet(id, reasoning) {
58
+ if (cache.has(id)) cache.delete(id);
59
+ cache.set(id, reasoning);
60
+ while (cache.size > CACHE_MAX) {
61
+ cache.delete(cache.keys().next().value);
62
+ }
63
+ }
64
+
65
+ const log = (msg) => appendFileSync(LOG, msg);
66
+
67
+ writeFileSync(LOG, `# openrouter proxy started ${new Date().toISOString()} mode=${MODE} port=${LISTEN_PORT} cacheMax=${CACHE_MAX}\n`);
68
+
69
+ // Rewrite outgoing /chat/completions body based on MODE + cache state.
70
+ function rewriteRequest(body) {
71
+ let json;
72
+ try { json = JSON.parse(body.toString("utf8")); }
73
+ catch { return { body, action: "passthrough-parse-fail" }; }
74
+
75
+ if (MODE === "off") {
76
+ if (!json.reasoning) json.reasoning = { enabled: false };
77
+ return { body: Buffer.from(JSON.stringify(json)), action: "disable-thinking" };
78
+ }
79
+
80
+ // MODE=preserve: walk history, inject cached reasoning_content
81
+ let injected = 0;
82
+ let orphaned = 0;
83
+ if (Array.isArray(json.messages)) {
84
+ for (const msg of json.messages) {
85
+ const needsReasoning =
86
+ msg.role === "assistant" &&
87
+ Array.isArray(msg.tool_calls) &&
88
+ msg.tool_calls.length > 0 &&
89
+ !msg.reasoning_content &&
90
+ !msg.reasoning;
91
+ if (!needsReasoning) continue;
92
+ const firstId = msg.tool_calls[0]?.id;
93
+ if (firstId && cache.has(firstId)) {
94
+ msg.reasoning_content = cache.get(firstId);
95
+ injected++;
96
+ } else {
97
+ orphaned++;
98
+ }
99
+ }
100
+ }
101
+
102
+ if (orphaned > 0) {
103
+ // Fallback: cache miss on a turn that needs echo-back. Disable thinking
104
+ // for THIS request only so DeepSeek doesn't 400. Next response will seed
105
+ // cache again. Injected assistant messages that WERE recovered stay as-is.
106
+ json.reasoning = { enabled: false };
107
+ return {
108
+ body: Buffer.from(JSON.stringify(json)),
109
+ action: `fallback-disable (injected=${injected}, orphaned=${orphaned})`,
110
+ };
111
+ }
112
+
113
+ if (injected > 0) {
114
+ return { body: Buffer.from(JSON.stringify(json)), action: `preserve (injected=${injected})` };
115
+ }
116
+
117
+ // No assistant-with-tool_calls needing reasoning — first request of a
118
+ // session, or request with only user messages. Pass through unchanged.
119
+ return { body, action: "preserve (no-op)" };
120
+ }
121
+
122
+ // Non-streaming response: extract reasoning + tool_call_ids from one JSON.
123
+ function captureNonStreaming(body) {
124
+ try {
125
+ const json = JSON.parse(body.toString("utf8"));
126
+ const msg = json.choices?.[0]?.message;
127
+ if (!msg) return 0;
128
+ const reasoning = msg.reasoning || msg.reasoning_content;
129
+ const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
130
+ if (!reasoning || !toolCalls.length) return 0;
131
+ for (const tc of toolCalls) if (tc.id) cacheSet(tc.id, reasoning);
132
+ return toolCalls.length;
133
+ } catch { return 0; }
134
+ }
135
+
136
+ // Streaming response: accumulate reasoning text + tool_call ids across SSE chunks.
137
+ // On stream end, associate the full reasoning with every observed tool_call id.
138
+ function parseSSEChunk(chunk, state) {
139
+ state.buffer += chunk.toString("utf8");
140
+ const events = state.buffer.split("\n\n");
141
+ state.buffer = events.pop(); // last may be incomplete, keep for next chunk
142
+
143
+ for (const evt of events) {
144
+ for (const line of evt.split("\n")) {
145
+ if (!line.startsWith("data: ")) continue;
146
+ const data = line.slice(6).trim();
147
+ if (!data || data === "[DONE]") continue;
148
+ try {
149
+ const json = JSON.parse(data);
150
+ const delta = json.choices?.[0]?.delta;
151
+ if (!delta) continue;
152
+ if (typeof delta.reasoning === "string") state.reasoning += delta.reasoning;
153
+ if (typeof delta.reasoning_content === "string") state.reasoning += delta.reasoning_content;
154
+ if (Array.isArray(delta.tool_calls)) {
155
+ for (const tc of delta.tool_calls) {
156
+ if (tc.id && !state.toolCallIds.includes(tc.id)) state.toolCallIds.push(tc.id);
157
+ }
158
+ }
159
+ } catch { /* partial JSON across chunks; next chunk completes it */ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // Exported request handler — pure function, no side effects at module load.
165
+ // Auto-start path at bottom wraps this in http.createServer() when run directly.
166
+ export function handler(clientReq, clientRes) {
167
+ const ts = new Date().toISOString();
168
+ let reqBody = Buffer.alloc(0);
169
+ clientReq.on("data", (c) => { reqBody = Buffer.concat([reqBody, c]); });
170
+ clientReq.on("end", () => {
171
+ const isChat = clientReq.url.includes("/chat/completions");
172
+
173
+ let outBody = reqBody;
174
+ let action = "passthrough";
175
+ if (isChat && reqBody.length > 0) {
176
+ const r = rewriteRequest(reqBody);
177
+ outBody = r.body;
178
+ action = r.action;
179
+ }
180
+
181
+ log(
182
+ `\n========== ${ts} ${clientReq.method} ${clientReq.url} ` +
183
+ `(${action}, cache=${cache.size}) ==========\n` +
184
+ `REQUEST (${outBody.length} bytes):\n${outBody.toString("utf8").slice(0, 4000)}\n` +
185
+ `---------- RESPONSE ----------\n`
186
+ );
187
+
188
+ const fwdHeaders = { ...clientReq.headers };
189
+ delete fwdHeaders.host;
190
+ fwdHeaders["content-length"] = outBody.length;
191
+
192
+ const upReq = https.request(
193
+ {
194
+ host: UPSTREAM_HOST,
195
+ port: UPSTREAM_PORT,
196
+ method: clientReq.method,
197
+ path: clientReq.url,
198
+ headers: fwdHeaders,
199
+ },
200
+ (upRes) => {
201
+ clientRes.writeHead(upRes.statusCode, upRes.headers);
202
+ const ct = upRes.headers["content-type"] || "";
203
+ const isStream = ct.includes("text/event-stream");
204
+ const state = { buffer: "", reasoning: "", toolCallIds: [] };
205
+ let collected = Buffer.alloc(0);
206
+
207
+ upRes.on("data", (chunk) => {
208
+ clientRes.write(chunk);
209
+ log(chunk.toString("utf8"));
210
+ if (MODE === "preserve" && isChat) {
211
+ if (isStream) parseSSEChunk(chunk, state);
212
+ else collected = Buffer.concat([collected, chunk]);
213
+ }
214
+ });
215
+ upRes.on("end", () => {
216
+ clientRes.end();
217
+ if (MODE === "preserve" && isChat) {
218
+ let cached = 0;
219
+ if (isStream) {
220
+ if (state.reasoning && state.toolCallIds.length) {
221
+ for (const id of state.toolCallIds) cacheSet(id, state.reasoning);
222
+ cached = state.toolCallIds.length;
223
+ }
224
+ } else {
225
+ cached = captureNonStreaming(collected);
226
+ }
227
+ if (cached > 0) {
228
+ log(`\n[cache] stored reasoning (${state.reasoning.length || "n/a"} chars) for ${cached} tool_call(s)\n`);
229
+ }
230
+ }
231
+ log(`========== END ${upRes.statusCode} (cache size=${cache.size}) ==========\n`);
232
+ });
233
+ }
234
+ );
235
+ upReq.on("error", (err) => {
236
+ log(`PROXY ERROR: ${err.message}\n`);
237
+ clientRes.writeHead(502);
238
+ clientRes.end("proxy error: " + err.message);
239
+ });
240
+ upReq.write(outBody);
241
+ upReq.end();
242
+ });
243
+ }
244
+
245
+ // Test helpers — small, pure, stateful.
246
+ export function getCacheSize() { return cache.size; }
247
+ export function clearCache() { cache.clear(); }
248
+
249
+ // Core function exports for testing (names only; no `export function` on
250
+ // the actual declarations to avoid duplicate-export errors).
251
+ export {
252
+ cacheSet,
253
+ cache,
254
+ rewriteRequest,
255
+ captureNonStreaming,
256
+ parseSSEChunk,
257
+ };
258
+
259
+ // Only auto-start when run directly, not when imported as a module.
260
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
261
+ if (isMain) {
262
+ http.createServer(handler).listen(LISTEN_PORT, () => {
263
+ console.log(`openrouter proxy: http://localhost:${LISTEN_PORT} → https://${UPSTREAM_HOST} (mode=${MODE})`);
264
+ console.log(`logs: ${LOG}`);
265
+ });
266
+ }
@@ -150,6 +150,10 @@ function buildUserPrompt(ctx: ContextWindow, recorderStatus: RecorderStatus | nu
150
150
  const hasImages = imagesForPrompt && imagesForPrompt.length > 0;
151
151
  const imageNote = hasImages ? `\n\nScreen screenshots (${imagesForPrompt!.length}) are attached below.` : "";
152
152
 
153
+ const knowledgeSection = ctx.knowledgeFacts
154
+ ? `\n\nRelevant background:\n${ctx.knowledgeFacts}`
155
+ : "";
156
+
153
157
  return `Active app: ${normalizeAppName(ctx.currentApp)}
154
158
  App history: ${appSwitches || "(none)"}${recorderSection}
155
159
 
@@ -157,7 +161,7 @@ Screen (OCR text, newest first):
157
161
  ${screenLines || "(no screen data)"}
158
162
 
159
163
  Audio transcript (newest first, \ud83d\udd0a=system, \ud83c\udf99=mic):
160
- ${audioLines || "(silence)"}${imageNote}`;
164
+ ${audioLines || "(silence)"}${knowledgeSection}${imageNote}`;
161
165
  }
162
166
 
163
167
  /**
@@ -37,6 +37,8 @@ export interface AgentLoopDeps {
37
37
  feedbackStore?: { queryRecent(n: number): FeedbackRecord[] };
38
38
  /** Optional: cost tracker for LLM cost accumulation. */
39
39
  costTracker?: CostTracker;
40
+ /** Optional: entity subscription cache for real-time knowledge injection. */
41
+ entityCache?: import("../learning/entity-cache.js").EntityCache;
40
42
  }
41
43
 
42
44
  export interface TraceContext {
@@ -265,6 +267,15 @@ export class AgentLoop extends EventEmitter {
265
267
  const contextWindow = buildContextWindow(
266
268
  feedBuffer, senseBuffer, richness, this.deps.agentConfig.maxAgeMs,
267
269
  );
270
+
271
+ // Entity subscription: inject cached knowledge facts into context
272
+ if (this.deps.entityCache) {
273
+ const recentText = contextWindow.audio.map(a => a.text).join(" ");
274
+ const entities = this.deps.entityCache.detectEntities(recentText);
275
+ const facts = this.deps.entityCache.getRelevantFacts(entities, 500);
276
+ if (facts) contextWindow.knowledgeFacts = facts;
277
+ }
278
+
268
279
  this.deps.profiler?.timerRecord("agent.contextBuild", Date.now() - ctxStart);
269
280
 
270
281
  this.running = true;
@@ -173,6 +173,48 @@ async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
173
173
  return JSON.stringify(unique.slice(0, max));
174
174
  }
175
175
 
176
+ /** Bi-temporal entity query: what did we know about entity X on a given date? */
177
+ async function queryKnowledgeAsOfMulti(entity: string, date: string): Promise<string> {
178
+ const { execFileSync } = await import("node:child_process");
179
+ const { dirname } = await import("node:path");
180
+
181
+ const localDir = resolveLocalMemoryDir();
182
+ const workspaceDir = `${resolveWorkspace()}/memory`;
183
+ const dbPaths = [
184
+ `${localDir}/knowledge-graph.db`,
185
+ `${workspaceDir}/knowledge-graph.db`,
186
+ ];
187
+
188
+ const scriptCandidates = [
189
+ `${dirname(new URL(import.meta.url).pathname)}/../sinain-hud-plugin/sinain-memory`,
190
+ `${dirname(new URL(import.meta.url).pathname)}/sinain-memory`,
191
+ `${resolveWorkspace()}/sinain-memory`,
192
+ ];
193
+ const scriptsDir = scriptCandidates.find(p => existsSync(`${p}/triplestore.py`)) || scriptCandidates[0];
194
+
195
+ for (const dbPath of dbPaths) {
196
+ if (!existsSync(dbPath)) continue;
197
+ try {
198
+ const pyCode = `
199
+ import sys, json; sys.path.insert(0, "${scriptsDir}")
200
+ from datetime import datetime; from triplestore import TripleStore
201
+ store = TripleStore("${dbPath}")
202
+ d = datetime.fromisoformat("${date}")
203
+ # Query both entity:X and fact:X-* patterns
204
+ result = store.entity_as_of("entity:${entity}", d)
205
+ if not result:
206
+ result = store.entity_as_of("${entity}", d)
207
+ print(json.dumps({k: v for k, v in result.items()}, ensure_ascii=False))
208
+ `;
209
+ const out = execFileSync("python3", ["-c", pyCode], {
210
+ timeout: 5000, encoding: "utf-8",
211
+ }).trim();
212
+ if (out && out !== "{}") return out;
213
+ } catch { /* skip */ }
214
+ }
215
+ return "{}";
216
+ }
217
+
176
218
  /** Export knowledge facts as a portable JSON module. */
177
219
  async function exportKnowledgeMulti(domain: string | null, max: number): Promise<string> {
178
220
  const { execFileSync } = await import("node:child_process");
@@ -386,6 +428,13 @@ async function main() {
386
428
  setImmediate(() => {
387
429
  localCuration.distillPendingSession();
388
430
  });
431
+
432
+ // ── Entity subscription cache ���─
433
+ // Detects entity mentions in transcription, prefetches knowledge facts async.
434
+ // By the time the agent loop runs (3s debounce), cache is warm.
435
+ const { EntityCache } = await import("./learning/entity-cache.js");
436
+ const entityCache = new EntityCache(queryKnowledgeFactsMulti);
437
+ entityCache.loadEntityNames().catch(() => {});
389
438
  localCuration.startPeriodicCuration();
390
439
 
391
440
  // Wire incremental distillation: when feed buffer fills, distill before items are lost
@@ -464,6 +513,7 @@ async function main() {
464
513
  },
465
514
  feedbackStore: feedbackStore ?? undefined,
466
515
  costTracker,
516
+ entityCache,
467
517
  });
468
518
 
469
519
  // ── Wire learning signal collector (needs agentLoop) ──
@@ -590,6 +640,11 @@ async function main() {
590
640
  if (!isSystem) item.audioSource = "mic";
591
641
  wsHandler.broadcast(`${tag} ${bufferText}`, "normal");
592
642
  recorder.onFeedItem(item); // Collect for recording if active
643
+
644
+ // Entity subscription: detect mentions and prefetch knowledge (async, non-blocking)
645
+ const detectedEntities = entityCache.detectEntities(result.text);
646
+ if (detectedEntities.length > 0) entityCache.prefetch(detectedEntities);
647
+
593
648
  agentLoop.onNewContext(); // Trigger debounced analysis
594
649
  });
595
650
 
@@ -817,6 +872,7 @@ async function main() {
817
872
  },
818
873
  queryKnowledgeFacts: queryKnowledgeFactsMulti,
819
874
  listKnowledgeEntities: listKnowledgeEntitiesMulti,
875
+ queryKnowledgeAsOf: queryKnowledgeAsOfMulti,
820
876
  exportKnowledge: exportKnowledgeMulti,
821
877
  importKnowledge: importKnowledgeToLocal,
822
878
 
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Entity subscription cache — detects entity mentions in real-time transcription
3
+ * and prefetches knowledge facts for injection into the agent prompt.
4
+ *
5
+ * Flow: transcript → detectEntities() → prefetch() → getRelevantFacts()
6
+ * The 3s agent debounce ensures prefetch completes before the next analysis tick.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { log, warn } from "../log.js";
14
+
15
+ const TAG = "entity-cache";
16
+ const MAX_ENTRIES = 50;
17
+ const TTL_MS = 5 * 60 * 1000; // 5 minutes
18
+ const JUNK_RE = /^[a-f0-9]{6,}$|^\d+$|^-+$/; // commit hashes, pure numbers, dashes
19
+
20
+ interface CacheEntry {
21
+ facts: string;
22
+ ts: number;
23
+ }
24
+
25
+ type QueryFn = (entities: string[], maxFacts: number) => Promise<string>;
26
+
27
+ export class EntityCache {
28
+ private cache = new Map<string, CacheEntry>();
29
+ private knownEntities: string[] = [];
30
+ private pendingQueries = new Set<string>();
31
+ private queryFn: QueryFn;
32
+ private evictionOrder: string[] = [];
33
+
34
+ constructor(queryFn: QueryFn) {
35
+ this.queryFn = queryFn;
36
+ }
37
+
38
+ /** Load entity names from the knowledge graph (async, non-blocking). */
39
+ async loadEntityNames(): Promise<void> {
40
+ try {
41
+ const __dir = dirname(fileURLToPath(import.meta.url));
42
+ const localDir = process.env.SINAIN_MEMORY_DIR
43
+ || process.env.OPENCLAW_WORKSPACE_DIR
44
+ ? `${(process.env.OPENCLAW_WORKSPACE_DIR || "").replace(/~/, process.env.HOME || "")}/memory`
45
+ : `${process.env.HOME}/.sinain/memory`;
46
+ const workspaceDir = `${(process.env.OPENCLAW_WORKSPACE_DIR || "").replace(/~/, process.env.HOME || "")}/memory`;
47
+
48
+ const dbPaths = [
49
+ `${localDir}/knowledge-graph.db`,
50
+ `${workspaceDir}/knowledge-graph.db`,
51
+ ].filter(p => existsSync(p));
52
+
53
+ if (dbPaths.length === 0) {
54
+ log(TAG, "no knowledge-graph.db found — entity detection disabled");
55
+ return;
56
+ }
57
+
58
+ // Query entity names directly via SQLite
59
+ const scriptCandidates = [
60
+ resolve(__dir, "..", "..", "sinain-hud-plugin", "sinain-memory", "graph_query.py"),
61
+ resolve(__dir, "..", "sinain-memory", "graph_query.py"),
62
+ ];
63
+ const scriptPath = scriptCandidates.find(p => existsSync(p));
64
+ if (!scriptPath) return;
65
+
66
+ const names = new Set<string>();
67
+ for (const dbPath of dbPaths) {
68
+ try {
69
+ const out = execFileSync("python3", [
70
+ "-c",
71
+ `import sys; sys.path.insert(0, "${dirname(scriptPath)}"); ` +
72
+ `from triplestore import TripleStore; store = TripleStore("${dbPath}"); ` +
73
+ `[print(n) for _, n in store.entities_with_attr("name") if _.startswith("entity:")]`,
74
+ ], { timeout: 5000, encoding: "utf-8" });
75
+ for (const line of out.split("\n")) {
76
+ const name = line.trim();
77
+ if (name.length >= 4 && !JUNK_RE.test(name)) {
78
+ names.add(name);
79
+ }
80
+ }
81
+ } catch { /* skip */ }
82
+ }
83
+
84
+ this.knownEntities = [...names].sort((a, b) => b.length - a.length); // longest first for greedy match
85
+ log(TAG, `loaded ${this.knownEntities.length} entity names`);
86
+ } catch (e: any) {
87
+ warn(TAG, `loadEntityNames failed: ${e.message?.slice(0, 80)}`);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Detect entity mentions in text. Synchronous, <1ms.
93
+ * Checks known entity names via substring match + extracts capitalized phrases.
94
+ */
95
+ detectEntities(text: string): string[] {
96
+ const lower = text.toLowerCase();
97
+ const found = new Set<string>();
98
+
99
+ // Known entity substring match (longest first avoids partial matches)
100
+ for (const name of this.knownEntities) {
101
+ if (found.size >= 5) break;
102
+ if (name.length < 5) {
103
+ // Short names: require word boundary
104
+ const re = new RegExp(`\\b${name.replace(/-/g, "[- ]?")}\\b`, "i");
105
+ if (re.test(lower)) found.add(name);
106
+ } else {
107
+ // Longer names: simple indexOf (spaces → optional hyphens)
108
+ const searchable = name.replace(/-/g, " ");
109
+ if (lower.includes(searchable) || lower.includes(name)) {
110
+ found.add(name);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Capitalized multi-word phrases (catch entities not yet in graph)
116
+ const caps = text.match(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/g);
117
+ if (caps) {
118
+ for (const phrase of caps.slice(0, 3)) {
119
+ const normalized = phrase.toLowerCase().replace(/\s+/g, "-");
120
+ if (normalized.length >= 4 && !found.has(normalized)) {
121
+ found.add(normalized);
122
+ if (found.size >= 5) break;
123
+ }
124
+ }
125
+ }
126
+
127
+ return [...found];
128
+ }
129
+
130
+ /** Async prefetch: fire-and-forget knowledge queries for cache misses. */
131
+ prefetch(entities: string[]): void {
132
+ const toFetch = entities.filter(e => !this.cache.has(e) && !this.pendingQueries.has(e));
133
+ if (toFetch.length === 0) return;
134
+
135
+ for (const entity of toFetch) {
136
+ this.pendingQueries.add(entity);
137
+ this.queryFn([entity], 3)
138
+ .then(facts => {
139
+ this.cache.set(entity, { facts, ts: Date.now() });
140
+ this.evictionOrder.push(entity);
141
+ this.evict();
142
+ })
143
+ .catch(() => { /* silent */ })
144
+ .finally(() => this.pendingQueries.delete(entity));
145
+ }
146
+ }
147
+
148
+ /** Get cached facts for entities. Synchronous — only reads pre-fetched data. */
149
+ getRelevantFacts(entities: string[], maxChars: number): string {
150
+ const parts: string[] = [];
151
+ let total = 0;
152
+
153
+ for (const entity of entities) {
154
+ const entry = this.cache.get(entity);
155
+ if (!entry || Date.now() - entry.ts > TTL_MS || !entry.facts) continue;
156
+ if (total + entry.facts.length + 2 > maxChars) break;
157
+ parts.push(entry.facts);
158
+ total += entry.facts.length + 2;
159
+ }
160
+
161
+ return parts.join("; ");
162
+ }
163
+
164
+ /** Number of cached entities. */
165
+ get size(): number {
166
+ return this.cache.size;
167
+ }
168
+
169
+ /** Number of known entity names loaded from the graph. */
170
+ get entityCount(): number {
171
+ return this.knownEntities.length;
172
+ }
173
+
174
+ private evict(): void {
175
+ while (this.cache.size > MAX_ENTRIES && this.evictionOrder.length > 0) {
176
+ const oldest = this.evictionOrder.shift()!;
177
+ this.cache.delete(oldest);
178
+ }
179
+ }
180
+ }
@@ -179,6 +179,7 @@ export interface ServerDeps {
179
179
  getKnowledgeDocPath?: () => string | null;
180
180
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
181
181
  listKnowledgeEntities?: (max: number) => Promise<string>;
182
+ queryKnowledgeAsOf?: (entity: string, date: string) => Promise<string>;
182
183
  exportKnowledge?: (domain: string | null, max: number) => Promise<string>;
183
184
  importKnowledge?: (data: string) => Promise<string>;
184
185
  onSpawnCommand?: (text: string) => void;
@@ -453,6 +454,28 @@ export function createAppServer(deps: ServerDeps) {
453
454
  return;
454
455
  }
455
456
 
457
+ // ── /knowledge/as-of — bi-temporal entity query ──
458
+ if (req.method === "GET" && url.pathname === "/knowledge/as-of") {
459
+ const entity = url.searchParams.get("entity") || "";
460
+ const date = url.searchParams.get("date") || "";
461
+ if (!entity || !date) {
462
+ res.statusCode = 400;
463
+ res.end(JSON.stringify({ ok: false, error: "entity and date params required" }));
464
+ return;
465
+ }
466
+ if (deps.queryKnowledgeAsOf) {
467
+ try {
468
+ const result = await deps.queryKnowledgeAsOf(entity, date);
469
+ res.end(JSON.stringify({ ok: true, entity, date, attributes: JSON.parse(result) }));
470
+ } catch (err) {
471
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
472
+ }
473
+ } else {
474
+ res.end(JSON.stringify({ ok: false, error: "bi-temporal query not available" }));
475
+ }
476
+ return;
477
+ }
478
+
456
479
  if (req.method === "GET" && url.pathname === "/knowledge/export") {
457
480
  // Export knowledge module (filterable by domain)
458
481
  const domain = url.searchParams.get("domain") || null;
@@ -328,6 +328,8 @@ export interface ContextWindow {
328
328
  windowMs: number;
329
329
  newestEventTs: number;
330
330
  preset: RichnessPreset;
331
+ /** Pre-fetched knowledge facts from entity subscription cache. */
332
+ knowledgeFacts?: string;
331
333
  }
332
334
 
333
335
  // ── Escalation types ──