shraga 0.1.12 → 0.1.14

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,8 +12,8 @@ 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
-
16
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..', '..');
15
+ import { getPromptSuffix } from '../prompt-suffix.ts';
16
+ import { APP_ROOT } from '../paths.ts';
17
17
  const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
18
18
  const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
19
19
  const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
@@ -174,7 +174,7 @@ export class ClaudeCodeEngine implements AgentEngine {
174
174
 
175
175
  async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
176
176
  const { config, directives } = opts;
177
- const cwd = PROJECT_ROOT;
177
+ const cwd = APP_ROOT;
178
178
 
179
179
  const fullPrompt = buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt);
180
180
  const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
@@ -265,8 +265,10 @@ export class ClaudeCodeEngine implements AgentEngine {
265
265
  if (effort) options['effort'] = effort;
266
266
 
267
267
  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}`;
268
+ // Optional add-ons may append a system-prompt suffix decided off the opaque turn hints (add-on-owned
269
+ // text). With nothing registered (CE's own state) this is '' and the prompt is byte-identical to before.
270
+ const addonSuffix = getPromptSuffix(opts.turnHints);
271
+ options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
270
272
  if (opts.abortController) options['abortController'] = opts.abortController;
271
273
  if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) options['mcpServers'] = opts.mcpServers;
272
274
 
@@ -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}`));
@@ -1,8 +1,8 @@
1
1
  import { spawn, type ChildProcess } from 'node:child_process';
2
2
  import path from 'node:path';
3
3
  import { getHttpSidecarSpecs, type HttpSidecarSpec } from './shraga-config.ts';
4
+ import { APP_ROOT } from './paths.ts';
4
5
 
5
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
6
6
  const sidecars = new Map<string, { proc: ChildProcess; spec: HttpSidecarSpec }>();
7
7
 
