@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 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. Gateway is opt-in (skip by default);
158
- // agent + escalation defaults go to agents.json.
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
- `Escalation: off (pick a gateway agent in the overlay to enable)`,
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 (no gateway by default for non-interactive).
372
- // openclaw profile is left as-is from the example template (or absent
373
- // if first run) gateway is opt-in via the wizard or `sinain config`.
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.15.6",
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"
@@ -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
+ }
@@ -64,7 +64,7 @@
64
64
  },
65
65
 
66
66
  "escalation": {
67
- "mode": "rich",
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": "Remote gateway routing. Selecting openclaw for a lane sends that lane's traffic to the OpenClaw gateway via WS RPC instead of the local bare agent. sinain-core reads these fields directly at startup to construct the WS client. Removing this profile disables the gateway path entirely (no WS client, no openclaw chip in the overlay roster).",
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
- if (this.deps.queryKnowledgeFacts) {
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 = {
@@ -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