@geravant/sinain 1.10.1 → 1.12.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/package.json +1 -1
- package/sinain-agent/CLAUDE.md +1 -1
- package/sinain-agent/run.sh +66 -7
- package/sinain-core/src/agent/analyzer.ts +4 -27
- package/sinain-core/src/agent/loop.ts +10 -40
- package/sinain-core/src/agent/situation-writer.ts +0 -16
- package/sinain-core/src/config.ts +1 -9
- package/sinain-core/src/escalation/escalator.ts +44 -16
- package/sinain-core/src/escalation/message-builder.ts +45 -118
- package/sinain-core/src/index.ts +20 -36
- package/sinain-core/src/learning/local-curation.ts +4 -4
- package/sinain-core/src/overlay/commands.ts +46 -13
- package/sinain-core/src/overlay/ws-handler.ts +13 -1
- package/sinain-core/src/server.ts +121 -0
- package/sinain-core/src/types.ts +25 -28
- package/sinain-mcp-server/index.ts +28 -0
- package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
- package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/assertions.py +0 -21
- package/sinain-memory/eval/benchmarks/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/base_adapter.py +43 -0
- package/sinain-memory/eval/benchmarks/config.py +23 -0
- package/sinain-memory/eval/benchmarks/evaluate.py +146 -0
- package/sinain-memory/eval/benchmarks/ingest.py +152 -0
- package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/qa_judge.py +81 -0
- package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +177 -0
- package/sinain-memory/eval/benchmarks/query.py +172 -0
- package/sinain-memory/eval/benchmarks/report.py +87 -0
- package/sinain-memory/eval/benchmarks/runner.py +276 -0
- package/sinain-memory/koog-config.json +11 -0
- package/sinain-core/src/agent/traits.ts +0 -520
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord, UserCommand } from "../types.js";
|
|
1
|
+
import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord, UserCommand, ResponseSize } from "../types.js";
|
|
2
2
|
import { normalizeAppName } from "../agent/context-window.js";
|
|
3
3
|
import { levelFor, applyLevel } from "../privacy/index.js";
|
|
4
4
|
|
|
@@ -67,11 +67,18 @@ export function isCodingContext(context: ContextWindow): CodingContextResult {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function
|
|
70
|
+
function sizeInstruction(size: ResponseSize): string {
|
|
71
|
+
switch (size) {
|
|
72
|
+
case "small": return "1-2 sentences";
|
|
73
|
+
case "large": return "3-5 sentences";
|
|
74
|
+
default: return "2-3 sentences";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getInstructions(context: ContextWindow): string {
|
|
71
79
|
const { coding, needsSolution } = isCodingContext(context);
|
|
72
80
|
|
|
73
81
|
if (needsSolution) {
|
|
74
|
-
// Coding challenge/problem - be very action-oriented
|
|
75
82
|
return `The user is working on a coding problem. Be PROACTIVE and SOLVE IT:
|
|
76
83
|
|
|
77
84
|
1. Provide a solution approach and working code based on what you can see
|
|
@@ -92,13 +99,10 @@ Response should be actionable: working code with brief explanation.`;
|
|
|
92
99
|
- If it's a non-code file (config, markdown, email): share a relevant insight, action item, or connection to their current project
|
|
93
100
|
- If context is minimal: tell a short clever joke (tech humor — never repeat recent ones)
|
|
94
101
|
|
|
95
|
-
NEVER just describe what the user is doing. Every response must teach, suggest, or connect dots
|
|
96
|
-
(2-5 sentences, or more + code if there's an error or code question).`;
|
|
102
|
+
NEVER just describe what the user is doing. Every response must teach, suggest, or connect dots.`;
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
if (mode === "focus" || mode === "rich") {
|
|
101
|
-
return `Based on the above, ALWAYS provide a useful response for the user's HUD.
|
|
105
|
+
return `Based on the above, ALWAYS provide a useful response for the user's HUD.
|
|
102
106
|
Important: Do NOT respond with NO_REPLY — a response is always required.
|
|
103
107
|
|
|
104
108
|
- If there's an error: investigate and suggest a fix
|
|
@@ -109,40 +113,25 @@ Important: Do NOT respond with NO_REPLY — a response is always required.
|
|
|
109
113
|
|
|
110
114
|
NEVER just describe what the user is doing — they can see their own screen.
|
|
111
115
|
NEVER respond with "standing by", "monitoring", or similar filler.
|
|
112
|
-
Every response must teach something, suggest something, or connect dots the user hasn't noticed
|
|
113
|
-
(2-5 sentences). Be specific and actionable.`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
return `Based on the above, proactively help the user:
|
|
117
|
-
- If there's an error: investigate and suggest a fix
|
|
118
|
-
- If they seem stuck: offer guidance
|
|
119
|
-
- If they're coding: provide relevant insights
|
|
120
|
-
- Keep your response concise and actionable (2-5 sentences)`;
|
|
116
|
+
Every response must teach something, suggest something, or connect dots the user hasn't noticed.`;
|
|
121
117
|
}
|
|
122
118
|
|
|
123
119
|
/**
|
|
124
|
-
* Build a structured escalation message with
|
|
125
|
-
*
|
|
126
|
-
* Expected message sizes:
|
|
127
|
-
* lean (selective): ~7 KB / ~1,700 tokens
|
|
128
|
-
* standard (focus): ~25 KB / ~6,000 tokens
|
|
129
|
-
* rich: ~111 KB / ~28,000 tokens
|
|
120
|
+
* Build a structured escalation message with full context (rich mode).
|
|
130
121
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
* - Error escalations prioritize error sections
|
|
135
|
-
* - Question escalations prioritize audio sections
|
|
136
|
-
* - App context is always included
|
|
122
|
+
* Always includes all sections (screen, audio, errors).
|
|
123
|
+
* Response length is controlled by the `responseSize` parameter (small/medium/large)
|
|
124
|
+
* which is set by the user via the HUD overlay slider.
|
|
137
125
|
*/
|
|
138
126
|
export function buildEscalationMessage(
|
|
139
127
|
digest: string,
|
|
140
128
|
context: ContextWindow,
|
|
141
129
|
entry: AgentEntry,
|
|
142
|
-
|
|
130
|
+
_mode: EscalationMode,
|
|
143
131
|
escalationReason?: string,
|
|
144
132
|
recentFeedback?: FeedbackRecord[],
|
|
145
133
|
userCommand?: UserCommand,
|
|
134
|
+
responseSize: ResponseSize = "medium",
|
|
146
135
|
): string {
|
|
147
136
|
const sections: string[] = [];
|
|
148
137
|
|
|
@@ -167,7 +156,6 @@ export function buildEscalationMessage(
|
|
|
167
156
|
// Errors — extracted from OCR, full stack traces in rich mode
|
|
168
157
|
const errors = context.screen.filter(e => hasErrorPattern(e.ocr));
|
|
169
158
|
const hasErrors = errors.length > 0;
|
|
170
|
-
const hasQuestion = escalationReason?.startsWith("question:");
|
|
171
159
|
|
|
172
160
|
// Privacy levels for agent_gateway destination
|
|
173
161
|
let ocrLevel: import("../types.js").PrivacyLevel = "full";
|
|
@@ -183,99 +171,35 @@ export function buildEscalationMessage(
|
|
|
183
171
|
const applyAudio = (text: string) => applyLevel(text.slice(0, context.preset.maxTranscriptChars), audioLevel, "audio");
|
|
184
172
|
const applyTitle = (title: string | undefined) => title ? applyLevel(title, titlesLevel, "titles") : "";
|
|
185
173
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
sections.push("## Errors (high priority)");
|
|
192
|
-
for (const e of errors) {
|
|
193
|
-
sections.push(`\`\`\`\n${applyOcr(e.ocr)}\n\`\`\``);
|
|
194
|
-
}
|
|
195
|
-
// Include screen context (reduced)
|
|
196
|
-
if (context.screen.length > 0) {
|
|
197
|
-
sections.push("## Screen (recent OCR)");
|
|
198
|
-
for (const e of context.screen.slice(0, 5)) { // Limit in selective mode
|
|
199
|
-
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
200
|
-
const app = normalizeAppName(e.meta.app);
|
|
201
|
-
const title = applyTitle(e.meta.windowTitle);
|
|
202
|
-
const titlePart = title ? ` [${title}]` : "";
|
|
203
|
-
sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
// Question-triggered: prioritize audio, then screen
|
|
208
|
-
else if (hasQuestion) {
|
|
209
|
-
if (context.audio.length > 0) {
|
|
210
|
-
sections.push("## Audio (recent transcripts)");
|
|
211
|
-
for (const e of context.audio) {
|
|
212
|
-
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
213
|
-
sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
// Include screen context (reduced)
|
|
217
|
-
if (context.screen.length > 0) {
|
|
218
|
-
sections.push("## Screen (recent OCR)");
|
|
219
|
-
for (const e of context.screen.slice(0, 5)) {
|
|
220
|
-
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
221
|
-
const app = normalizeAppName(e.meta.app);
|
|
222
|
-
const title = applyTitle(e.meta.windowTitle);
|
|
223
|
-
const titlePart = title ? ` [${title}]` : "";
|
|
224
|
-
sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
// Other triggers: balanced sections
|
|
229
|
-
else {
|
|
230
|
-
if (context.screen.length > 0) {
|
|
231
|
-
sections.push("## Screen (recent OCR)");
|
|
232
|
-
for (const e of context.screen) {
|
|
233
|
-
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
234
|
-
const app = normalizeAppName(e.meta.app);
|
|
235
|
-
const title = applyTitle(e.meta.windowTitle);
|
|
236
|
-
const titlePart = title ? ` [${title}]` : "";
|
|
237
|
-
sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
if (context.audio.length > 0) {
|
|
241
|
-
sections.push("## Audio (recent transcripts)");
|
|
242
|
-
for (const e of context.audio) {
|
|
243
|
-
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
244
|
-
sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
// Focus/rich mode: include all sections
|
|
250
|
-
if (hasErrors) {
|
|
251
|
-
sections.push("## Errors (high priority)");
|
|
252
|
-
for (const e of errors) {
|
|
253
|
-
sections.push(`\`\`\`\n${applyOcr(e.ocr)}\n\`\`\``);
|
|
254
|
-
}
|
|
174
|
+
// Always include all sections (rich mode)
|
|
175
|
+
if (hasErrors) {
|
|
176
|
+
sections.push("## Errors (high priority)");
|
|
177
|
+
for (const e of errors) {
|
|
178
|
+
sections.push(`\`\`\`\n${applyOcr(e.ocr)}\n\`\`\``);
|
|
255
179
|
}
|
|
180
|
+
}
|
|
256
181
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
182
|
+
if (context.screen.length > 0) {
|
|
183
|
+
sections.push("## Screen (recent OCR)");
|
|
184
|
+
for (const e of context.screen) {
|
|
185
|
+
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
186
|
+
const app = normalizeAppName(e.meta.app);
|
|
187
|
+
const title = applyTitle(e.meta.windowTitle);
|
|
188
|
+
const titlePart = title ? ` [${title}]` : "";
|
|
189
|
+
sections.push(`- [${ago}s ago] [${app}]${titlePart} ${applyOcr(e.ocr)}`);
|
|
266
190
|
}
|
|
191
|
+
}
|
|
267
192
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
193
|
+
if (context.audio.length > 0) {
|
|
194
|
+
sections.push("## Audio (recent transcripts)");
|
|
195
|
+
for (const e of context.audio) {
|
|
196
|
+
const ago = Math.round((Date.now() - e.ts) / 1000);
|
|
197
|
+
sections.push(`- [${ago}s ago] "${applyAudio(e.text)}"`);
|
|
274
198
|
}
|
|
275
199
|
}
|
|
276
200
|
|
|
277
|
-
//
|
|
278
|
-
sections.push(getInstructions(
|
|
201
|
+
// Context-aware instructions (no size — that's in the response length section below)
|
|
202
|
+
sections.push(getInstructions(context));
|
|
279
203
|
|
|
280
204
|
// Stale escalation hint — forces a proactive response after prolonged silence
|
|
281
205
|
if (escalationReason === "stale") {
|
|
@@ -293,7 +217,10 @@ the local analyzer reported idle/no-change. Provide a PROACTIVE response:
|
|
|
293
217
|
sections.push(formatInlineFeedback(recentFeedback));
|
|
294
218
|
}
|
|
295
219
|
|
|
296
|
-
|
|
220
|
+
// Response length — single authoritative size instruction, placed last for salience
|
|
221
|
+
const limit = sizeInstruction(responseSize);
|
|
222
|
+
sections.push(`## Response Length
|
|
223
|
+
Your response MUST be ${limit}. This appears on the user's HUD overlay — be specific and actionable.`);
|
|
297
224
|
|
|
298
225
|
return sections.join("\n\n");
|
|
299
226
|
}
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -8,7 +8,6 @@ import { AudioPipeline } from "./audio/pipeline.js";
|
|
|
8
8
|
import type { CaptureSpawner } from "./audio/capture-spawner.js";
|
|
9
9
|
import { TranscriptionService } from "./audio/transcription.js";
|
|
10
10
|
import { AgentLoop } from "./agent/loop.js";
|
|
11
|
-
import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
|
|
12
11
|
import { shortAppName } from "./agent/context-window.js";
|
|
13
12
|
import { Escalator } from "./escalation/escalator.js";
|
|
14
13
|
import { Recorder } from "./recorder.js";
|
|
@@ -344,10 +343,6 @@ async function main() {
|
|
|
344
343
|
localCuration.distillPendingSession(); // Recover any session saved before a force-kill
|
|
345
344
|
localCuration.startPeriodicCuration();
|
|
346
345
|
|
|
347
|
-
// ── Initialize trait engine ──
|
|
348
|
-
const traitRoster = loadTraitRoster(config.traitConfig.configPath);
|
|
349
|
-
const traitEngine = new TraitEngine(traitRoster, config.traitConfig);
|
|
350
|
-
|
|
351
346
|
// ── Initialize escalation ──
|
|
352
347
|
const escalator = new Escalator({
|
|
353
348
|
feedBuffer,
|
|
@@ -372,33 +367,6 @@ async function main() {
|
|
|
372
367
|
// Handle recorder commands
|
|
373
368
|
const stopResult = recorder.handleCommand(entry.record);
|
|
374
369
|
|
|
375
|
-
// Dispatch task via subagent spawn
|
|
376
|
-
if (entry.task || stopResult) {
|
|
377
|
-
let task: string;
|
|
378
|
-
let label: string | undefined;
|
|
379
|
-
|
|
380
|
-
if (stopResult && stopResult.segments > 0 && entry.task) {
|
|
381
|
-
// Recording stopped with explicit task instruction
|
|
382
|
-
task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
|
|
383
|
-
label = stopResult.title;
|
|
384
|
-
} else if (stopResult && stopResult.segments > 0) {
|
|
385
|
-
// Recording stopped without explicit task — default to cleanup/summarize
|
|
386
|
-
task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
|
|
387
|
-
label = stopResult.title;
|
|
388
|
-
} else if (entry.task) {
|
|
389
|
-
// Standalone task without recording
|
|
390
|
-
task = entry.task;
|
|
391
|
-
} else {
|
|
392
|
-
task = "";
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
if (task) {
|
|
396
|
-
escalator.dispatchSpawnTask(task, label).catch(err => {
|
|
397
|
-
error(TAG, "spawn task dispatch error:", err);
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
370
|
// Escalation continues as normal
|
|
403
371
|
escalator.onAgentAnalysis(entry, contextWindow);
|
|
404
372
|
},
|
|
@@ -422,8 +390,6 @@ async function main() {
|
|
|
422
390
|
};
|
|
423
391
|
return ctx;
|
|
424
392
|
} : undefined,
|
|
425
|
-
traitEngine,
|
|
426
|
-
traitLogDir: config.traitConfig.logDir,
|
|
427
393
|
getKnowledgeDocPath: () => {
|
|
428
394
|
const workspace = resolveWorkspace();
|
|
429
395
|
const p = `${workspace}/memory/sinain-knowledge.md`;
|
|
@@ -564,6 +530,9 @@ async function main() {
|
|
|
564
530
|
// ── Screen capture active flag ──
|
|
565
531
|
let screenActive = true;
|
|
566
532
|
|
|
533
|
+
// ── Escalation pause/resume state ──
|
|
534
|
+
let savedEscalationMode: typeof config.escalationConfig.mode | null = null;
|
|
535
|
+
|
|
567
536
|
// ── Create HTTP + WS server ──
|
|
568
537
|
const server = createAppServer({
|
|
569
538
|
config,
|
|
@@ -674,6 +643,7 @@ async function main() {
|
|
|
674
643
|
|
|
675
644
|
// Bare agent HTTP escalation bridge
|
|
676
645
|
getEscalationPending: () => escalator.getPendingHttp(),
|
|
646
|
+
isEscalationPaused: () => savedEscalationMode !== null,
|
|
677
647
|
respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
|
|
678
648
|
|
|
679
649
|
// Knowledge graph integration (checks both local and workspace DBs)
|
|
@@ -733,7 +703,22 @@ async function main() {
|
|
|
733
703
|
wsHandler.updateState({ screen: screenActive ? "active" : "off" });
|
|
734
704
|
return screenActive;
|
|
735
705
|
},
|
|
736
|
-
|
|
706
|
+
onToggleEscalation: () => {
|
|
707
|
+
if (savedEscalationMode === null) {
|
|
708
|
+
// Pause: save current mode, switch to off
|
|
709
|
+
savedEscalationMode = config.escalationConfig.mode;
|
|
710
|
+
escalator.setMode("off");
|
|
711
|
+
log(TAG, `escalation paused (was: ${savedEscalationMode})`);
|
|
712
|
+
return false;
|
|
713
|
+
} else {
|
|
714
|
+
// Resume: restore saved mode
|
|
715
|
+
const mode = savedEscalationMode;
|
|
716
|
+
savedEscalationMode = null;
|
|
717
|
+
escalator.setMode(mode);
|
|
718
|
+
log(TAG, `escalation resumed (mode: ${mode})`);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
},
|
|
737
722
|
});
|
|
738
723
|
|
|
739
724
|
// Broadcast initial screen state so overlay gets correct status on connect
|
|
@@ -795,7 +780,6 @@ async function main() {
|
|
|
795
780
|
log(TAG, ` mic: ${config.micEnabled ? (config.micConfig.autoStart ? "active" : "standby") : "disabled"}`);
|
|
796
781
|
log(TAG, ` agent: ${config.agentConfig.enabled ? "enabled" : "disabled"}`);
|
|
797
782
|
log(TAG, ` escal: ${config.escalationConfig.mode}`);
|
|
798
|
-
log(TAG, ` traits: ${config.traitConfig.enabled ? "enabled" : "disabled"} (${traitRoster.length} traits)`);
|
|
799
783
|
log(TAG, ` cost: display=${config.costDisplayEnabled ? "on" : "off"} (always logged)`);
|
|
800
784
|
|
|
801
785
|
// ── Graceful shutdown ──
|
|
@@ -95,7 +95,7 @@ export class LocalCurationService {
|
|
|
95
95
|
* Called during shutdown — instant (no LLM), survives tsx force-kill.
|
|
96
96
|
*/
|
|
97
97
|
savePendingSession(feedItems: FeedItem[]): void {
|
|
98
|
-
if (feedItems.length <
|
|
98
|
+
if (feedItems.length < 1) {
|
|
99
99
|
log(TAG, `skipping save — only ${feedItems.length} feed items`);
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
@@ -135,7 +135,7 @@ export class LocalCurationService {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
const items: FeedItem[] = data.items || [];
|
|
138
|
-
if (items.length <
|
|
138
|
+
if (items.length < 1) {
|
|
139
139
|
log(TAG, `pending session too small (${items.length} items) — removing`);
|
|
140
140
|
unlinkSync(pendingPath);
|
|
141
141
|
return;
|
|
@@ -160,7 +160,7 @@ export class LocalCurationService {
|
|
|
160
160
|
* picked up on next startup via distillPendingSession().
|
|
161
161
|
*/
|
|
162
162
|
async distillSession(feedItems: FeedItem[]): Promise<void> {
|
|
163
|
-
if (feedItems.length <
|
|
163
|
+
if (feedItems.length < 1) {
|
|
164
164
|
log(TAG, `skipping distillation — only ${feedItems.length} feed items`);
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
@@ -332,7 +332,7 @@ export class LocalCurationService {
|
|
|
332
332
|
|
|
333
333
|
/** Fallback: write raw feed summary when distillation fails. */
|
|
334
334
|
private writeDailyNotesFallback(feedItems: FeedItem[]): void {
|
|
335
|
-
if (feedItems.length <
|
|
335
|
+
if (feedItems.length < 1) return;
|
|
336
336
|
|
|
337
337
|
const date = new Date().toISOString().slice(0, 10);
|
|
338
338
|
const notesPath = resolve(this.memoryDir, `${date}.md`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import type { InboundMessage } from "../types.js";
|
|
2
|
+
import type { InboundMessage, ResponseSize } from "../types.js";
|
|
3
3
|
import type { WsHandler } from "./ws-handler.js";
|
|
4
4
|
import type { AudioPipeline } from "../audio/pipeline.js";
|
|
5
5
|
import type { CoreConfig } from "../types.js";
|
|
@@ -21,8 +21,8 @@ export interface CommandDeps {
|
|
|
21
21
|
onSpawnCommand?: (text: string) => void;
|
|
22
22
|
/** Toggle screen capture — returns new state */
|
|
23
23
|
onToggleScreen: () => boolean;
|
|
24
|
-
/** Toggle
|
|
25
|
-
|
|
24
|
+
/** Toggle escalation pause/resume — returns true if now active */
|
|
25
|
+
onToggleEscalation: () => boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -79,8 +79,29 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
79
79
|
}
|
|
80
80
|
break;
|
|
81
81
|
}
|
|
82
|
+
case "spawn_reply": {
|
|
83
|
+
const { taskId, text } = msg as any;
|
|
84
|
+
log(TAG, `spawn reply for ${taskId}: "${(text || "").slice(0, 60)}"`);
|
|
85
|
+
// Forward to the /spawn/reply HTTP endpoint internally
|
|
86
|
+
fetch(`http://localhost:${deps.config.port}/spawn/reply`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ taskId, text }),
|
|
90
|
+
}).catch(() => {});
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "spawn_permission_reply": {
|
|
94
|
+
const { taskId, decision } = msg as any;
|
|
95
|
+
log(TAG, `spawn permission reply for ${taskId}: ${decision}`);
|
|
96
|
+
fetch(`http://localhost:${deps.config.port}/spawn/permission-reply`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ taskId, decision }),
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
82
103
|
case "command": {
|
|
83
|
-
handleCommand(msg
|
|
104
|
+
handleCommand(msg, deps);
|
|
84
105
|
log(TAG, `command processed: ${msg.action}`);
|
|
85
106
|
break;
|
|
86
107
|
}
|
|
@@ -88,8 +109,11 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
88
109
|
});
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
|
|
112
|
+
const VALID_RESPONSE_SIZES = new Set<ResponseSize>(["small", "medium", "large"]);
|
|
113
|
+
|
|
114
|
+
function handleCommand(msg: InboundMessage & { action: string }, deps: CommandDeps): void {
|
|
92
115
|
const { wsHandler, systemAudioPipeline, micPipeline } = deps;
|
|
116
|
+
const action = msg.action;
|
|
93
117
|
|
|
94
118
|
switch (action) {
|
|
95
119
|
case "toggle_audio": {
|
|
@@ -142,15 +166,24 @@ function handleCommand(action: string, deps: CommandDeps): void {
|
|
|
142
166
|
log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
|
|
143
167
|
break;
|
|
144
168
|
}
|
|
145
|
-
case "
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
169
|
+
case "toggle_escalation": {
|
|
170
|
+
const nowActive = deps.onToggleEscalation();
|
|
171
|
+
wsHandler.updateState({ escalation: nowActive ? "active" : "paused" });
|
|
172
|
+
wsHandler.broadcast(
|
|
173
|
+
nowActive ? "Escalations resumed" : "Escalations paused — context still accumulating",
|
|
174
|
+
"normal"
|
|
175
|
+
);
|
|
176
|
+
log(TAG, `escalation toggled ${nowActive ? "ON" : "OFF"}`);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "set_response_size": {
|
|
180
|
+
const size = (msg as any).responseSize as string;
|
|
181
|
+
if (VALID_RESPONSE_SIZES.has(size as ResponseSize)) {
|
|
182
|
+
wsHandler.updateState({ responseSize: size as ResponseSize });
|
|
183
|
+
log(TAG, `response size set to ${size}`);
|
|
184
|
+
} else {
|
|
185
|
+
log(TAG, `invalid response size: ${size}`);
|
|
149
186
|
}
|
|
150
|
-
const nowEnabled = deps.onToggleTraits();
|
|
151
|
-
wsHandler.updateState({ traits: nowEnabled ? "active" : "off" });
|
|
152
|
-
wsHandler.broadcast(`Trait voices ${nowEnabled ? "on" : "off"}`, "normal");
|
|
153
|
-
log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
|
|
154
187
|
break;
|
|
155
188
|
}
|
|
156
189
|
case "open_settings": {
|
|
@@ -37,7 +37,9 @@ export class WsHandler {
|
|
|
37
37
|
audio: "muted",
|
|
38
38
|
mic: "muted",
|
|
39
39
|
screen: "off",
|
|
40
|
+
escalation: "active",
|
|
40
41
|
connection: "disconnected",
|
|
42
|
+
responseSize: "medium",
|
|
41
43
|
};
|
|
42
44
|
private replayBuffer: FeedMessage[] = [];
|
|
43
45
|
private spawnTaskBuffer: Map<string, SpawnTaskMessage> = new Map();
|
|
@@ -72,7 +74,9 @@ export class WsHandler {
|
|
|
72
74
|
audio: this.state.audio,
|
|
73
75
|
mic: this.state.mic,
|
|
74
76
|
screen: this.state.screen,
|
|
77
|
+
escalation: this.state.escalation,
|
|
75
78
|
connection: this.state.connection,
|
|
79
|
+
responseSize: this.state.responseSize,
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
// Replay recent feed messages for late-joining clients
|
|
@@ -149,12 +153,14 @@ export class WsHandler {
|
|
|
149
153
|
|
|
150
154
|
/** Send a status update to all connected overlays. */
|
|
151
155
|
broadcastStatus(): void {
|
|
152
|
-
const msg: StatusMessage & { envPath?: string } = {
|
|
156
|
+
const msg: StatusMessage & { envPath?: string; escalation?: string; responseSize?: string } = {
|
|
153
157
|
type: "status",
|
|
154
158
|
audio: this.state.audio,
|
|
155
159
|
mic: this.state.mic,
|
|
156
160
|
screen: this.state.screen,
|
|
161
|
+
escalation: this.state.escalation,
|
|
157
162
|
connection: this.state.connection,
|
|
163
|
+
responseSize: this.state.responseSize,
|
|
158
164
|
};
|
|
159
165
|
if (loadedEnvPath) msg.envPath = loadedEnvPath;
|
|
160
166
|
this.broadcastMessage(msg);
|
|
@@ -229,6 +235,12 @@ export class WsHandler {
|
|
|
229
235
|
case "spawn_command":
|
|
230
236
|
log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
|
|
231
237
|
break;
|
|
238
|
+
case "spawn_reply":
|
|
239
|
+
log(TAG, `\u2190 spawn reply: taskId=${(msg as any).taskId}`);
|
|
240
|
+
break;
|
|
241
|
+
case "spawn_permission_reply":
|
|
242
|
+
log(TAG, `\u2190 spawn permission reply: taskId=${(msg as any).taskId} decision=${(msg as any).decision}`);
|
|
243
|
+
break;
|
|
232
244
|
case "profiling":
|
|
233
245
|
if (this.onProfilingCb) this.onProfilingCb(msg);
|
|
234
246
|
return;
|
|
@@ -174,6 +174,7 @@ export interface ServerDeps {
|
|
|
174
174
|
feedbackStore?: FeedbackStore;
|
|
175
175
|
setUserCommand?: (text: string) => void;
|
|
176
176
|
getEscalationPending?: () => any;
|
|
177
|
+
isEscalationPaused?: () => boolean;
|
|
177
178
|
respondEscalation?: (id: string, response: string) => any;
|
|
178
179
|
getKnowledgeDocPath?: () => string | null;
|
|
179
180
|
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
@@ -203,6 +204,9 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
|
|
203
204
|
});
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
/** Pending spawn questions/permissions — resolve callbacks keyed by "ask:{taskId}" or "perm:{taskId}" */
|
|
208
|
+
const pendingSpawnQuestions = new Map<string, (answer: string) => void>();
|
|
209
|
+
|
|
206
210
|
export function createAppServer(deps: ServerDeps) {
|
|
207
211
|
const { config, feedBuffer, senseBuffer, wsHandler } = deps;
|
|
208
212
|
let senseInBytes = 0;
|
|
@@ -562,6 +566,11 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
562
566
|
|
|
563
567
|
// ── /escalation/pending ──
|
|
564
568
|
if (req.method === "GET" && url.pathname === "/escalation/pending") {
|
|
569
|
+
const paused = deps.isEscalationPaused?.() ?? false;
|
|
570
|
+
if (paused) {
|
|
571
|
+
res.end(JSON.stringify({ ok: true, escalation: null, paused: true }));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
565
574
|
const pending = deps.getEscalationPending?.();
|
|
566
575
|
res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
|
|
567
576
|
return;
|
|
@@ -620,6 +629,118 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
620
629
|
return;
|
|
621
630
|
}
|
|
622
631
|
|
|
632
|
+
// ── /spawn/ask (MCP tool posts question, blocks until user replies) ──
|
|
633
|
+
if (req.method === "POST" && url.pathname === "/spawn/ask") {
|
|
634
|
+
const body = await readBody(req, 8192);
|
|
635
|
+
const { taskId, question } = JSON.parse(body);
|
|
636
|
+
if (!taskId || !question) {
|
|
637
|
+
res.writeHead(400);
|
|
638
|
+
res.end(JSON.stringify({ ok: false, error: "missing taskId or question" }));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Broadcast question to overlay
|
|
642
|
+
deps.wsHandler?.broadcastRaw({
|
|
643
|
+
type: "spawn_task",
|
|
644
|
+
taskId,
|
|
645
|
+
label: "user-command",
|
|
646
|
+
status: "awaiting_input",
|
|
647
|
+
startedAt: Date.now(),
|
|
648
|
+
question,
|
|
649
|
+
});
|
|
650
|
+
// Hold response open until user replies (or timeout after 5 min)
|
|
651
|
+
const answer = await new Promise<string>((resolve) => {
|
|
652
|
+
const key = `ask:${taskId}`;
|
|
653
|
+
pendingSpawnQuestions.set(key, resolve);
|
|
654
|
+
setTimeout(() => {
|
|
655
|
+
if (pendingSpawnQuestions.has(key)) {
|
|
656
|
+
pendingSpawnQuestions.delete(key);
|
|
657
|
+
resolve("(no reply — user did not respond within 5 minutes)");
|
|
658
|
+
}
|
|
659
|
+
}, 5 * 60_000);
|
|
660
|
+
});
|
|
661
|
+
res.end(JSON.stringify({ ok: true, answer }));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── /spawn/reply (overlay sends answer to a spawn question) ──
|
|
666
|
+
if (req.method === "POST" && url.pathname === "/spawn/reply") {
|
|
667
|
+
const body = await readBody(req, 8192);
|
|
668
|
+
const { taskId, text } = JSON.parse(body);
|
|
669
|
+
const key = `ask:${taskId}`;
|
|
670
|
+
const resolve = pendingSpawnQuestions.get(key);
|
|
671
|
+
if (resolve) {
|
|
672
|
+
pendingSpawnQuestions.delete(key);
|
|
673
|
+
resolve(text || "(empty reply)");
|
|
674
|
+
res.end(JSON.stringify({ ok: true }));
|
|
675
|
+
} else {
|
|
676
|
+
res.end(JSON.stringify({ ok: false, error: "no pending question for this task" }));
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── /spawn/approve (Claude hook posts tool permission, blocks until user decides) ──
|
|
682
|
+
if (req.method === "POST" && url.pathname === "/spawn/approve") {
|
|
683
|
+
const body = await readBody(req, 16384);
|
|
684
|
+
const hookInput = JSON.parse(body);
|
|
685
|
+
const tool = hookInput?.tool_name || hookInput?.toolName || "unknown";
|
|
686
|
+
const input = hookInput?.tool_input || hookInput?.input || {};
|
|
687
|
+
|
|
688
|
+
// Auto-approve safe read-only tools
|
|
689
|
+
const safeTools = ["Read", "Glob", "Grep", "Ls", "Cat"];
|
|
690
|
+
if (safeTools.includes(tool) || tool.startsWith("mcp__sinain")) {
|
|
691
|
+
res.end(JSON.stringify({
|
|
692
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
|
693
|
+
}));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const taskId = `perm-${Date.now()}`;
|
|
698
|
+
// Broadcast permission request to overlay
|
|
699
|
+
deps.wsHandler?.broadcastRaw({
|
|
700
|
+
type: "spawn_task",
|
|
701
|
+
taskId,
|
|
702
|
+
label: "permission",
|
|
703
|
+
status: "awaiting_permission",
|
|
704
|
+
startedAt: Date.now(),
|
|
705
|
+
permission: { tool, input },
|
|
706
|
+
});
|
|
707
|
+
// Hold response open until user decides
|
|
708
|
+
const decision = await new Promise<string>((resolve) => {
|
|
709
|
+
const key = `perm:${taskId}`;
|
|
710
|
+
pendingSpawnQuestions.set(key, resolve);
|
|
711
|
+
setTimeout(() => {
|
|
712
|
+
if (pendingSpawnQuestions.has(key)) {
|
|
713
|
+
pendingSpawnQuestions.delete(key);
|
|
714
|
+
resolve("deny"); // default deny on timeout
|
|
715
|
+
}
|
|
716
|
+
}, 2 * 60_000);
|
|
717
|
+
});
|
|
718
|
+
res.end(JSON.stringify({
|
|
719
|
+
hookSpecificOutput: {
|
|
720
|
+
hookEventName: "PreToolUse",
|
|
721
|
+
permissionDecision: decision === "allow" ? "allow" : "deny",
|
|
722
|
+
permissionDecisionReason: decision === "allow" ? "User approved via HUD" : "User denied or timed out",
|
|
723
|
+
},
|
|
724
|
+
}));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── /spawn/permission-reply (overlay sends allow/deny) ──
|
|
729
|
+
if (req.method === "POST" && url.pathname === "/spawn/permission-reply") {
|
|
730
|
+
const body = await readBody(req, 1024);
|
|
731
|
+
const { taskId, decision } = JSON.parse(body);
|
|
732
|
+
const key = `perm:${taskId}`;
|
|
733
|
+
const resolve = pendingSpawnQuestions.get(key);
|
|
734
|
+
if (resolve) {
|
|
735
|
+
pendingSpawnQuestions.delete(key);
|
|
736
|
+
resolve(decision || "deny");
|
|
737
|
+
res.end(JSON.stringify({ ok: true }));
|
|
738
|
+
} else {
|
|
739
|
+
res.end(JSON.stringify({ ok: false, error: "no pending permission for this task" }));
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
623
744
|
res.writeHead(404);
|
|
624
745
|
res.end(JSON.stringify({ error: "not found" }));
|
|
625
746
|
} catch (err: any) {
|