8
8
  async function isPortAlive(url: string): Promise<boolean> {
@@ -17,7 +17,7 @@ async function isPortAlive(url: string): Promise<boolean> {
17
17
  let shuttingDown = false;
18
18
 
19
19
  function startOne(spec: HttpSidecarSpec, restarts = 0) {
20
- const vendorDir = path.join(PROJECT_ROOT, 'vendor', spec.dir);
20
+ const vendorDir = path.join(APP_ROOT, 'vendor', spec.dir);
21
21
  const entrypoint = path.join(vendorDir, 'src/mcp/cli.ts');
22
22
  const args = ['run', entrypoint, '--port', String(spec.port)];
23
23
  const startedAt = Date.now();
package/src/server/mcp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { dataPath } from './paths.ts';
3
+ import { dataPath, APP_ROOT } from './paths.ts';
4
4
  import { dataSync } from './data-sync.ts';
5
5
  import { getGlobalMcpsFromConfig } from './shraga-config.ts';
6
6
 
@@ -151,8 +151,6 @@ function withStdioType(config: McpConfig): McpConfig {
151
151
  return out;
152
152
  }
153
153
 
154
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
155
-
156
154
  /** Canonical path baked into MCP env when prod has the file deployed (cwd = app dir). */
157
155
  const GOOGLE_SA_DEPLOY_REL = './secrets/google-service-account.json';
158
156
 
@@ -163,7 +161,7 @@ const GOOGLE_SA_DEPLOY_REL = './secrets/google-service-account.json';
163
161
  */
164
162
  function finalizeGoogleServiceAccountCredentials(config: McpConfig): McpConfig {
165
163
  const jsonFromEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON?.trim();
166
- const defaultAbs = path.join(PROJECT_ROOT, 'secrets/google-service-account.json');
164
+ const defaultAbs = path.join(APP_ROOT, 'secrets/google-service-account.json');
167
165
  const defaultExists = existsSync(defaultAbs);
168
166
 
169
167
  let result = { ...config };
@@ -184,7 +182,7 @@ function finalizeGoogleServiceAccountCredentials(config: McpConfig): McpConfig {
184
182
  const pathOk =
185
183
  raw &&
186
184
  !raw.includes('${') &&
187
- existsSync(path.isAbsolute(raw) ? raw : path.resolve(PROJECT_ROOT, raw.replace(/^\.\//, '')));
185
+ existsSync(path.isAbsolute(raw) ? raw : path.resolve(APP_ROOT, raw.replace(/^\.\//, '')));
188
186
 
189
187
  if (pathOk) continue;
190
188
  if (!defaultExists) continue;
@@ -2,7 +2,11 @@ import path from 'node:path';
2
2
  import { readdirSync } from 'node:fs';
3
3
 
4
4
  function resolveDataDir(): string {
5
- if (process.env.DATA_DIR) return process.env.DATA_DIR;
5
+ // Absolutize against cwd: run.sh sets a RELATIVE `DATA_DIR=data-<env>`, and a relative path breaks
6
+ // any consumer that isn't cwd-relative — notably the dynamic `import(configPath)` in
7
+ // shraga-config.ts, which resolves a relative specifier against the IMPORTING MODULE
8
+ // (`node_modules/shraga/src/server/`), not the process cwd.
9
+ if (process.env.DATA_DIR) return path.resolve(process.env.DATA_DIR);
6
10
  const root = process.cwd();
7
11
  const hasNamed = readdirSync(root).some(f => f.startsWith('data-'));
8
12
  if (hasNamed) {
@@ -17,8 +21,55 @@ function resolveDataDir(): string {
17
21
  export const DATA_DIR = resolveDataDir();
18
22
  export const dataPath = (...segments: string[]) => path.join(DATA_DIR, ...segments);
19
23
 
20
- // The Shraga app root (where `defaults/` lives and the agent's project filesystem is rooted).
21
- // Derived from THIS module's stable location — `src/server/paths.ts` → repo root is two dirs up — so
22
- // it's correct even for code loaded from an overlay checkout in a different directory (where
23
- // `import.meta.dirname`-relative math would resolve to the overlay, not the app).
24
- export const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
24
+ // ── Two distinct roots. Conflating them is what broke npm-consumer deployments. ──────────────────
25
+ //
26
+ // PACKAGE_ROOT — where the SHRAGA PACKAGE's own shipped assets live (`defaults/`, `dist/client/`,
27
+ // `package.json` — everything in package.json `files`). Package-relative is CORRECT here: in an npm
28
+ // consumer these really do live under `node_modules/shraga/`. Do not "fix" this to APP_ROOT.
29
+ export const PACKAGE_ROOT = path.resolve(import.meta.dirname, '..', '..');
30
+
31
+ /**
32
+ * APP_ROOT — the DEPLOYMENT/consumer root: where `vendor/`, `secrets/` and `data/` live, and where
33
+ * the agent's project filesystem is rooted. NOT shipped in the package.
34
+ *
35
+ * In a source checkout this equals PACKAGE_ROOT. In an npm consumer (`shraga-circles`, `shraga-ee`)
36
+ * it is the CONSUMER root, while PACKAGE_ROOT is `<consumer>/node_modules/shraga` — resolving vendor
37
+ * from PACKAGE_ROOT is what silently killed ~21/25 MCPs in prod.
38
+ *
39
+ * Signal, in precedence order:
40
+ * 1. SHRAGA_APP_ROOT env — explicit escape hatch for any layout the heuristics get wrong.
41
+ * 2. node_modules ancestor — if this file sits under `.../node_modules/shraga/...`, the app root is
42
+ * the directory CONTAINING that `node_modules`. Independent of cwd, so it survives a server
43
+ * started from anywhere (systemd, a cron shell, `bun --cwd`).
44
+ * 3. process.cwd() — the source-checkout case, and already this module's established app-root signal
45
+ * (see resolveDataDir above; run.sh `cd`s to the app root before launching).
46
+ *
47
+ * Failure modes, explicit:
48
+ * - Hoisted/pnpm layouts where `shraga` resolves to a store dir outside the consumer's own
49
+ * node_modules: rule 2 picks the hoisting root, which may not be the dir holding `vendor/`.
50
+ * - A nested `node_modules/x/node_modules/shraga`: rule 2 stops at the INNERMOST node_modules.
51
+ * Both are exactly why rule 1 exists — set SHRAGA_APP_ROOT and the heuristics are bypassed.
52
+ */
53
+ function resolveAppRoot(): string {
54
+ const explicit = process.env.SHRAGA_APP_ROOT?.trim();
55
+ if (explicit) return path.resolve(explicit);
56
+
57
+ const marker = `${path.sep}node_modules${path.sep}`;
58
+ const idx = PACKAGE_ROOT.lastIndexOf(marker);
59
+ if (idx !== -1) return PACKAGE_ROOT.slice(0, idx);
60
+
61
+ return process.cwd();
62
+ }
63
+
64
+ export const APP_ROOT = resolveAppRoot();
65
+
66
+ /**
67
+ * @deprecated Ambiguous name — use APP_ROOT (vendor/secrets/data, agent cwd) or PACKAGE_ROOT
68
+ * (shipped assets) explicitly.
69
+ *
70
+ * Aliased to APP_ROOT, not PACKAGE_ROOT, deliberately: every remaining external consumer of this
71
+ * export (shraga-ee `engine/cursor.ts`, `engine/agentx.ts`) uses it as the agent's project root —
72
+ * i.e. they meant APP_ROOT and were hitting the same npm-layout bug. Pointing the alias here fixes
73
+ * them without an EE change. Nothing in this package reads shipped assets through it.
74
+ */
75
+ export const PROJECT_ROOT = APP_ROOT;
@@ -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 };
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { DATA_DIR } from './paths.ts';
3
+ import { DATA_DIR, APP_ROOT } from './paths.ts';
4
4
  import type { McpServerConfig, McpConfig, McpHttpServerConfig } from './mcp.ts';
5
5
 
6
6
  /** Shorthand for vendor-dir MCPs (auto-resolves command/args from vendor/{name}) */
@@ -70,8 +70,6 @@ export function defineConfig(config: ShragaConfig): ShragaConfig {
70
70
  return config;
71
71
  }
72
72
 
73
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
74
-
75
73
  /**
76
74
  * Config filenames, in precedence order. `shraga.config.ts` is canonical; `unclaw.config.ts` is
77
75
  * the legacy name kept for back-compat — existing deployments have that file in their data dir,
@@ -131,7 +129,7 @@ export function getGlobalMcpsFromConfig(): McpConfig {
131
129
  result[name] = { type: 'stdio', ...full } satisfies McpServerConfig;
132
130
  } else {
133
131
  const shorthand = entry as McpShorthandEntry;
134
- const vendorDir = path.join(PROJECT_ROOT, 'vendor', shorthand.dir ?? name);
132
+ const vendorDir = path.join(APP_ROOT, 'vendor', shorthand.dir ?? name);
135
133
  const command = shorthand.command ?? 'bun';
136
134
  const args = shorthand.args ?? ['run', path.join(vendorDir, 'src/mcp/cli.ts'), '--stdio'];
137
135
  const env: Record<string, string> = {};
@@ -1,13 +1,11 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync, unlinkSync, renameSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { DATA_DIR, dataPath } from './paths.ts';
3
+ import { DATA_DIR, dataPath, APP_ROOT } from './paths.ts';
4
4
  import { getBuiltinSkillNames } from './seed.ts';
5
5
  import { dataSync } from './data-sync.ts';
6
6
  import { injectFile } from './file-inject.ts';
7
7
  import { getGlobalMcpConfig } from './mcp.ts';
8
8
 
9
- const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
10
-
11
9
  const SKILLS_DIR = dataPath('skills');
12
10
  const DEFAULTS_PATH = dataPath('skills-defaults.json');
13
11
 
@@ -108,7 +106,7 @@ function formatMcpCommandBlock(mcpName: string, skillBody: string, args: string)
108
106
  * Markdown from `vendor/<serverName>/.claude/skills/<serverName>/SKILL.md` — same file the MCP exposes as skill://serverName/workflow.
109
107
  */
110
108
  export function resolveMcpBundledSkillContent(serverName: string): string | null {
111
- const file = path.join(PROJECT_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
109
+ const file = path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
112
110
  if (!existsSync(file)) return null;
113
111
  return readFileSync(file, 'utf-8');
114
112
  }
@@ -128,7 +126,7 @@ export function resolvedSkillInjectionBlock(name: string): string | null {
128
126
  }
129
127
 
130
128
  function mcpSkillFilePath(serverName: string): string {
131
- return path.join(PROJECT_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
129
+ return path.join(APP_ROOT, 'vendor', serverName, '.claude/skills', serverName, 'SKILL.md');
132
130
  }
133
131
 
134
132
  /**