@sna-sdk/core 0.3.0 → 0.5.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 CHANGED
@@ -9,6 +9,7 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
9
9
  - **SQLite database** — schema and `getDb()` for `skill_events`, `chat_sessions`, `chat_messages`
10
10
  - **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
11
11
  - **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
12
+ - **History management** — `agent.resume` auto-loads DB history, `agent.subscribe({ since: 0 })` unified history+realtime channel
12
13
  - **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
13
14
  - **CLI** — `sna up/down/status`, `sna dispatch`, `sna gen client`, `sna tu` (mock API testing)
14
15
  - **Agent providers** — Claude Code and Codex process management
@@ -86,6 +87,11 @@ const db = getDb(); // SQLite instance (data/sna.db)
86
87
  | `@sna-sdk/core/lib/sna-run` | `snaRun()` helper for spawning Claude Code |
87
88
  | `@sna-sdk/core/testing` | `startMockAnthropicServer()` for testing without real API calls |
88
89
 
90
+ **Environment Variables:**
91
+ - `SNA_DB_PATH` — Override SQLite database location (default: `process.cwd()/data/sna.db`)
92
+ - `SNA_CLAUDE_COMMAND` — Override claude binary path
93
+ - `SNA_PORT` — API server port (default: 3099)
94
+
89
95
  ## Documentation
90
96
 
