shraga 0.1.12 → 0.1.13

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.
@@ -12,6 +12,7 @@ import { DEFAULT_MODEL } from '../directives.ts';
12
12
  import { resolveModelSwitch } from '../model-aliases.ts';
13
13
  import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
14
14
  import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
15
+ import { getPromptSuffix } from '../prompt-suffix.ts';
15
16
 
16
17
  const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
17
18
  const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
@@ -265,8 +266,10 @@ export class ClaudeCodeEngine implements AgentEngine {
265
266
  if (effort) options['effort'] = effort;
266
267
 
267
268
  const userPrompt = config.systemPrompt || DEFAULT_USER_PROMPT;
268
- const voiceSuffix = ""; // voice mode is an optional add-on; not present in this build
269
- options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${voiceSuffix}`;
269
+ // Optional add-ons may append a system-prompt suffix decided off the opaque turn hints (add-on-owned
270
+ // text). With nothing registered (CE's own state) this is '' and the prompt is byte-identical to before.
271
+ const addonSuffix = getPromptSuffix(opts.turnHints);
272
+ options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
270
273
  if (opts.abortController) options['abortController'] = opts.abortController;
271
274
  if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) options['mcpServers'] = opts.mcpServers;
272
275
 
@@ -23,7 +23,9 @@ export interface EngineStreamOpts {
23
23
  onPermissionRequest?: PermissionHandler;
24
24
  onDestructiveApproval?: PermissionHandler;
25
25
  onUserQuestion?: QuestionHandler;
26
- voiceMode?: boolean;
26
+ /** Opaque per-send hints bag, forwarded verbatim from the client. The core interprets no key of it;
27
+ * an add-on engine reads its own keys (e.g. a duplex engine's `voice` marker). */
28
+ turnHints?: Record<string, unknown>;
27
29
  /** True when the conversation was truncated (user replayed/edited a message) — engines with cached state should reset. */
28
30
  conversationReset?: boolean;
29
31
  context?: Record<string, string>;
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bun
2
+ // Ingress router: tiny host-header TCP router fronting shraga instances.
3
+ // The deployment's ingress (CF Tunnel or Caddy) always points here; this process
4
+ // routes by Host header to local ports. Previews and blue-green flips are just edits
5
+ // to the routing file — this process never restarts during a flip (that's why it runs
6
+ // as its own process, NOT inside the server: `flip-restart` restarts the server while
7
+ // the router holds traffic).
8
+ //
9
+ // bun run src/server/ingress-router.ts # INGRESS_PORT (default 3100)
10
+ // shraga ingress # same, via the CLI
11
+ //
12
+ // Routing file (dataPath('ingress-router.json'), hot-reloaded on change):
13
+ // { "default": 3032, "routes": { "pr-13.preview.agent.example.com": 3850 } }
14
+ //
15
+ // Works at the TCP level: reads bytes until the first request's headers end, parses Host,
16
+ // connects upstream, replays the buffered bytes, then splices both directions blindly.
17
+ // WebSocket upgrades and keep-alive flow through untouched (same upstream per connection).
18
+
19
+ import net from 'node:net';
20
+ import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs';
21
+ import { dataPath } from './paths.ts';
22
+
23
+ const PORT = Number(process.env.INGRESS_PORT) || 3100;
24
+ const CONFIG = dataPath('ingress-router.json');
25
+ const HEADER_LIMIT = 16 * 1024;
26
+
27
+ interface Routing { default: number; routes: Record<string, number>; }
28
+ let routing: Routing = { default: 3032, routes: {} };
29
+
30
+ function loadRouting() {
31
+ try {
32
+ routing = { routes: {}, ...JSON.parse(readFileSync(CONFIG, 'utf-8')) };
33
+ console.log(`[ingress] routing: default→:${routing.default}, ${Object.keys(routing.routes).length} route(s)`);
34
+ } catch (err) {
35
+ console.error(`[ingress] bad routing file, keeping previous:`, (err as Error).message);
36
+ }
37
+ }
38
+ if (!existsSync(CONFIG)) writeFileSync(CONFIG, JSON.stringify(routing, null, 2));
39
+ loadRouting();
40
+ let reloadTimer: ReturnType<typeof setTimeout> | null = null;
41
+ watch(CONFIG, () => { // debounce — editors fire multiple events per save
42
+ if (reloadTimer) clearTimeout(reloadTimer);
43
+ reloadTimer = setTimeout(loadRouting, 100);
44
+ });
45
+
46
+ function upstreamFor(host: string): number {
47
+ const bare = host.toLowerCase().split(':')[0];
48
+ return routing.routes[bare] ?? routing.default;
49
+ }
50
+
51
+ const server = net.createServer((client) => {
52
+ let buf = Buffer.alloc(0);
53
+ client.once('error', () => client.destroy());
54
+
55
+ const onData = (chunk: Buffer) => {
56
+ buf = Buffer.concat([buf, chunk]);
57
+ const headerEnd = buf.indexOf('\r\n\r\n');
58
+ if (headerEnd === -1) {
59
+ if (buf.length > HEADER_LIMIT) client.destroy();
60
+ return;
61
+ }
62
+ client.off('data', onData);
63
+ client.pause();
64
+
65
+ const head = buf.subarray(0, headerEnd).toString('latin1');
66
+ const host = /\r\nhost:\s*([^\r\n]+)/i.exec('\r\n' + head)?.[1]?.trim() ?? '';
67
+ const port = upstreamFor(host);
68
+
69
+ const upstream = net.connect(port, '127.0.0.1', () => {
70
+ client.setTimeout(0); // routed — long-lived (WS) connections idle freely
71
+ upstream.write(buf);
72
+ client.pipe(upstream);
73
+ upstream.pipe(client);
74
+ client.resume();
75
+ });
76
+ const drop = () => { client.destroy(); upstream.destroy(); };
77
+ upstream.on('error', (err) => {
78
+ console.warn(`[ingress] upstream :${port} (${host}):`, err.message);
79
+ if (!client.writableEnded) client.end('HTTP/1.1 502 Bad Gateway\r\ncontent-length: 0\r\nconnection: close\r\n\r\n');
80
+ upstream.destroy();
81
+ });
82
+ client.on('error', drop);
83
+ client.on('close', () => upstream.destroy());
84
+ upstream.on('close', () => client.destroy());
85
+ };
86
+ client.on('data', onData);
87
+ client.setTimeout(15_000, () => { if (!client.bytesWritten) client.destroy(); });
88
+ });
89
+
90
+ server.listen(PORT, () => console.log(`[ingress] listening on :${PORT}, routing file: ${CONFIG}`));
@@ -0,0 +1,38 @@
1
+ // System-prompt-suffix seam — a generic drop-in for optional add-ons to append text to the agent's
2
+ // system prompt, decided per-turn off the opaque hints bag.
3
+ //
4
+ // Every turn, each engine appends whatever the registered contributors return. A contributor reads its
5
+ // OWN keys off the opaque `turnHints` bag (the core interprets none) and returns a suffix — or '' to
6
+ // contribute nothing. The core owns only this seam: it names no add-on concept (voice, etc.). With no
7
+ // contributor registered (CE's own state) the suffix is '' and the assembled prompt is byte-identical
8
+ // to before. An add-on (e.g. shraga-ee's voice feature) registers a contributor that returns its own
9
+ // bundled suffix when its marker (e.g. `turnHints.voice`) is present.
10
+ //
11
+ // Sibling of `turn-context.ts` and shaped like it: typed, optional, registered before startup, a no-op
12
+ // when nothing registers.
13
+
14
+ /** Return a system-prompt suffix for this turn (trimmed by the seam), or '' to contribute nothing.
15
+ * Reads its own keys off the opaque per-turn hints bag. */
16
+ export type PromptSuffixContributor = (turnHints?: Record<string, unknown>) => string;
17
+
18
+ const contributors: PromptSuffixContributor[] = [];
19
+
20
+ /** Register a prompt-suffix contributor. Called by an optional add-on before startup. */
21
+ export function registerPromptSuffix(fn: PromptSuffixContributor): void {
22
+ contributors.push(fn);
23
+ }
24
+
25
+ /** The combined suffix for this turn, or '' when nothing contributes. A throwing contributor is
26
+ * contained (logged, skipped) so a broken add-on can't take down a turn. */
27
+ export function getPromptSuffix(turnHints?: Record<string, unknown>): string {
28
+ const parts: string[] = [];
29
+ for (const fn of contributors) {
30
+ try {
31
+ const s = fn(turnHints)?.trim();
32
+ if (s) parts.push(s);
33
+ } catch (err) {
34
+ console.error('[prompt-suffix] contributor failed:', (err as Error)?.message ?? err);
35
+ }
36
+ }
37
+ return parts.join('\n\n');
38
+ }
@@ -356,6 +356,8 @@ export type ConvBlock =
356
356
  | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
357
357
  | { type: 'tool_result'; toolUseId: string; output: string }
358
358
  | { type: 'thinking'; text: string }
359
+ // Persisted block written by an add-on engine's background worker (e.g. EE's duplex voice brain).
360
+ // The core stores/renders it but owns none of its semantics; name kept for stored-history back-compat.
359
361
  | { type: 'duplex_result'; label?: string; tier?: string; text?: string }
360
362
  | { type: 'summary'; text: string; compactedCount: number }
361
363
  | { type: 'compact_marker'; summary: string; compactedCount: number };