@geravant/sinain 1.15.6 → 1.18.2
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/onboard.js +38 -10
- package/package.json +4 -1
- package/sinain-agent/.claude/settings.json +16 -0
- package/sinain-agent/agents.example.json +9 -9
- 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/escalation/escalator.ts +13 -2
- 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
package/onboard.js
CHANGED
|
@@ -154,25 +154,54 @@ export async function runOnboard(args = {}) {
|
|
|
154
154
|
p.log.success("API key saved.");
|
|
155
155
|
|
|
156
156
|
if (flow === "quickstart") {
|
|
157
|
-
// QuickStart: sensible defaults
|
|
158
|
-
//
|
|
157
|
+
// QuickStart: sensible defaults + a single opt-in question for OpenClaw.
|
|
158
|
+
// Gateway integration is off by default; users who want it run Advanced
|
|
159
|
+
// (or answer Yes here, which then walks them through stepGateway).
|
|
159
160
|
vars.TRANSCRIPTION_BACKEND = base.TRANSCRIPTION_BACKEND || "openrouter";
|
|
160
161
|
vars.PRIVACY_MODE = base.PRIVACY_MODE || "standard";
|
|
161
162
|
vars.AGENT_MODEL = base.AGENT_MODEL || "google/gemini-2.5-flash-lite";
|
|
163
|
+
|
|
164
|
+
// Ask explicitly so first-run installs don't silently inherit a gateway
|
|
165
|
+
// profile from agents.example.json. Default reflects current state — No
|
|
166
|
+
// for fresh installs (silences the WS reconnect loop), Yes for re-runs
|
|
167
|
+
// that already had OpenClaw configured (so we don't surprise-delete it).
|
|
168
|
+
const hasExistingGateway = (() => {
|
|
169
|
+
try {
|
|
170
|
+
const agentsPath = path.join(SINAIN_DIR, "agents.json");
|
|
171
|
+
if (!fs.existsSync(agentsPath)) return false;
|
|
172
|
+
const cfg = JSON.parse(fs.readFileSync(agentsPath, "utf-8"));
|
|
173
|
+
return !!cfg?.profiles?.openclaw;
|
|
174
|
+
} catch { return false; }
|
|
175
|
+
})();
|
|
176
|
+
const enableGateway = guard(await p.confirm({
|
|
177
|
+
message: "Enable OpenClaw gateway integration?",
|
|
178
|
+
initialValue: hasExistingGateway,
|
|
179
|
+
}));
|
|
180
|
+
|
|
162
181
|
agentsPatch = {
|
|
163
182
|
default: base.SINAIN_AGENT || "claude",
|
|
164
183
|
escalationMode: "off",
|
|
165
|
-
// Don't touch openclawProfile in quickstart — keeps existing config
|
|
166
|
-
// intact for re-runs; first-time users get the "skip" state from
|
|
167
|
-
// agents.example.json bootstrap (no openclaw profile means no gateway).
|
|
168
184
|
};
|
|
169
185
|
|
|
186
|
+
if (enableGateway) {
|
|
187
|
+
// Walk through full gateway setup (URL, tokens, session key) — same
|
|
188
|
+
// step Advanced uses. Returns { envVars, agentsPatch } we merge in.
|
|
189
|
+
const gatewayResult = await stepGateway(base, "OpenClaw gateway");
|
|
190
|
+
Object.assign(vars, gatewayResult.envVars);
|
|
191
|
+
Object.assign(agentsPatch, gatewayResult.agentsPatch);
|
|
192
|
+
} else {
|
|
193
|
+
// Explicitly clear any inherited openclaw profile so the runtime
|
|
194
|
+
// doesn't auto-register the gateway or attempt WS reconnects.
|
|
195
|
+
agentsPatch.openclawProfile = null;
|
|
196
|
+
}
|
|
197
|
+
|
|
170
198
|
p.note(
|
|
171
199
|
[
|
|
172
200
|
`Transcription: ${vars.TRANSCRIPTION_BACKEND}`,
|
|
173
201
|
`Privacy: ${vars.PRIVACY_MODE}`,
|
|
174
202
|
`Model: ${vars.AGENT_MODEL}`,
|
|
175
|
-
`
|
|
203
|
+
`OpenClaw gateway: ${enableGateway ? "enabled" : "disabled"}`,
|
|
204
|
+
`Escalation: ${agentsPatch.escalationMode || "off"}`,
|
|
176
205
|
"",
|
|
177
206
|
`Change later: sinain config`,
|
|
178
207
|
].join("\n"),
|
|
@@ -368,10 +397,9 @@ if (flags.nonInteractive) {
|
|
|
368
397
|
}
|
|
369
398
|
|
|
370
399
|
writeEnv(vars);
|
|
371
|
-
// Default agent + escalation off
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
writeAgentsConfig({ default: "claude", escalationMode: "off" });
|
|
400
|
+
// Default agent + escalation off + openclaw explicitly disabled. Gateway is
|
|
401
|
+
// opt-in via the interactive wizard (`sinain onboard`) or `sinain config`.
|
|
402
|
+
writeAgentsConfig({ default: "claude", escalationMode: "off", openclawProfile: null });
|
|
375
403
|
console.log(c.green(` Config written to ${ENV_PATH} + ~/.sinain/agents.json`));
|
|
376
404
|
process.exit(0);
|
|
377
405
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geravant/sinain",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.2",
|
|
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"
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
|
|
66
66
|
"escalation": {
|
|
67
|
-
"mode": "
|
|
67
|
+
"mode": "off",
|
|
68
68
|
"cooldownMs": 30000,
|
|
69
69
|
"staleMs": 90000
|
|
70
70
|
},
|
|
@@ -81,8 +81,15 @@
|
|
|
81
81
|
"OPENAI_API_KEY": "${OPENROUTER_API_KEY}"
|
|
82
82
|
}
|
|
83
83
|
},
|
|
84
|
+
"codex": { "type": "codex" },
|
|
85
|
+
"goose": { "type": "goose" },
|
|
86
|
+
"junie": { "type": "junie" },
|
|
87
|
+
"aider": { "type": "aider" }
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
"_examples": {
|
|
84
91
|
"openclaw": {
|
|
85
|
-
"_comment": "
|
|
92
|
+
"_comment": "OpenClaw gateway routing. Move this entry into `profiles` to enable. The wizard's `sinain onboard --advanced` step or `sinain config gateway` populates this section automatically with your gateway URL/tokens. Selecting openclaw for a lane sends that lane's traffic via WS RPC to the gateway instead of the local bare agent. Disabled by default so first-run installs don't attempt WS connections to a gateway that isn't running.",
|
|
86
93
|
"type": "openclaw",
|
|
87
94
|
"wsUrl": "ws://localhost:18789",
|
|
88
95
|
"wsToken": "${OPENCLAW_WS_TOKEN}",
|
|
@@ -93,13 +100,6 @@
|
|
|
93
100
|
"phase2TimeoutMs": 120000,
|
|
94
101
|
"pingIntervalMs": 30000
|
|
95
102
|
},
|
|
96
|
-
"codex": { "type": "codex" },
|
|
97
|
-
"goose": { "type": "goose" },
|
|
98
|
-
"junie": { "type": "junie" },
|
|
99
|
-
"aider": { "type": "aider" }
|
|
100
|
-
},
|
|
101
|
-
|
|
102
|
-
"_examples": {
|
|
103
103
|
"pclaude": {
|
|
104
104
|
"_comment": "Personal claude config. `bin` must be a real PATH binary — replicate a shell alias as bin+env (the alias `pclaude=CLAUDE_CONFIG_DIR=$HOME/.claude-personal claude` becomes the entry below).",
|
|
105
105
|
"type": "claude",
|
|
@@ -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;
|
|
@@ -99,6 +99,12 @@ export class Escalator {
|
|
|
99
99
|
// Store context from last escalation for response handling
|
|
100
100
|
private lastEscalationContext: ContextWindow | null = null;
|
|
101
101
|
|
|
102
|
+
// Knowledge enrichment is skipped on the very first escalation per process
|
|
103
|
+
// to avoid the 5s fetchKnowledgeFacts() cold-start tax on user-perceived
|
|
104
|
+
// first-response latency. Each subsequent escalation does its own fetch
|
|
105
|
+
// independently — no cross-escalation cache, no shared content state.
|
|
106
|
+
private firstEscalationDone = false;
|
|
107
|
+
|
|
102
108
|
// User command to inject into the next escalation
|
|
103
109
|
private pendingUserCommand: UserCommand | null = null;
|
|
104
110
|
private static readonly USER_COMMAND_EXPIRY_MS = 120_000; // 2 minutes
|
|
@@ -281,8 +287,10 @@ export class Escalator {
|
|
|
281
287
|
// Clear user command after building the message (consumed once)
|
|
282
288
|
this.pendingUserCommand = null;
|
|
283
289
|
|
|
284
|
-
// Enrich with long-term knowledge facts (best-effort, 5s max)
|
|
285
|
-
|
|
290
|
+
// Enrich with long-term knowledge facts (best-effort, 5s max).
|
|
291
|
+
// Skipped on the inaugural escalation per process to eliminate cold-start
|
|
292
|
+
// latency — the user's first response shouldn't wait for KG warmup.
|
|
293
|
+
if (this.deps.queryKnowledgeFacts && this.firstEscalationDone) {
|
|
286
294
|
try {
|
|
287
295
|
const knowledgeSection = await fetchKnowledgeFacts(
|
|
288
296
|
contextWindow, entry.digest, this.deps.queryKnowledgeFacts,
|
|
@@ -294,7 +302,10 @@ export class Escalator {
|
|
|
294
302
|
} catch (err) {
|
|
295
303
|
log(TAG, `knowledge enrichment failed: ${String(err)}`);
|
|
296
304
|
}
|
|
305
|
+
} else if (!this.firstEscalationDone) {
|
|
306
|
+
log(TAG, `first escalation: skipping knowledge fetch (fast path)`);
|
|
297
307
|
}
|
|
308
|
+
this.firstEscalationDone = true;
|
|
298
309
|
|
|
299
310
|
const slotId = createHash("sha256").update(this.deps.openclawConfig.sessionKey + entry.ts).digest("hex").slice(0, 16);
|
|
300
311
|
const slotEntry: SlotEntry = {
|
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
|
@@ -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 #
|
|
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
|