@geravant/sinain 1.0.19 → 1.2.0
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/README.md +10 -1
- package/cli.js +176 -0
- package/index.ts +4 -2
- package/install.js +89 -14
- package/launcher.js +622 -0
- package/openclaw.plugin.json +4 -0
- package/pack-prepare.js +48 -0
- package/package.json +24 -5
- package/sense_client/README.md +82 -0
- package/sense_client/__init__.py +1 -0
- package/sense_client/__main__.py +462 -0
- package/sense_client/app_detector.py +54 -0
- package/sense_client/app_detector_win.py +83 -0
- package/sense_client/capture.py +215 -0
- package/sense_client/capture_win.py +88 -0
- package/sense_client/change_detector.py +86 -0
- package/sense_client/config.py +64 -0
- package/sense_client/gate.py +145 -0
- package/sense_client/ocr.py +347 -0
- package/sense_client/privacy.py +65 -0
- package/sense_client/requirements.txt +13 -0
- package/sense_client/roi_extractor.py +84 -0
- package/sense_client/sender.py +173 -0
- package/sense_client/tests/__init__.py +0 -0
- package/sense_client/tests/test_stream1_optimizations.py +234 -0
- package/setup-overlay.js +82 -0
- package/sinain-agent/.env.example +17 -0
- package/sinain-agent/CLAUDE.md +87 -0
- package/sinain-agent/mcp-config.json +12 -0
- package/sinain-agent/run.sh +248 -0
- package/sinain-core/.env.example +93 -0
- package/sinain-core/package-lock.json +552 -0
- package/sinain-core/package.json +21 -0
- package/sinain-core/src/agent/analyzer.ts +366 -0
- package/sinain-core/src/agent/context-window.ts +172 -0
- package/sinain-core/src/agent/loop.ts +404 -0
- package/sinain-core/src/agent/situation-writer.ts +187 -0
- package/sinain-core/src/agent/traits.ts +520 -0
- package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
- package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
- package/sinain-core/src/audio/capture-spawner.ts +14 -0
- package/sinain-core/src/audio/pipeline.ts +335 -0
- package/sinain-core/src/audio/transcription-local.ts +141 -0
- package/sinain-core/src/audio/transcription.ts +278 -0
- package/sinain-core/src/buffers/feed-buffer.ts +71 -0
- package/sinain-core/src/buffers/sense-buffer.ts +425 -0
- package/sinain-core/src/config.ts +245 -0
- package/sinain-core/src/escalation/escalation-slot.ts +136 -0
- package/sinain-core/src/escalation/escalator.ts +828 -0
- package/sinain-core/src/escalation/message-builder.ts +370 -0
- package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
- package/sinain-core/src/escalation/scorer.ts +166 -0
- package/sinain-core/src/index.ts +537 -0
- package/sinain-core/src/learning/feedback-store.ts +253 -0
- package/sinain-core/src/learning/signal-collector.ts +218 -0
- package/sinain-core/src/log.ts +24 -0
- package/sinain-core/src/overlay/commands.ts +126 -0
- package/sinain-core/src/overlay/ws-handler.ts +267 -0
- package/sinain-core/src/privacy/index.ts +18 -0
- package/sinain-core/src/privacy/presets.ts +40 -0
- package/sinain-core/src/privacy/redact.ts +92 -0
- package/sinain-core/src/profiler.ts +181 -0
- package/sinain-core/src/recorder.ts +186 -0
- package/sinain-core/src/server.ts +456 -0
- package/sinain-core/src/trace/trace-store.ts +73 -0
- package/sinain-core/src/trace/tracer.ts +94 -0
- package/sinain-core/src/types.ts +427 -0
- package/sinain-core/src/util/dedup.ts +48 -0
- package/sinain-core/src/util/task-store.ts +84 -0
- package/sinain-core/tsconfig.json +18 -0
- package/sinain-knowledge/curation/engine.ts +137 -24
- package/sinain-knowledge/data/git-store.ts +26 -0
- package/sinain-knowledge/data/store.ts +117 -0
- package/sinain-mcp-server/index.ts +417 -0
- package/sinain-mcp-server/package.json +19 -0
- package/sinain-mcp-server/tsconfig.json +15 -0
- package/sinain-memory/graph_query.py +185 -0
- package/sinain-memory/knowledge_integrator.py +450 -0
- package/sinain-memory/memory-config.json +3 -1
- package/sinain-memory/session_distiller.py +162 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { FeedItem, SenseEvent, RecordCommand, RecorderStatus, StopResult } from "./types.js";
|
|
2
|
+
import { log, warn } from "./log.js";
|
|
3
|
+
|
|
4
|
+
const TAG = "recorder";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recorder collects audio transcripts during a recording session.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* - handleCommand({command: "start", label: "Meeting"}) → starts recording
|
|
11
|
+
* - onFeedItem() → called for each audio transcript, collects if recording
|
|
12
|
+
* - onSenseEvent() → tracks app context for title detection
|
|
13
|
+
* - handleCommand({command: "stop"}) → stops recording, returns StopResult with transcript
|
|
14
|
+
* - getStatus() → returns current RecorderStatus for prompt injection
|
|
15
|
+
*/
|
|
16
|
+
export class Recorder {
|
|
17
|
+
private recording = false;
|
|
18
|
+
private label: string | null = null;
|
|
19
|
+
private startedAt: number | null = null;
|
|
20
|
+
private segments: { text: string; ts: number }[] = [];
|
|
21
|
+
private lastApp: string = "";
|
|
22
|
+
private lastWindowTitle: string = "";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Handle a record command from the analyzer.
|
|
26
|
+
* Returns StopResult on stop if there are segments, otherwise undefined.
|
|
27
|
+
*/
|
|
28
|
+
handleCommand(cmd: RecordCommand | undefined): StopResult | undefined {
|
|
29
|
+
if (!cmd) return undefined;
|
|
30
|
+
|
|
31
|
+
if (cmd.command === "start") {
|
|
32
|
+
return this.start(cmd.label);
|
|
33
|
+
} else if (cmd.command === "stop") {
|
|
34
|
+
return this.stop();
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a new recording session.
|
|
41
|
+
*/
|
|
42
|
+
private start(label?: string): undefined {
|
|
43
|
+
if (this.recording) {
|
|
44
|
+
log(TAG, `already recording "${this.label}" — ignoring start`);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.recording = true;
|
|
49
|
+
this.label = label || null;
|
|
50
|
+
this.startedAt = Date.now();
|
|
51
|
+
this.segments = [];
|
|
52
|
+
|
|
53
|
+
const labelStr = label ? ` "${label}"` : "";
|
|
54
|
+
log(TAG, `started recording${labelStr}`);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Stop recording and return the collected transcript.
|
|
60
|
+
*/
|
|
61
|
+
private stop(): StopResult | undefined {
|
|
62
|
+
if (!this.recording) {
|
|
63
|
+
log(TAG, "not recording — ignoring stop");
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const title = this.buildTitle();
|
|
68
|
+
const transcript = this.buildTranscript();
|
|
69
|
+
const segments = this.segments.length;
|
|
70
|
+
const durationS = this.startedAt ? Math.round((Date.now() - this.startedAt) / 1000) : 0;
|
|
71
|
+
|
|
72
|
+
// Reset state
|
|
73
|
+
this.recording = false;
|
|
74
|
+
this.label = null;
|
|
75
|
+
this.startedAt = null;
|
|
76
|
+
this.segments = [];
|
|
77
|
+
|
|
78
|
+
log(TAG, `stopped recording: "${title}" (${segments} segments, ${durationS}s)`);
|
|
79
|
+
|
|
80
|
+
if (segments === 0) {
|
|
81
|
+
log(TAG, "no segments captured — returning undefined");
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { title, transcript, segments, durationS };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Called for each audio FeedItem. Collects if recording.
|
|
90
|
+
*/
|
|
91
|
+
onFeedItem(item: FeedItem): void {
|
|
92
|
+
if (!this.recording) return;
|
|
93
|
+
if (item.source !== "audio") return;
|
|
94
|
+
if (!item.text || item.text.trim().length === 0) return;
|
|
95
|
+
|
|
96
|
+
// Strip audio source prefixes if present ([📝], [🔊], [🎤])
|
|
97
|
+
let text = item.text;
|
|
98
|
+
if (text.startsWith("[📝] ")) {
|
|
99
|
+
text = text.slice(5);
|
|
100
|
+
} else if (text.startsWith("[🔊] ")) {
|
|
101
|
+
text = text.slice(5);
|
|
102
|
+
} else if (text.startsWith("[🎤] ")) {
|
|
103
|
+
text = text.slice(5);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this.segments.push({ text: text.trim(), ts: item.ts });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Called for each SenseEvent. Tracks app context for title.
|
|
111
|
+
*/
|
|
112
|
+
onSenseEvent(event: SenseEvent): void {
|
|
113
|
+
if (event.meta.app) {
|
|
114
|
+
this.lastApp = event.meta.app;
|
|
115
|
+
}
|
|
116
|
+
if (event.meta.windowTitle) {
|
|
117
|
+
this.lastWindowTitle = event.meta.windowTitle;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get current recorder status for prompt injection.
|
|
123
|
+
*/
|
|
124
|
+
getStatus(): RecorderStatus {
|
|
125
|
+
return {
|
|
126
|
+
recording: this.recording,
|
|
127
|
+
label: this.label,
|
|
128
|
+
startedAt: this.startedAt,
|
|
129
|
+
segments: this.segments.length,
|
|
130
|
+
durationMs: this.startedAt ? Date.now() - this.startedAt : 0,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build a title from label or app context.
|
|
136
|
+
*/
|
|
137
|
+
private buildTitle(): string {
|
|
138
|
+
if (this.label) return this.label;
|
|
139
|
+
|
|
140
|
+
// Try to build from app context
|
|
141
|
+
const app = this.lastApp.replace(/\.app$/i, "").trim();
|
|
142
|
+
if (app) {
|
|
143
|
+
// Common meeting apps
|
|
144
|
+
if (/zoom|meet|teams|slack|discord/i.test(app)) {
|
|
145
|
+
return `${app} call`;
|
|
146
|
+
}
|
|
147
|
+
return `Recording in ${app}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Fallback with timestamp
|
|
151
|
+
const now = new Date();
|
|
152
|
+
return `Recording ${now.toLocaleDateString()} ${now.toLocaleTimeString()}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build transcript from collected segments.
|
|
157
|
+
* Format: [MM:SS] text
|
|
158
|
+
*/
|
|
159
|
+
private buildTranscript(): string {
|
|
160
|
+
if (this.segments.length === 0) return "";
|
|
161
|
+
if (!this.startedAt) return this.segments.map(s => s.text).join("\n");
|
|
162
|
+
|
|
163
|
+
const baseTs = this.startedAt;
|
|
164
|
+
return this.segments
|
|
165
|
+
.map(s => {
|
|
166
|
+
const offsetMs = s.ts - baseTs;
|
|
167
|
+
const offsetSec = Math.max(0, Math.floor(offsetMs / 1000));
|
|
168
|
+
const min = Math.floor(offsetSec / 60);
|
|
169
|
+
const sec = offsetSec % 60;
|
|
170
|
+
const timestamp = `[${String(min).padStart(2, "0")}:${String(sec).padStart(2, "0")}]`;
|
|
171
|
+
return `${timestamp} ${s.text}`;
|
|
172
|
+
})
|
|
173
|
+
.join("\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Force stop recording (e.g., on shutdown).
|
|
178
|
+
*/
|
|
179
|
+
forceStop(): StopResult | undefined {
|
|
180
|
+
if (this.recording) {
|
|
181
|
+
warn(TAG, "force stopping recording");
|
|
182
|
+
return this.stop();
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
3
|
+
import type { CoreConfig, SenseEvent } from "./types.js";
|
|
4
|
+
import type { Profiler } from "./profiler.js";
|
|
5
|
+
import type { FeedbackStore } from "./learning/feedback-store.js";
|
|
6
|
+
import { FeedBuffer } from "./buffers/feed-buffer.js";
|
|
7
|
+
import { SenseBuffer, type SemanticSenseEvent, type TextDelta } from "./buffers/sense-buffer.js";
|
|
8
|
+
import { WsHandler } from "./overlay/ws-handler.js";
|
|
9
|
+
import { log, error } from "./log.js";
|
|
10
|
+
|
|
11
|
+
const TAG = "server";
|
|
12
|
+
const MAX_SENSE_BODY = 2 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
/** Server epoch — lets clients detect restarts. */
|
|
15
|
+
const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
16
|
+
|
|
17
|
+
export interface ServerDeps {
|
|
18
|
+
config: CoreConfig;
|
|
19
|
+
feedBuffer: FeedBuffer;
|
|
20
|
+
senseBuffer: SenseBuffer;
|
|
21
|
+
wsHandler: WsHandler;
|
|
22
|
+
profiler?: Profiler;
|
|
23
|
+
onSenseEvent: (event: SenseEvent) => void;
|
|
24
|
+
onSenseDelta?: (data: { app: string; activity: string; changes: TextDelta[]; priority?: string; ts: number }) => void;
|
|
25
|
+
onFeedPost: (text: string, priority: string) => void;
|
|
26
|
+
isScreenActive: () => boolean;
|
|
27
|
+
onSenseProfile: (snapshot: any) => void;
|
|
28
|
+
getHealthPayload: () => Record<string, unknown>;
|
|
29
|
+
getAgentDigest: () => unknown;
|
|
30
|
+
getAgentHistory: (limit: number) => unknown[];
|
|
31
|
+
getAgentContext: () => unknown;
|
|
32
|
+
getAgentConfig: () => unknown;
|
|
33
|
+
updateAgentConfig: (updates: Record<string, unknown>) => unknown;
|
|
34
|
+
getTraces: (after: number, limit: number) => unknown[];
|
|
35
|
+
reconnectGateway: () => void;
|
|
36
|
+
feedbackStore?: FeedbackStore;
|
|
37
|
+
getEscalationPending?: () => any;
|
|
38
|
+
respondEscalation?: (id: string, response: string) => any;
|
|
39
|
+
getKnowledgeDocPath?: () => string | null;
|
|
40
|
+
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let body = "";
|
|
46
|
+
let bytes = 0;
|
|
47
|
+
req.on("data", (chunk: Buffer) => {
|
|
48
|
+
bytes += chunk.length;
|
|
49
|
+
if (bytes > maxBytes) {
|
|
50
|
+
reject(new Error("body too large"));
|
|
51
|
+
req.destroy();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
body += chunk;
|
|
55
|
+
});
|
|
56
|
+
req.on("end", () => resolve(body));
|
|
57
|
+
req.on("error", reject);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createAppServer(deps: ServerDeps) {
|
|
62
|
+
const { config, feedBuffer, senseBuffer, wsHandler } = deps;
|
|
63
|
+
let senseInBytes = 0;
|
|
64
|
+
|
|
65
|
+
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
66
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
67
|
+
res.setHeader("Content-Type", "application/json");
|
|
68
|
+
|
|
69
|
+
if (req.method === "OPTIONS") {
|
|
70
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
71
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
72
|
+
res.writeHead(204);
|
|
73
|
+
res.end();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const url = new URL(req.url || "/", `http://localhost:${config.port}`);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// ── /sense ──
|
|
81
|
+
if (req.method === "POST" && url.pathname === "/sense") {
|
|
82
|
+
if (!deps.isScreenActive()) {
|
|
83
|
+
res.end(JSON.stringify({ ok: true, gated: true }));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const body = await readBody(req, MAX_SENSE_BODY);
|
|
87
|
+
senseInBytes += Buffer.byteLength(body);
|
|
88
|
+
deps.profiler?.gauge("network.senseInBytes", senseInBytes);
|
|
89
|
+
const data = JSON.parse(body);
|
|
90
|
+
if (!data.type || data.ts === undefined) {
|
|
91
|
+
res.writeHead(400);
|
|
92
|
+
res.end(JSON.stringify({ ok: false, error: "missing type or ts" }));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// Extract image data from ROI if present
|
|
96
|
+
const imageData = data.roi?.data || undefined;
|
|
97
|
+
const imageBbox = data.roi?.bbox || undefined;
|
|
98
|
+
|
|
99
|
+
const event = senseBuffer.push({
|
|
100
|
+
type: data.type,
|
|
101
|
+
ts: data.ts,
|
|
102
|
+
ocr: data.ocr || "",
|
|
103
|
+
imageData,
|
|
104
|
+
imageBbox,
|
|
105
|
+
meta: {
|
|
106
|
+
ssim: data.meta?.ssim ?? 0,
|
|
107
|
+
app: data.meta?.app || "unknown",
|
|
108
|
+
windowTitle: data.meta?.windowTitle,
|
|
109
|
+
screen: data.meta?.screen ?? 0,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (event) {
|
|
113
|
+
log(TAG, `[sense] #${event.id} (${event.type}): app=${event.meta.app} ssim=${event.meta.ssim?.toFixed(3)}`);
|
|
114
|
+
deps.onSenseEvent(event);
|
|
115
|
+
res.end(JSON.stringify({ ok: true, id: event.id }));
|
|
116
|
+
} else {
|
|
117
|
+
// Event was deduplicated
|
|
118
|
+
res.end(JSON.stringify({ ok: true, deduplicated: true }));
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (req.method === "GET" && url.pathname === "/sense") {
|
|
124
|
+
const after = parseInt(url.searchParams.get("after") || "0");
|
|
125
|
+
const metaOnly = url.searchParams.get("meta_only") === "true";
|
|
126
|
+
const events = senseBuffer.query(after, metaOnly);
|
|
127
|
+
res.end(JSON.stringify({ events, epoch: serverEpoch }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── /sense/context (structured semantic context) ──
|
|
132
|
+
if (req.method === "GET" && url.pathname === "/sense/context") {
|
|
133
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "10"), 50);
|
|
134
|
+
const includeDeltas = url.searchParams.get("include_deltas") === "true";
|
|
135
|
+
const includeSummary = url.searchParams.get("include_summary") !== "false";
|
|
136
|
+
const context = senseBuffer.getStructuredContext({
|
|
137
|
+
limit,
|
|
138
|
+
includeDeltas,
|
|
139
|
+
includeSummary,
|
|
140
|
+
});
|
|
141
|
+
res.end(JSON.stringify({ ok: true, context, epoch: serverEpoch }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── /sense/activity (activity breakdown) ──
|
|
146
|
+
if (req.method === "GET" && url.pathname === "/sense/activity") {
|
|
147
|
+
const since = parseInt(url.searchParams.get("since") || "0");
|
|
148
|
+
const breakdown = senseBuffer.getActivityBreakdown(since);
|
|
149
|
+
res.end(JSON.stringify({
|
|
150
|
+
ok: true,
|
|
151
|
+
activity: senseBuffer.latestActivity(),
|
|
152
|
+
breakdown,
|
|
153
|
+
epoch: serverEpoch,
|
|
154
|
+
}));
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── /sense/deltas (accumulated deltas) ──
|
|
159
|
+
if (req.method === "GET" && url.pathname === "/sense/deltas") {
|
|
160
|
+
const flush = url.searchParams.get("flush") === "true";
|
|
161
|
+
const deltas = senseBuffer.getAccumulatedDeltas(flush);
|
|
162
|
+
res.end(JSON.stringify({ ok: true, deltas, count: deltas.length }));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── /feed ──
|
|
167
|
+
if (req.method === "GET" && url.pathname === "/feed") {
|
|
168
|
+
const after = parseInt(url.searchParams.get("after") || "0");
|
|
169
|
+
const items = feedBuffer.query(after);
|
|
170
|
+
res.end(JSON.stringify({ messages: items, epoch: serverEpoch }));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (req.method === "POST" && url.pathname === "/feed") {
|
|
175
|
+
const body = await readBody(req, 65536);
|
|
176
|
+
const { text, priority } = JSON.parse(body);
|
|
177
|
+
deps.onFeedPost(text, priority || "normal");
|
|
178
|
+
res.end(JSON.stringify({ ok: true }));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── /agent ──
|
|
183
|
+
if (req.method === "GET" && url.pathname === "/agent/digest") {
|
|
184
|
+
res.end(JSON.stringify({ ok: true, digest: deps.getAgentDigest() }));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (req.method === "GET" && url.pathname === "/agent/history") {
|
|
189
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "10"), 50);
|
|
190
|
+
res.end(JSON.stringify({ ok: true, results: deps.getAgentHistory(limit) }));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (req.method === "GET" && url.pathname === "/agent/context") {
|
|
195
|
+
res.end(JSON.stringify({ ok: true, context: deps.getAgentContext() }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (req.method === "GET" && url.pathname === "/agent/config") {
|
|
200
|
+
res.end(JSON.stringify({ ok: true, config: deps.getAgentConfig() }));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (req.method === "POST" && url.pathname === "/agent/config") {
|
|
205
|
+
const body = await readBody(req, 4096);
|
|
206
|
+
const updates = JSON.parse(body);
|
|
207
|
+
const result = deps.updateAgentConfig(updates);
|
|
208
|
+
res.end(JSON.stringify({ ok: true, config: result }));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── /knowledge ──
|
|
213
|
+
if (req.method === "GET" && url.pathname === "/knowledge") {
|
|
214
|
+
// Return portable knowledge document
|
|
215
|
+
const knowledgePath = deps.getKnowledgeDocPath?.();
|
|
216
|
+
if (knowledgePath) {
|
|
217
|
+
try {
|
|
218
|
+
const { readFileSync } = await import("node:fs");
|
|
219
|
+
const content = readFileSync(knowledgePath, "utf-8");
|
|
220
|
+
res.end(JSON.stringify({ ok: true, content }));
|
|
221
|
+
} catch {
|
|
222
|
+
res.end(JSON.stringify({ ok: true, content: "" }));
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
res.end(JSON.stringify({ ok: true, content: "" }));
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (req.method === "GET" && url.pathname === "/knowledge/facts") {
|
|
231
|
+
// Query knowledge graph for entity-matched facts
|
|
232
|
+
const entitiesParam = url.searchParams.get("entities") || "";
|
|
233
|
+
const maxFacts = Math.min(parseInt(url.searchParams.get("max") || "5"), 20);
|
|
234
|
+
const entities = entitiesParam.split(",").map(e => e.trim()).filter(Boolean);
|
|
235
|
+
|
|
236
|
+
if (deps.queryKnowledgeFacts) {
|
|
237
|
+
try {
|
|
238
|
+
const facts = await deps.queryKnowledgeFacts(entities, maxFacts);
|
|
239
|
+
res.end(JSON.stringify({ ok: true, facts }));
|
|
240
|
+
} catch (err) {
|
|
241
|
+
res.end(JSON.stringify({ ok: true, facts: [], error: String(err) }));
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
res.end(JSON.stringify({ ok: true, facts: [] }));
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── /traces ──
|
|
250
|
+
if (req.method === "GET" && url.pathname === "/traces") {
|
|
251
|
+
const after = parseInt(url.searchParams.get("after") || "0");
|
|
252
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 500);
|
|
253
|
+
res.end(JSON.stringify({ traces: deps.getTraces(after, limit) }));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── /profiling/sense ──
|
|
258
|
+
if (req.method === "POST" && url.pathname === "/profiling/sense") {
|
|
259
|
+
const body = await readBody(req, 4096);
|
|
260
|
+
deps.onSenseProfile(JSON.parse(body));
|
|
261
|
+
res.end(JSON.stringify({ ok: true }));
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── /learning/feedback ──
|
|
266
|
+
if (req.method === "GET" && url.pathname === "/learning/feedback") {
|
|
267
|
+
if (!deps.feedbackStore) {
|
|
268
|
+
res.end(JSON.stringify({ ok: false, error: "learning disabled" }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") || "20"), 100);
|
|
272
|
+
const records = deps.feedbackStore.queryRecent(limit);
|
|
273
|
+
res.end(JSON.stringify({ ok: true, records, count: records.length }));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── /learning/stats ──
|
|
278
|
+
if (req.method === "GET" && url.pathname === "/learning/stats") {
|
|
279
|
+
if (!deps.feedbackStore) {
|
|
280
|
+
res.end(JSON.stringify({ ok: false, error: "learning disabled" }));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const stats = deps.feedbackStore.getStats();
|
|
284
|
+
res.end(JSON.stringify({ ok: true, ...stats }));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── /reconnect-gateway ──
|
|
289
|
+
if (req.method === "POST" && url.pathname === "/reconnect-gateway") {
|
|
290
|
+
deps.reconnectGateway();
|
|
291
|
+
res.end(JSON.stringify({ ok: true, message: "gateway reconnection initiated" }));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── /health ──
|
|
296
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
297
|
+
res.end(JSON.stringify({
|
|
298
|
+
ok: true,
|
|
299
|
+
epoch: serverEpoch,
|
|
300
|
+
messages: feedBuffer.size,
|
|
301
|
+
senseEvents: senseBuffer.size,
|
|
302
|
+
overlayClients: wsHandler.clientCount,
|
|
303
|
+
...deps.getHealthPayload(),
|
|
304
|
+
}));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── /escalation/pending ──
|
|
309
|
+
if (req.method === "GET" && url.pathname === "/escalation/pending") {
|
|
310
|
+
const pending = deps.getEscalationPending?.();
|
|
311
|
+
res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── /escalation/respond ──
|
|
316
|
+
if (req.method === "POST" && url.pathname === "/escalation/respond") {
|
|
317
|
+
const body = await readBody(req, 65536);
|
|
318
|
+
const { id, response } = JSON.parse(body);
|
|
319
|
+
if (!id || !response) {
|
|
320
|
+
res.writeHead(400);
|
|
321
|
+
res.end(JSON.stringify({ ok: false, error: "missing id or response" }));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const result = deps.respondEscalation?.(id, response) ?? { ok: false, error: "escalation not configured" };
|
|
325
|
+
res.end(JSON.stringify(result));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
res.writeHead(404);
|
|
330
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
331
|
+
} catch (err: any) {
|
|
332
|
+
const status = err.message === "body too large" ? 413 : 400;
|
|
333
|
+
res.writeHead(status);
|
|
334
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Attach WS server on the same HTTP server
|
|
339
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
340
|
+
wss.on("connection", (ws, req) => {
|
|
341
|
+
const pathname = new URL(req.url || "/", `http://localhost:${config.port}`).pathname;
|
|
342
|
+
|
|
343
|
+
// Sense WebSocket endpoint for low-latency event streaming
|
|
344
|
+
if (pathname === "/sense/ws") {
|
|
345
|
+
log(TAG, "[sense/ws] client connected");
|
|
346
|
+
|
|
347
|
+
// Backpressure tracking
|
|
348
|
+
let pendingAcks = 0;
|
|
349
|
+
const MAX_PENDING = 5;
|
|
350
|
+
|
|
351
|
+
ws.on("message", (data) => {
|
|
352
|
+
try {
|
|
353
|
+
const msg = JSON.parse(data.toString());
|
|
354
|
+
|
|
355
|
+
// Gate sense ingestion when screen is toggled off
|
|
356
|
+
if (!deps.isScreenActive()) {
|
|
357
|
+
ws.send(JSON.stringify({ type: "ack", gated: true }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Handle different message types
|
|
362
|
+
if (msg.type === "delta") {
|
|
363
|
+
// Delta-only update (new semantic format)
|
|
364
|
+
senseBuffer.pushDelta({
|
|
365
|
+
app: msg.app || "unknown",
|
|
366
|
+
activity: msg.activity || "unknown",
|
|
367
|
+
changes: msg.changes || [],
|
|
368
|
+
priority: msg.priority,
|
|
369
|
+
ts: msg.ts || Date.now(),
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Trigger immediate context update for urgent priority
|
|
373
|
+
if (msg.priority === "urgent") {
|
|
374
|
+
deps.onSenseDelta?.(msg);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Send ack with backpressure signal
|
|
378
|
+
pendingAcks++;
|
|
379
|
+
const backpressure = pendingAcks > MAX_PENDING ? 100 : 0;
|
|
380
|
+
ws.send(JSON.stringify({ type: "ack", backpressure }));
|
|
381
|
+
pendingAcks = Math.max(0, pendingAcks - 1);
|
|
382
|
+
|
|
383
|
+
} else {
|
|
384
|
+
// Full event (backwards compatible)
|
|
385
|
+
const imageData = msg.roi?.data || undefined;
|
|
386
|
+
const imageBbox = msg.roi?.bbox || undefined;
|
|
387
|
+
|
|
388
|
+
const event = senseBuffer.push({
|
|
389
|
+
type: msg.type,
|
|
390
|
+
ts: msg.ts,
|
|
391
|
+
ocr: msg.ocr || "",
|
|
392
|
+
imageData,
|
|
393
|
+
imageBbox,
|
|
394
|
+
meta: {
|
|
395
|
+
ssim: msg.meta?.ssim ?? 0,
|
|
396
|
+
app: msg.meta?.app || "unknown",
|
|
397
|
+
windowTitle: msg.meta?.windowTitle,
|
|
398
|
+
screen: msg.meta?.screen ?? 0,
|
|
399
|
+
},
|
|
400
|
+
semantic: msg.semantic,
|
|
401
|
+
priority: msg.priority,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
if (event) {
|
|
405
|
+
deps.onSenseEvent(event);
|
|
406
|
+
|
|
407
|
+
// Send ack with event ID
|
|
408
|
+
pendingAcks++;
|
|
409
|
+
const backpressure = pendingAcks > MAX_PENDING ? 100 : 0;
|
|
410
|
+
ws.send(JSON.stringify({ type: "ack", id: event.id, backpressure }));
|
|
411
|
+
pendingAcks = Math.max(0, pendingAcks - 1);
|
|
412
|
+
} else {
|
|
413
|
+
// Deduplicated
|
|
414
|
+
ws.send(JSON.stringify({ type: "ack", deduplicated: true }));
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} catch (err: any) {
|
|
418
|
+
ws.send(JSON.stringify({ type: "error", message: err.message }));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
ws.on("close", () => {
|
|
423
|
+
log(TAG, "[sense/ws] client disconnected");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
ws.on("error", (err) => {
|
|
427
|
+
error(TAG, `[sense/ws] error: ${err.message}`);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Default: overlay WebSocket handler
|
|
434
|
+
wsHandler.handleConnection(ws, req);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
httpServer,
|
|
439
|
+
wss,
|
|
440
|
+
start(): Promise<void> {
|
|
441
|
+
return new Promise((resolve, reject) => {
|
|
442
|
+
httpServer.on("error", reject);
|
|
443
|
+
httpServer.listen(config.port, "0.0.0.0", () => {
|
|
444
|
+
log(TAG, `listening on http://0.0.0.0:${config.port} (HTTP + WS, epoch=${serverEpoch})`);
|
|
445
|
+
resolve();
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
},
|
|
449
|
+
async destroy(): Promise<void> {
|
|
450
|
+
wsHandler.destroy();
|
|
451
|
+
wss.close();
|
|
452
|
+
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
|
453
|
+
log(TAG, "server closed");
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|