@geravant/sinain 1.15.6 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.15.6",
3
+ "version": "1.18.1",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,9 @@
43
43
  "sinain-agent/agents.example.json",
44
44
  "sinain-agent/.env.example",
45
45
  "sinain-agent/CLAUDE.md",
46
+ "sinain-agent/openrouter-proxy.mjs",
47
+ "sinain-agent/.claude/settings.json",
48
+ "sinain-agent/hooks/approve-tool.sh",
46
49
  "sense_client",
47
50
  "HEARTBEAT.md",
48
51
  "SKILL.md"
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "./hooks/approve-tool.sh",
10
+ "timeout": 130
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for sinain-agent (escalation + spawn paths).
3
+ #
4
+ # Forwards every tool-invocation to sinain-core /spawn/approve which:
5
+ # - auto-approves safe read-only tools (Read, Glob, Grep, Ls, Cat)
6
+ # - auto-approves all mcp__sinain* tools
7
+ # - routes everything else to the overlay for Allow/Deny
8
+ #
9
+ # Scoped to sinain-agent via --settings in run.sh: regular openclaude/claude
10
+ # sessions outside this directory don't load this settings.json and aren't
11
+ # affected. Previously this hook early-exited unless SINAIN_SPAWN=1 was set,
12
+ # which broke escalation-path write permissions (agent couldn't Bash/Edit).
13
+
14
+ CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
15
+
16
+ # Read hook input from stdin. Claude Code / openclaude typically include
17
+ # session_id per the PreToolUse contract; if missing (or if we want a sinain-
18
+ # native correlation), inject SINAIN_SPAWN_TASK_ID / SINAIN_ESC_TASK_ID as
19
+ # sinainTaskId so the server can still key YOLO on a stable id per invocation.
20
+ HOOK_STDIN=$(cat)
21
+ SINAIN_TASK_ID="${SINAIN_SPAWN_TASK_ID:-${SINAIN_ESC_TASK_ID:-}}"
22
+ if [ -n "$SINAIN_TASK_ID" ] && command -v python3 >/dev/null 2>&1; then
23
+ HOOK_STDIN=$(printf '%s' "$HOOK_STDIN" | SINAIN_TASK_ID="$SINAIN_TASK_ID" python3 -c '
24
+ import json, os, sys
25
+ try:
26
+ d = json.load(sys.stdin)
27
+ if isinstance(d, dict) and not d.get("sinainTaskId"):
28
+ d["sinainTaskId"] = os.environ["SINAIN_TASK_ID"]
29
+ print(json.dumps(d))
30
+ except Exception:
31
+ # On any parse failure, pass original through — server can still work
32
+ sys.stdout.write(os.environ.get("HOOK_STDIN_FALLBACK", ""))
33
+ ')
34
+ fi
35
+
36
+ RESPONSE=$(printf '%s' "$HOOK_STDIN" | curl -sf -X POST "$CORE_URL/spawn/approve" \
37
+ -H 'Content-Type: application/json' \
38
+ --max-time 130 \
39
+ --data-binary @- 2>/dev/null)
40
+
41
+ if [ -n "$RESPONSE" ]; then
42
+ echo "$RESPONSE"
43
+ else
44
+ # If sinain-core is unreachable, allow by default (don't block the agent)
45
+ echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"sinain-core unreachable, auto-allowing"}}'
46
+ fi
@@ -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 ──
@@ -246,6 +246,88 @@ def query_facts_by_entity_graph(
246
246
  return []
247
247
 
248
248
 
249
+ def expand_entity_community(
250
+ store,
251
+ entity_name: str,
252
+ max_related: int = 3,
253
+ max_facts_per_entity: int = 30,
254
+ ) -> list[tuple[str, int]]:
255
+ """Find related entities by following entity → facts → mentioned entities.
256
+
257
+ Returns [(entity_name, co_mention_count), ...] sorted by frequency.
258
+ """
259
+ entity_node_id = f"entity:{entity_name.lower().replace(' ', '-')}"
260
+ if not store.entity(entity_node_id):
261
+ return []
262
+
263
+ # Collect facts linked to this entity (both about and mentions)
264
+ fact_ids = set()
265
+ for fact_eid, _ in store.backrefs(entity_node_id, attribute="about")[:max_facts_per_entity]:
266
+ if fact_eid.startswith("fact:"):
267
+ fact_ids.add(fact_eid)
268
+ for fact_eid, _ in store.backrefs(entity_node_id, attribute="mentions")[:max_facts_per_entity]:
269
+ if fact_eid.startswith("fact:"):
270
+ fact_ids.add(fact_eid)
271
+
272
+ # Follow each fact's outgoing refs to find other entity nodes
273
+ related_counts: dict[str, int] = {}
274
+ for fact_eid in fact_ids:
275
+ attrs = store.entity(fact_eid)
276
+ for ref_attr in ("about", "mentions"):
277
+ targets = attrs.get(ref_attr, [])
278
+ if not isinstance(targets, list):
279
+ targets = [targets]
280
+ for target in targets:
281
+ if isinstance(target, str) and target.startswith("entity:") and target != entity_node_id:
282
+ name = target[len("entity:"):]
283
+ related_counts[name] = related_counts.get(name, 0) + 1
284
+
285
+ # Sort by frequency, return top N
286
+ ranked = sorted(related_counts.items(), key=lambda x: -x[1])
287
+ return ranked[:max_related]
288
+
289
+
290
+ def _cooccurring_entities(
291
+ store,
292
+ fact_ids: set[str],
293
+ max_entities: int = 3,
294
+ ) -> list[str]:
295
+ """Find entities that co-occur in the same distillation pass (shared first_seen timestamp)."""
296
+ if not fact_ids:
297
+ return []
298
+
299
+ # Get first_seen timestamps for the input facts
300
+ timestamps = set()
301
+ for fid in list(fact_ids)[:20]: # cap to avoid huge queries
302
+ attrs = store.entity(fid)
303
+ fs = attrs.get("first_seen", [])
304
+ if isinstance(fs, list) and fs:
305
+ timestamps.add(fs[0])
306
+ elif isinstance(fs, str):
307
+ timestamps.add(fs)
308
+
309
+ if not timestamps:
310
+ return []
311
+
312
+ # Find other facts with same timestamps and extract their entity names
313
+ placeholders = ",".join("?" for _ in timestamps)
314
+ rows = store._conn.execute(
315
+ f"SELECT DISTINCT t2.value FROM triples t1 "
316
+ f"JOIN triples t2 ON t2.entity_id = t1.entity_id AND t2.attribute = 'entity' AND t2.retracted = 0 "
317
+ f"WHERE t1.attribute = 'first_seen' AND t1.value IN ({placeholders}) "
318
+ f"AND t1.retracted = 0 AND t1.entity_id LIKE 'fact:%' "
319
+ f"AND t1.entity_id NOT IN ({','.join('?' for _ in fact_ids)})",
320
+ list(timestamps) + list(fact_ids),
321
+ ).fetchall()
322
+
323
+ # Count co-occurrence per entity name
324
+ counts: dict[str, int] = {}
325
+ for (name,) in rows:
326
+ counts[name] = counts.get(name, 0) + 1
327
+ ranked = sorted(counts, key=lambda x: -counts[x])
328
+ return ranked[:max_entities]
329
+
330
+
249
331
  def query_facts_hybrid(
250
332
  db_path: str,
251
333
  query: str,
@@ -257,17 +339,45 @@ def query_facts_hybrid(
257
339
  expands top results with 1-hop graph neighbors.
258
340
  """
259
341
  import re
342
+ import time
260
343
  keywords = [w.lower() for w in re.findall(r"[a-zA-Z][a-zA-Z0-9-]+", query) if len(w) > 2]
261
344
 
262
345
  # Entity graph pre-filter: find facts linked to mentioned entities via backrefs.
263
346
  # Used to BOOST relevant facts in RRF, not as a separate tier (avoids dilution).
264
347
  graph_fact_ids: set[str] = set()
348
+ community_fact_ids: set[str] = set()
265
349
  for kw in keywords:
266
350
  for f in query_facts_by_entity_graph(db_path, kw, max_facts=50):
267
351
  eid = f.get("entity_id", "")
268
352
  if eid:
269
353
  graph_fact_ids.add(eid)
270
354
 
355
+ # Community expansion: follow mentions edges to find related entities
356
+ t0 = time.monotonic()
357
+ try:
358
+ from triplestore import TripleStore
359
+ store = TripleStore(db_path)
360
+
361
+ matched_entities = set()
362
+ for kw in keywords:
363
+ node_id = f"entity:{kw}"
364
+ if store.entity(node_id):
365
+ matched_entities.add(kw)
366
+
367
+ for ent in matched_entities:
368
+ if time.monotonic() - t0 > 0.5:
369
+ break # timing guard
370
+ community = expand_entity_community(store, ent, max_related=3)
371
+ for related_name, _count in community:
372
+ for f in query_facts_by_entity_graph(db_path, related_name, max_facts=20):
373
+ eid = f.get("entity_id", "")
374
+ if eid and eid not in graph_fact_ids:
375
+ community_fact_ids.add(eid)
376
+
377
+ store.close()
378
+ except Exception:
379
+ pass
380
+
271
381
  # Run three retrieval methods independently
272
382
  candidate_limit = max_facts * 3
273
383
  fts_results = query_facts_fts(db_path, query, max_facts=candidate_limit)
@@ -296,11 +406,31 @@ def query_facts_hybrid(
296
406
  for rank, eid in enumerate(ranked_list):
297
407
  rrf_scores[eid] = rrf_scores.get(eid, 0.0) + 1.0 / (K + rank)
298
408
 
409
+ # Co-occurrence boost: use FTS/tag results to find temporally related entities
410
+ import time as _time
411
+ _t_cooccur = _time.monotonic()
412
+ query_matched_ids = {f.get("entity_id", "") for f in fts_results + tag_results if f.get("entity_id")}
413
+ if query_matched_ids and _time.monotonic() - _t_cooccur < 0.3:
414
+ try:
415
+ from triplestore import TripleStore
416
+ _store = TripleStore(db_path)
417
+ cooccur = _cooccurring_entities(_store, query_matched_ids, max_entities=5)
418
+ for ent_name in cooccur:
419
+ for f in query_facts_by_entity_graph(db_path, ent_name, max_facts=10):
420
+ eid = f.get("entity_id", "")
421
+ if eid and eid not in graph_fact_ids:
422
+ community_fact_ids.add(eid)
423
+ _store.close()
424
+ except Exception:
425
+ pass
426
+
299
427
  # Graph boost: facts linked to mentioned entities via backrefs get priority
300
- if graph_fact_ids:
428
+ if graph_fact_ids or community_fact_ids:
301
429
  for eid in rrf_scores:
302
430
  if eid in graph_fact_ids:
303
- rrf_scores[eid] += 0.02 # significant boost — graph-linked facts rank higher
431
+ rrf_scores[eid] += 0.02 # direct graph-linked facts
432
+ elif eid in community_fact_ids:
433
+ rrf_scores[eid] += 0.01 # community-expanded facts (half weight)
304
434
 
305
435
  # Apply confidence decay as secondary signal (fresh facts rank above stale ones)
306
436
  from triplestore import decayed_confidence