@geravant/sinain 1.2.0 → 1.2.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/install.js CHANGED
@@ -279,10 +279,11 @@ async function installLocal() {
279
279
  }
280
280
  }
281
281
 
282
+ const token = cfg?.gateway?.auth?.token ?? "(see openclaw.json)";
282
283
  console.log("\n✓ sinain installed successfully.");
283
284
  console.log(" Plugin config: ~/.openclaw/openclaw.json");
284
- console.log(` Auth token: ${authToken}`);
285
- console.log(" Next: run 'openclaw gateway' in a new terminal, then run ./setup-nemoclaw.sh on your Mac.\n");
285
+ console.log(` Auth token: ${token}`);
286
+ console.log(" Next: run 'openclaw gateway' if not running, or restart to pick up changes.\n");
286
287
  }
287
288
 
288
289
  // ── Knowledge snapshot repo setup (shared) ──────────────────────────────────
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.2.0",
4
- "description": "sinain AI overlay system for macOS (npx @geravant/sinain start)",
3
+ "version": "1.2.1",
4
+ "description": "Ambient AI overlay invisible to screen capture real-time insights from audio + screen context",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sinain": "./cli.js",
@@ -14,7 +14,6 @@ AUDIO_VAD_ENABLED=true
14
14
  AUDIO_VAD_THRESHOLD=0.003
15
15
  AUDIO_AUTO_START=true
16
16
  AUDIO_GAIN_DB=20
17
- # AUDIO_ALT_DEVICE= # alternate device for switch_device command
18
17
 
19
18
  # ── Microphone (opt-in for privacy) ──
20
19
  MIC_ENABLED=false # set true to capture user's microphone
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sinain-core",
3
3
  "version": "1.0.0",
