@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.
- package/cli.js +0 -171
- package/launcher.js +0 -298
- package/package.json +4 -1
- package/sinain-agent/.claude/settings.json +16 -0
- package/sinain-agent/hooks/approve-tool.sh +46 -0
- package/sinain-agent/openrouter-proxy.mjs +266 -0
- package/sinain-core/src/agent/analyzer.ts +5 -1
- package/sinain-core/src/agent/loop.ts +11 -0
- package/sinain-core/src/index.ts +56 -0
- package/sinain-core/src/learning/entity-cache.ts +180 -0
- package/sinain-core/src/server.ts +23 -0
- package/sinain-core/src/types.ts +2 -0
- package/sinain-memory/graph_query.py +132 -2
|
@@ -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;
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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;
|
package/sinain-core/src/types.ts
CHANGED