91
97
  - [Architecture](https://github.com/neuradex/sna/blob/main/docs/architecture.md)
@@ -0,0 +1,37 @@
1
+ import { HistoryMessage } from './types.js';
2
+
3
+ /**
4
+ * History injection for Claude Code via JSONL resume.
5
+ *
6
+ * Writes a JSONL session file and passes --resume <filepath> to CC.
7
+ * CC loads it as real multi-turn conversation history.
8
+ *
9
+ * Key discovery: --resume with a .jsonl file path bypasses CC's project
10
+ * directory lookup and calls loadMessagesFromJsonlPath directly.
11
+ * This is the only reliable way to inject synthetic history.
12
+ *
13
+ * Verified: real Claude Haiku correctly recalls injected context.
14
+ * Fallback: recalled-conversation XML if file write fails.
15
+ */
16
+
17
+ /**
18
+ * Write a JSONL session file for --resume <filepath>.
19
+ *
20
+ * Minimal format (verified working):
21
+ * {"parentUuid":null,"isSidechain":false,"type":"user","uuid":"...","timestamp":"...","cwd":"...","sessionId":"...","message":{"role":"user","content":"..."}}
22
+ * {"parentUuid":"<prev>","isSidechain":false,"type":"assistant","uuid":"...","timestamp":"...","cwd":"...","sessionId":"...","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
23
+ */
24
+ declare function writeHistoryJsonl(history: HistoryMessage[], opts: {
25
+ cwd: string;
26
+ }): {
27
+ filePath: string;
28
+ extraArgs: string[];
29
+ } | null;
30
+ /**
31
+ * Pack history into a single assistant stdin message.
32
+ * CC treats type:"assistant" as context injection (no API call triggered).
33
+ * Used when file write fails.
34
+ */
35
+ declare function buildRecalledConversation(history: HistoryMessage[]): string;
36
+
37
+ export { buildRecalledConversation, writeHistoryJsonl };
@@ -0,0 +1,70 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ function writeHistoryJsonl(history, opts) {
4
+ for (let i = 1; i < history.length; i++) {
5
+ if (history[i].role === history[i - 1].role) {
6
+ throw new Error(
7
+ `History validation failed: consecutive ${history[i].role} at index ${i - 1} and ${i}. Messages must alternate user\u2194assistant. Merge tool results into text before injecting.`
8
+ );
9
+ }
10
+ }
11
+ try {
12
+ const dir = path.join(opts.cwd, ".sna", "history");
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ const sessionId = crypto.randomUUID();
15
+ const filePath = path.join(dir, `${sessionId}.jsonl`);
16
+ const now = (/* @__PURE__ */ new Date()).toISOString();
17
+ const lines = [];
18
+ let prevUuid = null;
19
+ for (const msg of history) {
20
+ const uuid = crypto.randomUUID();
21
+ if (msg.role === "user") {
22
+ lines.push(JSON.stringify({
23
+ parentUuid: prevUuid,
24
+ isSidechain: false,
25
+ type: "user",
26
+ uuid,
27
+ timestamp: now,
28
+ cwd: opts.cwd,
29
+ sessionId,
30
+ message: { role: "user", content: msg.content }
31
+ }));
32
+ } else {
33
+ lines.push(JSON.stringify({
34
+ parentUuid: prevUuid,
35
+ isSidechain: false,
36
+ type: "assistant",
37
+ uuid,
38
+ timestamp: now,
39
+ cwd: opts.cwd,
40
+ sessionId,
41
+ message: {
42
+ role: "assistant",
43
+ content: [{ type: "text", text: msg.content }]
44
+ }
45
+ }));
46
+ }
47
+ prevUuid = uuid;
48
+ }
49
+ fs.writeFileSync(filePath, lines.join("\n") + "\n");
50
+ return { filePath, extraArgs: ["--resume", filePath] };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ function buildRecalledConversation(history) {
56
+ const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
57
+ return JSON.stringify({
58
+ type: "assistant",
59
+ message: {
60
+ role: "assistant",
61
+ content: [{ type: "text", text: `<recalled-conversation>
62
+ ${xml}
63
+ </recalled-conversation>` }]
64
+ }
65
+ });
66
+ }
67
+ export {
68
+ buildRecalledConversation,
69
+ writeHistoryJsonl
70
+ };
@@ -2,6 +2,7 @@ import { spawn, execSync } from "child_process";
2
2
  import { EventEmitter } from "events";
3
3
  import fs from "fs";
4
4
  import path from "path";
5
+ import { writeHistoryJsonl, buildRecalledConversation } from "./cc-history-adapter.js";
5
6
  import { logger } from "../../lib/logger.js";
6
7
  const SHELL = process.env.SHELL || "/bin/zsh";
7
8
  function resolveClaudePath(cwd) {
@@ -79,33 +80,44 @@ class ClaudeCodeProcess {
79
80
  this._alive = false;
80
81
  this.emitter.emit("error", err);
81
82
  });
82
- if (options.history?.length) {
83
- if (!options.prompt) {
84
- throw new Error("history requires a prompt \u2014 the last stdin message must be a user message");
85
- }
86
- for (const msg of options.history) {
87
- if (msg.role === "user") {
88
- const line = JSON.stringify({
89
- type: "user",
90
- message: { role: "user", content: msg.content }
91
- });
92
- this.proc.stdin.write(line + "\n");
93
- } else if (msg.role === "assistant") {
94
- const line = JSON.stringify({
95
- type: "assistant",
96
- message: {
97
- role: "assistant",
98
- content: [{ type: "text", text: msg.content }]
99
- }
100
- });
101
- this.proc.stdin.write(line + "\n");
102
- }
103
- }
83
+ if (options.history?.length && !options._historyViaResume) {
84
+ const line = buildRecalledConversation(options.history);
85
+ this.proc.stdin.write(line + "\n");
104
86
  }
105
87
  if (options.prompt) {
106
88
  this.send(options.prompt);
107
89
  }
108
90
  }
91
+ /**
92
+ * Split completed assistant text into chunks and emit assistant_delta events
93
+ * at a fixed rate (~270 chars/sec), followed by the final assistant event.
94
+ *
95
+ * CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
96
+ */
97
+ emitTextAsDeltas(text) {
98
+ const CHUNK_SIZE = 4;
99
+ const CHUNK_DELAY_MS = 15;
100
+ let t = 0;
101
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
102
+ const chunk = text.slice(i, i + CHUNK_SIZE);
103
+ setTimeout(() => {
104
+ this.emitter.emit("event", {
105
+ type: "assistant_delta",
106
+ delta: chunk,
107
+ index: 0,
108
+ timestamp: Date.now()
109
+ });
110
+ }, t);
111
+ t += CHUNK_DELAY_MS;
112
+ }
113
+ setTimeout(() => {
114
+ this.emitter.emit("event", {
115
+ type: "assistant",
116
+ message: text,
117
+ timestamp: Date.now()
118
+ });
119
+ }, t);
120
+ }
109
121
  get alive() {
110
122
  return this._alive;
111
123
  }
@@ -181,6 +193,7 @@ class ClaudeCodeProcess {
181
193
  const content = msg.message?.content;
182
194
  if (!Array.isArray(content)) return null;
183
195
  const events = [];
196
+ const textBlocks = [];
184
197
  for (const block of content) {
185
198
  if (block.type === "thinking") {
186
199
  events.push({
@@ -198,15 +211,17 @@ class ClaudeCodeProcess {
198
211
  } else if (block.type === "text") {
199
212
  const text = (block.text ?? "").trim();
200
213
  if (text) {
201
- events.push({ type: "assistant", message: text, timestamp: Date.now() });
214
+ textBlocks.push(text);
202
215
  }
203
216
  }
204
217
  }
205
- if (events.length > 0) {
206
- for (let i = 1; i < events.length; i++) {
207
- this.emitter.emit("event", events[i]);
218
+ if (events.length > 0 || textBlocks.length > 0) {
219
+ for (const e of events) {
220
+ this.emitter.emit("event", e);
221
+ }
222
+ for (const text of textBlocks) {
223
+ this.emitTextAsDeltas(text);
208
224
  }
209
- return events[0];
210
225
  }
211
226
  return null;
212
227
  }
@@ -341,6 +356,14 @@ class ClaudeCodeProvider {
341
356
  if (options.permissionMode) {
342
357
  args.push("--permission-mode", options.permissionMode);
343
358
  }
359
+ if (options.history?.length && options.prompt) {
360
+ const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
361
+ if (result) {
362
+ args.push(...result.extraArgs);
363
+ options._historyViaResume = true;
364
+ logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
365
+ }
366
+ }
344
367
  if (extraArgsClean.length > 0) {
345
368
  args.push(...extraArgsClean);
346
369
  }
@@ -5,9 +5,13 @@
5
5
  * Codex JSONL, etc.) into these common types.
6
6
  */
7
7
  interface AgentEvent {
8
- type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "interrupted" | "error" | "complete";
8
+ type: "init" | "thinking" | "text_delta" | "assistant_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "user_message" | "interrupted" | "error" | "complete";
9
9
  message?: string;
10
10
  data?: Record<string, unknown>;
11
+ /** Streaming text delta (for assistant_delta events only) */
12
+ delta?: string;
13
+ /** Content block index (for assistant_delta events only) */
14
+ index?: number;
11
15
  timestamp: number;
12
16
  }
13
17
  /**
@@ -63,6 +67,8 @@ interface SpawnOptions {
63
67
  * Must alternate user→assistant. Assistant content is auto-wrapped in array format.
64
68
  */
65
69
  history?: HistoryMessage[];
70
+ /** @internal Set by provider when history was injected via JSONL resume. */
71
+ _historyViaResume?: boolean;
66
72
  /**
67
73
  * Additional CLI flags passed directly to the agent binary.
68
74
  * e.g. ["--system-prompt", "You are...", "--append-system-prompt", "Also...", "--mcp-config", "path"]
package/dist/db/schema.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
- const DB_PATH = path.join(process.cwd(), "data/sna.db");
4
+ const DB_PATH = process.env.SNA_DB_PATH ?? path.join(process.cwd(), "data/sna.db");
5
5
  const NATIVE_DIR = path.join(process.cwd(), ".sna/native");
6
6
  let _db = null;
7
7
  function loadBetterSqlite3() {
@@ -212,6 +212,9 @@ function cmdTu(args2) {
212
212
  case "claude":
213
213
  cmdTuClaude(args2.slice(1));
214
214
  break;
215
+ case "claude:oneshot":
216
+ cmdTuClaudeOneshot(args2.slice(1));
217
+ break;
215
218
  default:
216
219
  console.log(`
217
220
  sna tu \u2014 Test utilities (mock Anthropic API)
@@ -221,7 +224,8 @@ function cmdTu(args2) {
221
224
  sna tu api:down Stop mock API server
222
225
  sna tu api:log Show mock API request/response log
223
226
  sna tu api:log -f Follow log in real-time (tail -f)
224
- sna tu claude ... Run claude with mock API env vars (proxy)
227
+ sna tu claude ... Run claude with mock API env vars (proxy)
228
+ sna tu claude:oneshot ... One-shot: mock server \u2192 claude \u2192 results \u2192 cleanup
225
229
 
226
230
  Flow:
227
231
  1. sna tu api:up \u2192 mock server on random port
@@ -338,6 +342,19 @@ function cmdTuClaude(args2) {
338
342
  process.exit(e.status ?? 1);
339
343
  }
340
344
  }
345
+ function cmdTuClaudeOneshot(args2) {
346
+ const scriptDir = path.dirname(new URL(import.meta.url).pathname);
347
+ const oneshotScript = fs.existsSync(path.join(scriptDir, "tu-oneshot.js")) ? path.join(scriptDir, "tu-oneshot.js") : path.join(scriptDir, "tu-oneshot.ts");
348
+ try {
349
+ execSync(`node --import tsx "${oneshotScript}" ${args2.map((a) => `"${a}"`).join(" ")}`, {
350
+ stdio: "inherit",
351
+ cwd: ROOT,
352
+ timeout: 9e4
353
+ });
354
+ } catch (e) {
355
+ process.exit(e.status ?? 1);
356
+ }
357
+ }
341
358
  function readPidFile(filePath) {
342
359
  try {
343
360
  return parseInt(fs.readFileSync(filePath, "utf8").trim(), 10) || null;
@@ -784,7 +801,8 @@ Testing:
784
801
  sna tu api:down Stop mock API server
785
802
  sna tu api:log Show mock API request/response log
786
803
  sna tu api:log -f Follow log in real-time
787
- sna tu claude ... Run claude with mock API (isolated env, no account pollution)
804
+ sna tu claude ... Run claude with mock API (isolated env)
805
+ sna tu claude:oneshot ... One-shot: mock server \u2192 claude \u2192 structured results \u2192 cleanup
788
806
 
789
807
  Set SNA_CLAUDE_COMMAND to override claude binary in SDK.
790
808
  See: docs/testing.md
@@ -0,0 +1,2 @@
1
+
2
+ export { }
@@ -0,0 +1,66 @@
1
+ import { startMockAnthropicServer } from "../testing/mock-api.js";
2
+ import { spawn } from "child_process";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ async function main() {
6
+ const ROOT = process.cwd();
7
+ const STATE_DIR = path.join(ROOT, ".sna");
8
+ const args = process.argv.slice(2);
9
+ let claudePath = "claude";
10
+ const cachedPath = path.join(STATE_DIR, "claude-path");
11
+ if (fs.existsSync(cachedPath)) {
12
+ claudePath = fs.readFileSync(cachedPath, "utf8").trim() || claudePath;
13
+ }
14
+ const mock = await startMockAnthropicServer();
15
+ const mockConfigDir = path.join(STATE_DIR, "mock-claude-oneshot");
16
+ fs.mkdirSync(mockConfigDir, { recursive: true });
17
+ const env = {
18
+ PATH: process.env.PATH ?? "",
19
+ HOME: process.env.HOME ?? "",
20
+ SHELL: process.env.SHELL ?? "/bin/zsh",
21
+ TERM: process.env.TERM ?? "xterm-256color",
22
+ LANG: process.env.LANG ?? "en_US.UTF-8",
23
+ ANTHROPIC_BASE_URL: `http://localhost:${mock.port}`,
24
+ ANTHROPIC_API_KEY: "sk-test-mock-oneshot",
25
+ CLAUDE_CONFIG_DIR: mockConfigDir
26
+ };
27
+ const stdoutPath = path.join(STATE_DIR, "mock-claude-stdout.log");
28
+ const stderrPath = path.join(STATE_DIR, "mock-claude-stderr.log");
29
+ const proc = spawn(claudePath, args, {
30
+ env,
31
+ cwd: ROOT,
32
+ stdio: ["ignore", "pipe", "pipe"]
33
+ });
34
+ let stdout = "";
35
+ let stderr = "";
36
+ proc.stdout.on("data", (d) => {
37
+ stdout += d.toString();
38
+ });
39
+ proc.stderr.on("data", (d) => {
40
+ stderr += d.toString();
41
+ });
42
+ proc.stdout.pipe(process.stdout);
43
+ proc.on("exit", (code) => {
44
+ fs.writeFileSync(stdoutPath, stdout);
45
+ fs.writeFileSync(stderrPath, stderr);
46
+ console.log(`
47
+ ${"\u2500".repeat(60)}`);
48
+ console.log(`Mock API: ${mock.requests.length} request(s)`);
49
+ for (const req of mock.requests) {
50
+ console.log(` model=${req.model} stream=${req.stream} messages=${req.messages?.length}`);
51
+ }
52
+ console.log(`
53
+ Log files:`);
54
+ console.log(` stdout: ${stdoutPath}`);
55
+ console.log(` stderr: ${stderrPath}`);
56
+ console.log(` api log: ${path.join(STATE_DIR, "mock-api-last-request.json")}`);
57
+ console.log(` config: ${mockConfigDir}`);
58
+ console.log(` exit: ${code}`);
59
+ mock.close();
60
+ process.exit(code ?? 0);
61
+ });
62
+ setTimeout(() => {
63
+ proc.kill();
64
+ }, 6e4);
65
+ }
66
+ main();
@@ -25,6 +25,12 @@ interface ApiResponses {
25
25
  "agent.send": {
26
26
  status: "sent";
27
27
  };
28
+ "agent.resume": {
29
+ status: "resumed";
30
+ provider: string;
31
+ sessionId: string;
32
+ historyCount: number;
33
+ };
28
34
  "agent.restart": {
29
35
  status: "restarted";
30
36
  provider: string;
@@ -46,9 +52,16 @@ interface ApiResponses {
46
52
  };
47
53
  "agent.status": {
48
54
  alive: boolean;
55
+ agentStatus: "idle" | "busy" | "disconnected";
49
56
  sessionId: string | null;
50
57
  ccSessionId: string | null;
51
58
  eventCount: number;
59
+ messageCount: number;
60
+ lastMessage: {
61
+ role: string;
62
+ content: string;
63
+ created_at: string;
64
+ } | null;
52
65
  config: {
53
66
  provider: string;
54
67
  model: string;
@@ -0,0 +1,16 @@
1
+ import { HistoryMessage } from '../core/providers/types.js';
2
+
3
+ /**
4
+ * Build HistoryMessage[] from chat_messages DB records.
5
+ *
6
+ * Filters to user/assistant roles, ensures alternation,
7
+ * and merges consecutive same-role messages.
8
+ */
9
+
10
+ /**
11
+ * Load conversation history from DB for a session.
12
+ * Returns alternating user↔assistant messages ready for JSONL injection.
13
+ */
14
+ declare function buildHistoryFromDb(sessionId: string): HistoryMessage[];
15
+
16
+ export { buildHistoryFromDb };
@@ -0,0 +1,25 @@
1
+ import { getDb } from "../db/schema.js";
2
+ function buildHistoryFromDb(sessionId) {
3
+ const db = getDb();
4
+ const rows = db.prepare(
5
+ `SELECT role, content FROM chat_messages
6
+ WHERE session_id = ? AND role IN ('user', 'assistant')
7
+ ORDER BY id ASC`
8
+ ).all(sessionId);
9
+ if (rows.length === 0) return [];
10
+ const merged = [];
11
+ for (const row of rows) {
12
+ const role = row.role;
13
+ if (!row.content?.trim()) continue;
14
+ const last = merged[merged.length - 1];
15
+ if (last && last.role === role) {
16
+ last.content += "\n\n" + row.content;
17
+ } else {
18
+ merged.push({ role, content: row.content });
19
+ }
20
+ }
21
+ return merged;
22
+ }
23
+ export {
24
+ buildHistoryFromDb
25
+ };
@@ -1,13 +1,14 @@
1
1
  import * as hono_types from 'hono/types';
2
2
  import { Hono } from 'hono';
3
3
  import { SessionManager } from './session-manager.js';
4
- export { Session, SessionConfigChangedEvent, SessionInfo, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
4
+ export { AgentStatus, Session, SessionConfigChangedEvent, SessionInfo, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
5
5
  export { eventsRoute } from './routes/events.js';
6
6
  export { createEmitRoute, emitRoute } from './routes/emit.js';
7
7
  export { createRunRoute } from './routes/run.js';
8
8
  export { createAgentRoutes } from './routes/agent.js';
9
9
  export { createChatRoutes } from './routes/chat.js';
10
10
  export { attachWebSocket } from './ws.js';
11
+ export { buildHistoryFromDb } from './history-builder.js';
11
12
  import '../core/providers/types.js';
12
13
  import 'hono/utils/http-status';
13
14
  import 'ws';
@@ -27,6 +27,7 @@ import { createAgentRoutes as createAgentRoutes2 } from "./routes/agent.js";
27
27
  import { createChatRoutes as createChatRoutes2 } from "./routes/chat.js";
28
28
  import { SessionManager as SessionManager2 } from "./session-manager.js";
29
29
  import { attachWebSocket } from "./ws.js";
30
+ import { buildHistoryFromDb } from "./history-builder.js";
30
31
  function snaPortRoute(c) {
31
32
  const portFile = _path.join(process.cwd(), ".sna/sna-api.port");
32
33
  try {
@@ -39,6 +40,7 @@ function snaPortRoute(c) {
39
40
  export {
40
41
  SessionManager2 as SessionManager,
41
42
  attachWebSocket,
43
+ buildHistoryFromDb,
42
44
  createAgentRoutes2 as createAgentRoutes,
43
45
  createChatRoutes2 as createChatRoutes,
44
46
  createEmitRoute2 as createEmitRoute,