4
- "description": "Unified HUD-sense-audio-bridge-relayreplaces relay + bridge with a single process",
4
+ "description": "SinainHUD coreaudio transcription, agent analysis loop, escalation orchestration, WebSocket feed",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
@@ -228,7 +228,6 @@ export function loadConfig(): CoreConfig {
228
228
  return {
229
229
  port: intEnv("PORT", 9500),
230
230
  audioConfig,
231
- audioAltDevice: env("AUDIO_ALT_DEVICE", "BlackHole 2ch"),
232
231
  micConfig,
233
232
  micEnabled,
234
233
  transcriptionConfig,
@@ -1,4 +1,4 @@
1
- import type { AgentEntry, ContextWindow, EscalationConfig, OpenClawConfig, FeedItem, SpawnTaskMessage, SpawnTaskStatus } from "../types.js";
1
+ import type { AgentEntry, ContextWindow, EscalationConfig, OpenClawConfig, FeedItem, SpawnTaskMessage, SpawnTaskStatus, UserCommand } from "../types.js";
2
2
  import type { FeedBuffer } from "../buffers/feed-buffer.js";
3
3
  import type { WsHandler } from "../overlay/ws-handler.js";
4
4
  import type { Profiler } from "../profiler.js";
@@ -74,6 +74,10 @@ export class Escalator {
74
74
  // Store context from last escalation for response handling
75
75
  private lastEscalationContext: ContextWindow | null = null;
76
76
 
77
+ // User command to inject into the next escalation
78
+ private pendingUserCommand: UserCommand | null = null;
79
+ private static readonly USER_COMMAND_EXPIRY_MS = 120_000; // 2 minutes
80
+
77
81
  private stats = {
78
82
  totalEscalations: 0,
79
83
  totalResponses: 0,
@@ -123,6 +127,15 @@ export class Escalator {
123
127
  this.deps.signalCollector = sc;
124
128
  }
125
129
 
130
+ /** Queue a user command to inject into the next escalation. */
131
+ setUserCommand(text: string, source: "text" | "voice" = "text"): void {
132
+ this.pendingUserCommand = { text, ts: Date.now(), source };
133
+ const preview = text.length > 60 ? text.slice(0, 60) + "…" : text;
134
+ this.deps.feedBuffer.push(`⌘ Command queued: ${preview}`, "normal", "system", "stream");
135
+ this.deps.wsHandler.broadcast(`⌘ Command queued: ${preview}`, "normal");
136
+ log(TAG, `user command set: "${preview}"`);
137
+ }
138
+
126
139
  /** Start the WS connection to OpenClaw (skipped when transport=http). */
127
140
  start(): void {
128
141
  if (this.deps.escalationConfig.mode !== "off" && this.deps.escalationConfig.transport !== "http") {
@@ -161,6 +174,14 @@ export class Escalator {
161
174
  * Decides whether to escalate and enqueues the message for delivery.
162
175
  */
163
176
  async onAgentAnalysis(entry: AgentEntry, contextWindow: ContextWindow): Promise<void> {
177
+ // Expire stale user commands (safety net — 120s is generous)
178
+ if (this.pendingUserCommand && Date.now() - this.pendingUserCommand.ts > Escalator.USER_COMMAND_EXPIRY_MS) {
179
+ warn(TAG, `user command expired after ${Escalator.USER_COMMAND_EXPIRY_MS / 1000}s — no escalation occurred`);
180
+ this.deps.feedBuffer.push("⚠ Command expired — no escalation occurred", "normal", "system", "stream");
181
+ this.deps.wsHandler.broadcast("⚠ Command expired — no escalation occurred", "normal");
182
+ this.pendingUserCommand = null;
183
+ }
184
+
164
185
  // Skip WS escalations when circuit is open (HTTP transport bypasses this)
165
186
  const transport = this.deps.escalationConfig.transport;
166
187
  if (this.wsClient.isCircuitOpen && transport !== "http") {
@@ -168,6 +189,9 @@ export class Escalator {
168
189
  return;
169
190
  }
170
191
 
192
+ // If user command is pending, force escalation (bypass score + cooldown)
193
+ const hasUserCommand = this.pendingUserCommand !== null;
194
+
171
195
  const { escalate, score, stale } = shouldEscalate(
172
196
  entry.digest,
173
197
  entry.hud,
@@ -179,7 +203,7 @@ export class Escalator {
179
203
  this.deps.escalationConfig.staleMs,
180
204
  );
181
205
 
182
- if (!escalate) {
206
+ if (!escalate && !hasUserCommand) {
183
207
  log(TAG, `tick #${entry.id}: not escalating (mode=${this.deps.escalationConfig.mode}, score=${score.total}, hud="${entry.hud.slice(0, 40)}")`);
184
208
  return;
185
209
  }
@@ -192,21 +216,29 @@ export class Escalator {
192
216
  this.lastEscalatedDigest = entry.digest;
193
217
 
194
218
  const staleTag = stale ? ", STALE" : "";
219
+ const cmdTag = hasUserCommand ? ", USER_CMD" : "";
195
220
  const wsState = this.wsClient.isConnected ? "ws=connected" : "ws=disconnected";
196
- log(TAG, `escalating tick #${entry.id} (score=${score.total}, reasons=[${score.reasons.join(",")}]${staleTag}, ${wsState})`);
221
+ log(TAG, `escalating tick #${entry.id} (score=${score.total}, reasons=[${score.reasons.join(",")}]${staleTag}${cmdTag}, ${wsState})`);
197
222
 
198
223
  // Store context for response handling (used in pushResponse for coding-context max-length)
199
224
  this.lastEscalationContext = contextWindow;
200
225
 
201
- const escalationReason = score.reasons.join(", ");
226
+ const escalationReason = hasUserCommand
227
+ ? `user_command: ${this.pendingUserCommand!.text.slice(0, 80)}`
228
+ : score.reasons.join(", ");
202
229
  let message = buildEscalationMessage(
203
230
  entry.digest,
204
231
  contextWindow,
205
232
  entry,
206
233
  this.deps.escalationConfig.mode,
207
234
  escalationReason,
235
+ undefined,
236
+ this.pendingUserCommand ?? undefined,
208
237
  );
209
238
 
239
+ // Clear user command after building the message (consumed once)
240
+ this.pendingUserCommand = null;
241
+
210
242
  // Enrich with long-term knowledge facts (best-effort, 5s max)
211
243
  if (this.deps.queryKnowledgeFacts) {
212
244
  try {
@@ -389,6 +421,7 @@ ${recentLines.join("\n")}`;
389
421
  cooldownMs: this.deps.escalationConfig.cooldownMs,
390
422
  staleMs: this.deps.escalationConfig.staleMs,
391
423
  pendingSpawnTasks: this.pendingSpawnTasks.size,
424
+ pendingUserCommand: this.pendingUserCommand ? this.pendingUserCommand.text.slice(0, 80) : null,
392
425
  ...this.stats,
393
426
  };
394
427
  }
@@ -1,4 +1,4 @@
1
- import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord } from "../types.js";
1
+ import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord, UserCommand } from "../types.js";
2
2
  import { normalizeAppName } from "../agent/context-window.js";
3
3
  import { levelFor, applyLevel } from "../privacy/index.js";
4
4
 
@@ -142,12 +142,18 @@ export function buildEscalationMessage(
142
142
  mode: EscalationMode,
143
143
  escalationReason?: string,
144
144
  recentFeedback?: FeedbackRecord[],
145
+ userCommand?: UserCommand,
145
146
  ): string {
146
147
  const sections: string[] = [];
147
148
 
148
149
  // Header with tick metadata
149
150
  sections.push(`[sinain-hud live context — tick #${entry.id}]`);
150
151
 
152
+ // User command — placed at the top so the agent sees it first
153
+ if (userCommand) {
154
+ sections.push(`## User Command\n> ${userCommand.text}\n\nThe user has explicitly asked you to address the above command. Prioritize it in your response.`);
155
+ }
156
+
151
157
  // Digest (always full)
152
158
  sections.push(`## Digest\n${digest}`);
153
159
 
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { loadConfig } from "./config.js";
2
3
  import { FeedBuffer } from "./buffers/feed-buffer.js";
3
4
  import { SenseBuffer } from "./buffers/sense-buffer.js";
@@ -390,6 +391,9 @@ async function main() {
390
391
  getTraces: (after, limit) => tracer ? tracer.getTraces(after, limit) : [],
391
392
  reconnectGateway: () => escalator.reconnectGateway(),
392
393
 
394
+ // User command injection (bare agent / HTTP)
395
+ setUserCommand: (text: string) => escalator.setUserCommand(text),
396
+
393
397
  // Bare agent HTTP escalation bridge
394
398
  getEscalationPending: () => escalator.getPendingHttp(),
395
399
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
@@ -398,7 +402,7 @@ async function main() {
398
402
  getKnowledgeDocPath: () => {
399
403
  const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
400
404
  const p = `${workspace}/memory/sinain-knowledge.md`;
401
- try { if (require("node:fs").existsSync(p)) return p; } catch {}
405
+ try { if (existsSync(p)) return p; } catch {}
402
406
  return null;
403
407
  },
404
408
  queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
@@ -428,6 +432,9 @@ async function main() {
428
432
  onUserMessage: async (text) => {
429
433
  await escalator.sendDirect(text);
430
434
  },
435
+ onUserCommand: (text) => {
436
+ escalator.setUserCommand(text);
437
+ },
431
438
  onToggleScreen: () => {
432
439
  screenActive = !screenActive;
433
440
  if (!screenActive) {
@@ -13,6 +13,8 @@ export interface CommandDeps {
13
13
  micPipeline: AudioPipeline | null;
14
14
  config: CoreConfig;
15
15
  onUserMessage: (text: string) => Promise<void>;
16
+ /** Queue a user command to augment the next escalation */
17
+ onUserCommand: (text: string) => void;
16
18
  /** Toggle screen capture — returns new state */
17
19
  onToggleScreen: () => boolean;
18
20
  /** Toggle trait voices — returns new enabled state */
@@ -37,6 +39,11 @@ export function setupCommands(deps: CommandDeps): void {
37
39
  }
38
40
  break;
39
41
  }
42
+ case "user_command": {
43
+ log(TAG, `user command received: "${msg.text.slice(0, 60)}"`);
44
+ deps.onUserCommand(msg.text);
45
+ break;
46
+ }
40
47
  case "command": {
41
48
  handleCommand(msg.action, deps);
42
49
  log(TAG, `command processed: ${msg.action}`);
@@ -47,7 +54,7 @@ export function setupCommands(deps: CommandDeps): void {
47
54
  }
48
55
 
49
56
  function handleCommand(action: string, deps: CommandDeps): void {
50
- const { wsHandler, systemAudioPipeline, micPipeline, config } = deps;
57
+ const { wsHandler, systemAudioPipeline, micPipeline } = deps;
51
58
 
52
59
  switch (action) {
53
60
  case "toggle_audio": {
@@ -100,15 +107,6 @@ function handleCommand(action: string, deps: CommandDeps): void {
100
107
  log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
101
108
  break;
102
109
  }
103
- case "switch_device": {
104
- const current = systemAudioPipeline.getDevice();
105
- const alt = config.audioAltDevice;
106
- const next = current === config.audioConfig.device ? alt : config.audioConfig.device;
107
- systemAudioPipeline.switchDevice(next);
108
- wsHandler.broadcast(`Audio device \u2192 ${next}`, "normal");
109
- log(TAG, `audio device switched: ${current} \u2192 ${next}`);
110
- break;
111
- }
112
110
  case "toggle_traits": {
113
111
  if (!deps.onToggleTraits) {
114
112
  wsHandler.broadcast("Trait voices not configured", "normal");
@@ -194,6 +194,9 @@ export class WsHandler {
194
194
  case "command":
195
195
  log(TAG, `\u2190 command: ${msg.action}`);
196
196
  break;
197
+ case "user_command":
198
+ log(TAG, `\u2190 user command: ${msg.text.slice(0, 100)}`);
199
+ break;
197
200
  case "profiling":
198
201
  if (this.onProfilingCb) this.onProfilingCb(msg);
199
202
  return;
@@ -34,6 +34,7 @@ export interface ServerDeps {
34
34
  getTraces: (after: number, limit: number) => unknown[];
35
35
  reconnectGateway: () => void;
36
36
  feedbackStore?: FeedbackStore;
37
+ setUserCommand?: (text: string) => void;
37
38
  getEscalationPending?: () => any;
38
39
  respondEscalation?: (id: string, response: string) => any;
39
40
  getKnowledgeDocPath?: () => string | null;
@@ -305,6 +306,20 @@ export function createAppServer(deps: ServerDeps) {
305
306
  return;
306
307
  }
307
308
 
309
+ // ── /user/command ──
310
+ if (req.method === "POST" && url.pathname === "/user/command") {
311
+ const body = await readBody(req, 4096);
312
+ const { text } = JSON.parse(body);
313
+ if (!text) {
314
+ res.writeHead(400);
315
+ res.end(JSON.stringify({ ok: false, error: "missing text" }));
316
+ return;
317
+ }
318
+ deps.setUserCommand?.(text);
319
+ res.end(JSON.stringify({ ok: true, message: "Command queued for next escalation" }));
320
+ return;
321
+ }
322
+
308
323
  // ── /escalation/pending ──
309
324
  if (req.method === "GET" && url.pathname === "/escalation/pending") {
310
325
  const pending = deps.getEscalationPending?.();
@@ -66,8 +66,21 @@ export interface ProfilingMessage {
66
66
  ts: number;
67
67
  }
68
68
 
69
+ /** Overlay → sinain-core: user command to augment next escalation */
70
+ export interface UserCommandMessage {
71
+ type: "user_command";
72
+ text: string;
73
+ }
74
+
69
75
  export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage;
70
- export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage;
76
+ export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage;
77
+
78
+ /** Abstraction for user commands (text now, voice later). */
79
+ export interface UserCommand {
80
+ text: string;
81
+ ts: number;
82
+ source: "text" | "voice";
83
+ }
71
84
 
72
85
  // ── Feed buffer types ──
73
86
 
@@ -411,7 +424,6 @@ export interface PrivacyConfig {
411
424
  export interface CoreConfig {
412
425
  port: number;
413
426
  audioConfig: AudioPipelineConfig;
414
- audioAltDevice: string;
415
427
  micConfig: AudioPipelineConfig;
416
428
  micEnabled: boolean;
417
429
  transcriptionConfig: TranscriptionConfig;
@@ -367,7 +367,22 @@ server.tool(
367
367
  },
368
368
  );
369
369
 
370
- // 10. sinain_module_guidance
370
+ // 10. sinain_user_command
371
+ server.tool(
372
+ "sinain_user_command",
373
+ "Queue a user command to augment the next escalation context (forces escalation on next agent tick)",
374
+ { text: z.string().describe("The command text to inject into the next escalation") },
375
+ async ({ text }) => {
376
+ try {
377
+ const data = await coreRequest("POST", "/user/command", { text });
378
+ return textResult(JSON.stringify(data, null, 2));
379
+ } catch (err: any) {
380
+ return textResult(`Error queuing user command: ${err.message}`);
381
+ }
382
+ },
383
+ );
384
+
385
+ // 11. sinain_module_guidance
371
386
  server.tool(
372
387
  "sinain_module_guidance",
373
388
  "Read guidance from all active modules in the workspace",
@@ -21,7 +21,12 @@ def query_facts_by_entities(
21
21
  entities: list[str],
22
22
  max_facts: int = 5,
23
23
  ) -> list[dict]:
24
- """Query knowledge graph for facts related to specified entities/domains."""
24
+ """Query knowledge graph for facts matching keywords via tag index.
25
+
26
+ Uses auto-extracted 'tag' attributes for discovery. Results ranked by
27
+ number of matching tags (more matches = more relevant). Falls back to
28
+ domain/entity_id matching for untagged facts.
29
+ """
25
30
  if not Path(db_path).exists():
26
31
  return []
27
32
 
@@ -29,36 +34,56 @@ def query_facts_by_entities(
29
34
  from triplestore import TripleStore
30
35
  store = TripleStore(db_path)
31
36
 
32
- # Find fact entity_ids that match the requested domains or entity names
33
- placeholders = ",".join(["?" for _ in entities])
34
- # Match by domain attribute OR by entity name substring
35
- like_clauses = " OR ".join([f"entity_id LIKE ?" for _ in entities])
36
- entity_likes = [f"fact:{e}%" for e in entities]
37
+ # Normalize keywords for tag matching
38
+ keywords = [e.lower().replace(" ", "-") for e in entities]
39
+ placeholders = ",".join(["?" for _ in keywords])
37
40
 
41
+ # Primary: tag-based ranked search (AVET index)
38
42
  rows = store._conn.execute(
39
- f"""SELECT DISTINCT entity_id FROM triples
40
- WHERE NOT retracted AND (
41
- (attribute = 'domain' AND value IN ({placeholders}))
42
- OR ({like_clauses})
43
- )
43
+ f"""SELECT entity_id, COUNT(*) as matches
44
+ FROM triples
45
+ WHERE attribute = 'tag' AND NOT retracted
46
+ AND value IN ({placeholders})
47
+ GROUP BY entity_id
48
+ ORDER BY matches DESC
44
49
  LIMIT ?""",
45
- (*entities, *entity_likes, max_facts * 3),
50
+ (*keywords, max_facts * 3),
46
51
  ).fetchall()
47
52
 
48
53
  fact_ids = [r["entity_id"] for r in rows]
49
54
 
50
- # Load full attributes for each fact, sorted by confidence
55
+ # Fallback: if tags found < max_facts, also search domain/entity_id (for untagged facts)
56
+ if len(fact_ids) < max_facts:
57
+ domain_placeholders = ",".join(["?" for _ in keywords])
58
+ like_clauses = " OR ".join([f"entity_id LIKE ?" for _ in keywords])
59
+ entity_likes = [f"fact:{kw}%" for kw in keywords]
60
+
61
+ fallback_rows = store._conn.execute(
62
+ f"""SELECT DISTINCT entity_id FROM triples
63
+ WHERE NOT retracted AND entity_id NOT IN ({','.join(['?' for _ in fact_ids]) or "''"})
64
+ AND (
65
+ (attribute = 'domain' AND value IN ({domain_placeholders}))
66
+ OR ({like_clauses})
67
+ )
68
+ LIMIT ?""",
69
+ (*fact_ids, *keywords, *entity_likes, max_facts - len(fact_ids)),
70
+ ).fetchall()
71
+ fact_ids.extend(r["entity_id"] for r in fallback_rows)
72
+
73
+ # Load full attributes for each fact
51
74
  facts = []
52
75
  for fid in fact_ids:
53
76
  attrs = store.entity(fid)
54
77
  if not attrs:
55
78
  continue
56
79
  fact = {"entityId": fid}
57
- for a in attrs:
58
- fact[a["attribute"]] = a["value"]
80
+ for attr_name, values in attrs.items():
81
+ if attr_name == "tag":
82
+ continue # Don't include tags in output (noise)
83
+ fact[attr_name] = values[0] if len(values) == 1 else values
59
84
  facts.append(fact)
60
85
 
61
- # Sort by confidence descending
86
+ # Sort by confidence descending (tag ranking already done in SQL)
62
87
  facts.sort(key=lambda f: float(f.get("confidence", "0")), reverse=True)
63
88
  store.close()
64
89
  return facts[:max_facts]
@@ -93,8 +118,8 @@ def query_top_facts(db_path: str, limit: int = 30) -> list[dict]:
93
118
  if not attrs:
94
119
  continue
95
120
  fact = {"entityId": fid}
96
- for a in attrs:
97
- fact[a["attribute"]] = a["value"]
121
+ for attr_name, values in attrs.items():
122
+ fact[attr_name] = values[0] if len(values) == 1 else values
98
123
  facts.append(fact)
99
124
 
100
125
  store.close()
@@ -18,6 +18,7 @@ Usage:
18
18
  import argparse
19
19
  import hashlib
20
20
  import json
21
+ import re
21
22
  import shutil
22
23
  import sys
23
24
  from datetime import datetime, timezone
@@ -80,6 +81,34 @@ Respond with ONLY a JSON object:
80
81
  }"""
81
82
 
82
83
 
84
+ _STOPWORDS = frozenset({
85
+ "the", "and", "for", "when", "with", "that", "this", "from", "into",
86
+ "after", "before", "during", "should", "would", "could", "been", "have",
87
+ "will", "also", "then", "than", "not", "but", "are", "was", "were",
88
+ "can", "may", "use", "run", "set", "get", "try", "all", "any", "new",
89
+ "score", "seen",
90
+ })
91
+
92
+
93
+ def _extract_tags(value: str) -> list[str]:
94
+ """Extract searchable keyword tags from fact value text.
95
+
96
+ Returns up to 10 deduplicated lowercase tags suitable for AVET-indexed lookup.
97
+ """
98
+ # Lowercase words (including hyphenated compounds like "react-native")
99
+ words = re.findall(r"[a-z][a-z0-9-]+", value.lower())
100
+ tags = [w for w in words if len(w) > 2 and w not in _STOPWORDS]
101
+ # Detect compound terms from CamelCase or "Title Case" patterns
102
+ compounds = re.findall(r"[A-Z][a-z]+ [A-Z][a-z]+", value)
103
+ for c in compounds:
104
+ tags.append(c.lower().replace(" ", "-"))
105
+ # Numeric tokens that look meaningful (error codes, port numbers)
106
+ nums = re.findall(r"\b\d{3,5}\b", value)
107
+ tags.extend(nums)
108
+ # Deduplicate preserving order, cap at 10
109
+ return list(dict.fromkeys(tags))[:10]
110
+
111
+
83
112
  def _fact_id(entity: str, attribute: str, value: str) -> str:
84
113
  """Generate a deterministic fact entity ID from entity+attribute+value."""
85
114
  content = f"{entity}:{attribute}:{value}"
@@ -99,14 +128,19 @@ def _load_graph_facts(db_path: str, entities: list[str] | None = None, limit: in
99
128
 
100
129
  # Get all non-retracted fact entities with their attributes
101
130
  if entities:
102
- # Entity-scoped query: find facts related to specified domains
103
- domain_clause = " OR ".join([f"value = ?" for _ in entities])
131
+ # Tag-based search: find facts whose tags match any of the keywords
132
+ # Normalize keywords to lowercase for tag matching
133
+ keywords = [e.lower().replace(" ", "-") for e in entities]
134
+ placeholders = ",".join(["?" for _ in keywords])
104
135
  rows = store._conn.execute(
105
- f"""SELECT DISTINCT entity_id FROM triples
106
- WHERE attribute = 'domain' AND NOT retracted
107
- AND ({domain_clause})
136
+ f"""SELECT entity_id, COUNT(*) as matches
137
+ FROM triples
138
+ WHERE attribute = 'tag' AND NOT retracted
139
+ AND value IN ({placeholders})
140
+ GROUP BY entity_id
141
+ ORDER BY matches DESC
108
142
  LIMIT ?""",
109
- (*entities, limit),
143
+ (*keywords, limit),
110
144
  ).fetchall()
111
145
  fact_ids = [r["entity_id"] for r in rows]
112
146
  else:
@@ -127,8 +161,8 @@ def _load_graph_facts(db_path: str, entities: list[str] | None = None, limit: in
127
161
  attrs = store.entity(fid)
128
162
  if attrs:
129
163
  fact = {"entityId": fid}
130
- for a in attrs:
131
- fact[a["attribute"]] = a["value"]
164
+ for attr_name, values in attrs.items():
165
+ fact[attr_name] = values[0] if len(values) == 1 else values
132
166
  facts.append(fact)
133
167
 
134
168
  store.close()
@@ -172,6 +206,9 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
172
206
  store.assert_triple(tx, entity_id, "reinforce_count", "1")
173
207
  if domain:
174
208
  store.assert_triple(tx, entity_id, "domain", domain)
209
+ # Auto-tag for keyword-based discovery
210
+ for tag in _extract_tags(value):
211
+ store.assert_triple(tx, entity_id, "tag", tag)
175
212
  stats["asserted"] += 1
176
213
 
177
214
  elif op == "reinforce":
@@ -186,16 +223,15 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
186
223
 
187
224
  cur_conf = 0.5
188
225
  cur_count = 0
189
- for a in attrs:
190
- if a["attribute"] == "confidence":
191
- try:
192
- cur_conf = float(a["value"])
193
- except ValueError:
194
- pass
195
- elif a["attribute"] == "reinforce_count":
196
- try:
197
- cur_count = int(a["value"])
198
- except ValueError:
226
+ if "confidence" in attrs:
227
+ try:
228
+ cur_conf = float(attrs["confidence"][0])
229
+ except (ValueError, IndexError):
230
+ pass
231
+ if "reinforce_count" in attrs:
232
+ try:
233
+ cur_count = int(attrs["reinforce_count"][0])
234
+ except (ValueError, IndexError):
199
235
  pass
200
236
 
201
237
  new_conf = min(1.0, cur_conf + 0.15)
@@ -209,7 +245,10 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
209
245
  store.assert_triple(tx, entity_id, "confidence", str(round(new_conf, 2)))
210
246
  store.retract_triple(tx, entity_id, "reinforce_count", str(cur_count))
211
247
  store.assert_triple(tx, entity_id, "reinforce_count", str(new_count))
212
- store.retract_triple(tx, entity_id, "last_reinforced", "") # retract any
248
+ # Retract old last_reinforced if present
249
+ old_reinforced = attrs.get("last_reinforced", [])
250
+ for val in old_reinforced:
251
+ store.retract_triple(tx, entity_id, "last_reinforced", val)
213
252
  store.assert_triple(tx, entity_id, "last_reinforced", digest_ts)
214
253
  stats["reinforced"] += 1
215
254
 
@@ -224,8 +263,9 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
224
263
  }))
225
264
  # Retract all attributes of this entity
226
265
  attrs = store.entity(entity_id)
227
- for a in attrs:
228
- store.retract_triple(tx, entity_id, a["attribute"], a["value"])
266
+ for attr_name, values in attrs.items():
267
+ for val in values:
268
+ store.retract_triple(tx, entity_id, attr_name, val)
229
269
  stats["retracted"] += 1
230
270
 
231
271
  store.close()
@@ -335,6 +375,7 @@ def main() -> None:
335
375
  parser.add_argument("--memory-dir", required=True, help="Path to memory/ directory")
336
376
  parser.add_argument("--digest", default=None, help="SessionDigest JSON string")
337
377
  parser.add_argument("--bootstrap", action="store_true", help="One-time: seed graph from playbook")
378
+ parser.add_argument("--retag", action="store_true", help="Re-extract tags for all existing facts")
338
379
  args = parser.parse_args()
339
380
 
340
381
  memory_dir = args.memory_dir
@@ -346,9 +387,37 @@ def main() -> None:
346
387
  output_json(result)
347
388
  return
348
389
 
390
+ # Retag mode: extract tags for all existing facts
391
+ if args.retag:
392
+ if not Path(db_path).exists():
393
+ output_json({"error": "knowledge-graph.db not found"})
394
+ return
395
+ from triplestore import TripleStore
396
+ store = TripleStore(db_path)
397
+ # Get all fact entities that have a 'value' attribute
398
+ rows = store._conn.execute(
399
+ "SELECT DISTINCT entity_id FROM triples WHERE attribute = 'value' AND NOT retracted AND entity_id LIKE 'fact:%'"
400
+ ).fetchall()
401
+ tagged = 0
402
+ for row in rows:
403
+ fid = row["entity_id"]
404
+ attrs = store.entity(fid)
405
+ value_text = attrs.get("value", [""])[0] if attrs else ""
406
+ existing_tags = set(attrs.get("tag", [])) if attrs else set()
407
+ new_tags = _extract_tags(value_text)
408
+ missing = [t for t in new_tags if t not in existing_tags]
409
+ if missing:
410
+ tx = store.begin_tx("retag", metadata=json.dumps({"entity_id": fid}))
411
+ for tag in missing:
412
+ store.assert_triple(tx, fid, "tag", tag)
413
+ tagged += 1
414
+ store.close()
415
+ output_json({"retagged": tagged, "total_facts": len(rows)})
416
+ return
417
+
349
418
  # Normal mode: integrate session digest
350
419
  if not args.digest:
351
- print("--digest is required (unless --bootstrap)", file=sys.stderr)
420
+ print("--digest is required (unless --bootstrap or --retag)", file=sys.stderr)
352
421
  output_json({"error": "--digest required"})
353
422
  return
354
423