@hamak/ai-agent-backend 0.9.3 → 0.9.5

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.
@@ -8,10 +8,24 @@
8
8
  *
9
9
  * Wire format (POST https://api.anthropic.com/v1/messages):
10
10
  * headers: x-api-key, anthropic-version: 2023-06-01, content-type: json
11
- * body: { model, max_tokens, system?, messages, stream }
11
+ * body: { model, max_tokens, system?, tools?, messages, stream }
12
+ * tools: [{ name, description, input_schema }] — sent only when the loop
13
+ * advertises tools (`CompleteOpts.tools`).
14
+ * reply: content blocks `text` and `tool_use { id, name, input }`;
15
+ * `stop_reason: "tool_use"` when the model wants a tool run.
12
16
  * stream: SSE — message_start (usage.input_tokens),
13
- * content_block_delta/text_delta (delta.text),
14
- * message_delta (usage.output_tokens), message_stop.
17
+ * content_block_start (tool_use: id + name),
18
+ * content_block_delta/text_delta (delta.text) or
19
+ * content_block_delta/input_json_delta (delta.partial_json),
20
+ * content_block_stop, message_delta (usage.output_tokens),
21
+ * message_stop.
22
+ *
23
+ * History mapping (issue #52): assistant turns that called tools are sent as
24
+ * `[text?, tool_use…]` blocks and the `role: 'tool'` results that follow are
25
+ * grouped into ONE user message of `tool_result` blocks — the API rejects a
26
+ * `tool_result` that does not answer a `tool_use` in the immediately preceding
27
+ * assistant turn, and rejects consecutive same-role turns, so both invariants
28
+ * are enforced in {@link toAnthropicMessages}.
15
29
  *
16
30
  * Note: Opus 4.8 / 4.7 reject `temperature`/`top_p` (400), so `temperature` is
17
31
  * sent only when explicitly configured. Default model: claude-opus-4-8.
@@ -1 +1 @@
1
- {"version":3,"file":"anthropic-provider.d.ts","sourceRoot":"","sources":["../src/anthropic-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EACV,UAAU,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,cAAc,EACd,WAAW,EACZ,MAAM,iBAAiB,CAAC;AAOzB,MAAM,WAAW,uBAAuB;IACtC,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AA4BD,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;gBAExB,MAAM,GAAE,uBAA4B;IAchD,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,IAAI;IAYN,QAAQ,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IAgC3E,MAAM,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,UAAU,CAAC;CAgDvF"}
1
+ {"version":3,"file":"anthropic-provider.d.ts","sourceRoot":"","sources":["../src/anthropic-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,KAAK,EACV,UAAU,EACV,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,cAAc,EACd,WAAW,EAIZ,MAAM,iBAAiB,CAAC;AASzB,MAAM,WAAW,uBAAuB;IACtC,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sDAAsD;IACtD,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,oEAAoE;IACpE,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB;AAuMD,qBAAa,iBAAkB,YAAW,cAAc;IACtD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAc;gBAExB,MAAM,GAAE,uBAA4B;IAchD,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,IAAI;IAaN,QAAQ,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA8C3E,MAAM,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,EAAE,YAAY,GAAG,aAAa,CAAC,UAAU,CAAC;CA0FvF"}
@@ -8,10 +8,24 @@
8
8
  *
9
9
  * Wire format (POST https://api.anthropic.com/v1/messages):
10
10
  * headers: x-api-key, anthropic-version: 2023-06-01, content-type: json
11
- * body: { model, max_tokens, system?, messages, stream }
11
+ * body: { model, max_tokens, system?, tools?, messages, stream }
12
+ * tools: [{ name, description, input_schema }] — sent only when the loop
13
+ * advertises tools (`CompleteOpts.tools`).
14
+ * reply: content blocks `text` and `tool_use { id, name, input }`;
15
+ * `stop_reason: "tool_use"` when the model wants a tool run.
12
16
  * stream: SSE — message_start (usage.input_tokens),
13
- * content_block_delta/text_delta (delta.text),
14
- * message_delta (usage.output_tokens), message_stop.
17
+ * content_block_start (tool_use: id + name),
18
+ * content_block_delta/text_delta (delta.text) or
19
+ * content_block_delta/input_json_delta (delta.partial_json),
20
+ * content_block_stop, message_delta (usage.output_tokens),
21
+ * message_stop.
22
+ *
23
+ * History mapping (issue #52): assistant turns that called tools are sent as
24
+ * `[text?, tool_use…]` blocks and the `role: 'tool'` results that follow are
25
+ * grouped into ONE user message of `tool_result` blocks — the API rejects a
26
+ * `tool_result` that does not answer a `tool_use` in the immediately preceding
27
+ * assistant turn, and rejects consecutive same-role turns, so both invariants
28
+ * are enforced in {@link toAnthropicMessages}.
15
29
  *
16
30
  * Note: Opus 4.8 / 4.7 reject `temperature`/`top_p` (400), so `temperature` is
17
31
  * sent only when explicitly configured. Default model: claude-opus-4-8.
@@ -20,21 +34,160 @@ import { fetchWithRetry } from '@hamak/ai-agent';
20
34
  const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
21
35
  const ANTHROPIC_VERSION = '2023-06-01';
22
36
  const DEFAULT_MAX_TOKENS = 4096;
23
- /** Map agent messages to Anthropic's user/assistant turns. */
37
+ /** Schema sent for a tool that declares none — the API requires an object schema. */
38
+ const EMPTY_INPUT_SCHEMA = { type: 'object', properties: {} };
39
+ // ---------------------------------------------------------------------------
40
+ // Request mapping
41
+ // ---------------------------------------------------------------------------
42
+ function toAnthropicTools(tools) {
43
+ return tools.map((t) => ({
44
+ name: t.name,
45
+ description: t.description,
46
+ input_schema: t.inputSchema ?? EMPTY_INPUT_SCHEMA,
47
+ }));
48
+ }
49
+ /**
50
+ * Map agent messages to Anthropic's user/assistant turns.
51
+ *
52
+ * Invariants enforced (the API 400s otherwise):
53
+ * - `system` messages are dropped here (sent in the top-level `system` field);
54
+ * - assistant `toolCalls` become `tool_use` blocks, and the `role: 'tool'`
55
+ * messages that follow are grouped into ONE user message of `tool_result`
56
+ * blocks, each paired with a `tool_use` id of that assistant turn;
57
+ * - a `tool_use` with no recorded result gets an `is_error` placeholder so the
58
+ * pairing stays complete; a result with no matching call is sent as text;
59
+ * - adjacent same-role turns are merged; the first turn is always `user`;
60
+ * - empty assistant turns (e.g. an aborted stream) are skipped.
61
+ */
24
62
  function toAnthropicMessages(messages) {
25
63
  const out = [];
26
- for (const m of messages) {
64
+ const push = (role, blocks) => {
65
+ if (blocks.length === 0)
66
+ return;
67
+ const last = out[out.length - 1];
68
+ if (last && last.role === role) {
69
+ last.content = [...asBlocks(last.content), ...blocks];
70
+ }
71
+ else {
72
+ out.push({ role, content: blocks });
73
+ }
74
+ };
75
+ /** Ids of the tool_use blocks in the latest assistant turn, awaiting results. */
76
+ let openCalls = [];
77
+ for (let i = 0; i < messages.length; i += 1) {
78
+ const m = messages[i];
27
79
  if (m.role === 'system')
28
- continue; // system is passed separately
29
- // tool results are surfaced as plain user text in this text-only skeleton
30
- out.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content });
80
+ continue;
81
+ if (m.role === 'assistant') {
82
+ const blocks = [];
83
+ if (m.content)
84
+ blocks.push({ type: 'text', text: m.content });
85
+ for (const call of m.toolCalls ?? []) {
86
+ blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input ?? {} });
87
+ }
88
+ if (blocks.length === 0)
89
+ continue; // aborted / in-progress placeholder
90
+ // Close any still-open calls before starting a new assistant turn.
91
+ push('user', placeholders(openCalls));
92
+ openCalls = (m.toolCalls ?? []).map((c) => c.id);
93
+ push('assistant', blocks);
94
+ continue;
95
+ }
96
+ if (m.role === 'tool') {
97
+ // Gather the whole run of consecutive tool results into one user turn.
98
+ const results = [];
99
+ const orphans = [];
100
+ let j = i;
101
+ while (j < messages.length && messages[j].role === 'tool') {
102
+ const t = messages[j];
103
+ const idx = t.toolCallId ? openCalls.indexOf(t.toolCallId) : -1;
104
+ if (idx !== -1 && t.toolCallId) {
105
+ openCalls.splice(idx, 1);
106
+ results.push({ type: 'tool_result', tool_use_id: t.toolCallId, content: t.content });
107
+ }
108
+ else if (t.content) {
109
+ orphans.push({ type: 'text', text: t.content });
110
+ }
111
+ j += 1;
112
+ }
113
+ i = j - 1;
114
+ // Every tool_use must be answered in this same user message.
115
+ results.push(...placeholders(openCalls));
116
+ openCalls = [];
117
+ push('user', [...results, ...orphans]);
118
+ continue;
119
+ }
120
+ // user
121
+ push('user', placeholders(openCalls));
122
+ openCalls = [];
123
+ if (m.content)
124
+ push('user', [{ type: 'text', text: m.content }]);
31
125
  }
126
+ // Trailing unanswered calls (the loop always records results before the next
127
+ // provider call, so this only guards a truncated history).
128
+ push('user', placeholders(openCalls));
32
129
  // The Messages API requires the first turn to be `user`.
33
130
  if (out.length > 0 && out[0].role !== 'user') {
34
131
  out.unshift({ role: 'user', content: '(context)' });
35
132
  }
133
+ // Collapse text-only turns to plain strings (smaller payload, stable shape).
134
+ for (const turn of out)
135
+ turn.content = collapse(turn.content);
36
136
  return out;
37
137
  }
138
+ function placeholders(ids) {
139
+ return ids.map((id) => ({
140
+ type: 'tool_result',
141
+ tool_use_id: id,
142
+ content: 'No result was recorded for this tool call.',
143
+ is_error: true,
144
+ }));
145
+ }
146
+ function asBlocks(content) {
147
+ return typeof content === 'string' ? [{ type: 'text', text: content }] : content;
148
+ }
149
+ function collapse(content) {
150
+ if (typeof content === 'string')
151
+ return content;
152
+ if (content.every((b) => b.type === 'text')) {
153
+ return content.map((b) => b.text).join('\n\n');
154
+ }
155
+ return content;
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // Response mapping
159
+ // ---------------------------------------------------------------------------
160
+ /**
161
+ * Parse a streamed `tool_use` input. Unparsable JSON is an error, never a
162
+ * tool call: executing a tool with garbage input is worse than failing the
163
+ * turn, and a truncated input is handled by the caller via `stop_reason`.
164
+ */
165
+ function parseToolInput(raw, call) {
166
+ const trimmed = raw.trim();
167
+ if (!trimmed)
168
+ return {};
169
+ try {
170
+ return JSON.parse(trimmed);
171
+ }
172
+ catch {
173
+ throw new Error(`[anthropic] tool_use input for "${call.name}" (${call.id}) is not valid JSON`);
174
+ }
175
+ }
176
+ /** Map the wire `stop_reason` to the provider-neutral {@link StopReason}. */
177
+ function toStopReason(raw) {
178
+ switch (raw) {
179
+ case 'end_turn':
180
+ case 'tool_use':
181
+ case 'max_tokens':
182
+ case 'refusal':
183
+ case 'stop_sequence':
184
+ return raw;
185
+ default:
186
+ // `pause_turn` (server-tool long turns: resend to continue) and anything
187
+ // newer are left undefined rather than mistaken for a finished turn.
188
+ return undefined;
189
+ }
190
+ }
38
191
  let seq = 0;
39
192
  function genId() {
40
193
  seq += 1;
@@ -70,6 +223,7 @@ export class AnthropicProvider {
70
223
  ...(opts.systemPrompt ? { system: opts.systemPrompt } : {}),
71
224
  // Only send temperature when explicitly set — Opus 4.8/4.7 reject it.
72
225
  ...(opts.temperature != null ? { temperature: opts.temperature } : {}),
226
+ ...(opts.tools && opts.tools.length > 0 ? { tools: toAnthropicTools(opts.tools) } : {}),
73
227
  messages: toAnthropicMessages(messages),
74
228
  stream,
75
229
  });
@@ -85,17 +239,32 @@ export class AnthropicProvider {
85
239
  throw new Error(`[anthropic] ${res.status} ${res.statusText}: ${await safeText(res)}`);
86
240
  }
87
241
  const json = (await res.json());
88
- const content = (json.content ?? [])
242
+ const blocks = json.content ?? [];
243
+ const stopReason = toStopReason(json.stop_reason);
244
+ const content = blocks
89
245
  .filter((b) => b.type === 'text')
90
246
  .map((b) => b.text ?? '')
91
247
  .join('');
248
+ // A `max_tokens` stop can leave a tool_use block with a cut-off input; the
249
+ // JSON body gives no per-block completeness signal, so no tool call from
250
+ // such a response is executed — the stop reason tells the loop why.
251
+ const toolCalls = stopReason === 'max_tokens'
252
+ ? []
253
+ : blocks
254
+ .filter((b) => b.type === 'tool_use')
255
+ .map((b, n) => ({ id: b.id ?? `toolu-${genId()}-${n}`, name: b.name ?? '', input: b.input ?? {} }));
92
256
  return {
93
257
  message: {
94
258
  id: genId(),
95
259
  role: 'assistant',
96
260
  content,
261
+ ...(toolCalls.length > 0
262
+ ? { toolCalls: toolCalls.map((c) => ({ id: c.id, name: c.name, input: c.input })) }
263
+ : {}),
97
264
  createdAt: new Date().toISOString(),
98
265
  },
266
+ ...(toolCalls.length > 0 ? { toolCalls } : {}),
267
+ ...(stopReason ? { stopReason } : {}),
99
268
  usage: {
100
269
  inputTokens: json.usage?.input_tokens ?? 0,
101
270
  outputTokens: json.usage?.output_tokens ?? 0,
@@ -118,6 +287,10 @@ export class AnthropicProvider {
118
287
  let buffer = '';
119
288
  let inputTokens = 0;
120
289
  let outputTokens = 0;
290
+ /** In-flight `tool_use` blocks keyed by content-block index. */
291
+ const pending = new Map();
292
+ const toolCalls = [];
293
+ let stopReason;
121
294
  try {
122
295
  for (;;) {
123
296
  const { value, done } = await reader.read();
@@ -134,15 +307,45 @@ export class AnthropicProvider {
134
307
  if (!payload || payload === '[DONE]')
135
308
  continue;
136
309
  const evt = JSON.parse(payload);
310
+ const index = evt.index ?? 0;
137
311
  if (evt.type === 'message_start') {
138
312
  inputTokens = evt.message?.usage?.input_tokens ?? inputTokens;
139
313
  }
140
- else if (evt.type === 'content_block_delta' && evt.delta?.type === 'text_delta') {
141
- if (evt.delta.text)
142
- yield { type: 'text-delta', delta: evt.delta.text };
314
+ else if (evt.type === 'content_block_start') {
315
+ if (evt.content_block?.type === 'tool_use') {
316
+ pending.set(index, {
317
+ id: evt.content_block.id ?? `toolu-${genId()}-${index}`,
318
+ name: evt.content_block.name ?? '',
319
+ json: '',
320
+ });
321
+ }
322
+ else if (evt.content_block?.type === 'text' && evt.content_block.text) {
323
+ yield { type: 'text-delta', delta: evt.content_block.text };
324
+ }
325
+ }
326
+ else if (evt.type === 'content_block_delta') {
327
+ if (evt.delta?.type === 'text_delta') {
328
+ if (evt.delta.text)
329
+ yield { type: 'text-delta', delta: evt.delta.text };
330
+ }
331
+ else if (evt.delta?.type === 'input_json_delta') {
332
+ const p = pending.get(index);
333
+ if (p)
334
+ p.json += evt.delta.partial_json ?? '';
335
+ }
336
+ }
337
+ else if (evt.type === 'content_block_stop') {
338
+ const p = pending.get(index);
339
+ if (p) {
340
+ pending.delete(index);
341
+ const call = { id: p.id, name: p.name, input: parseToolInput(p.json, p) };
342
+ toolCalls.push(call);
343
+ yield { type: 'tool-call', call };
344
+ }
143
345
  }
144
346
  else if (evt.type === 'message_delta') {
145
347
  outputTokens = evt.usage?.output_tokens ?? outputTokens;
348
+ stopReason = toStopReason(evt.delta?.stop_reason) ?? stopReason;
146
349
  }
147
350
  }
148
351
  }
@@ -150,9 +353,21 @@ export class AnthropicProvider {
150
353
  finally {
151
354
  reader.releaseLock();
152
355
  }
356
+ // A tool_use block that never got its `content_block_stop` has an
357
+ // incomplete input. Cut off by the token limit it is dropped (the loop sees
358
+ // `stopReason: 'max_tokens'`); any other cause is a broken stream.
359
+ if (pending.size > 0 && stopReason !== 'max_tokens') {
360
+ const names = [...pending.values()].map((p) => p.name).join(', ');
361
+ throw new Error(`[anthropic] stream ended before the tool_use input for ${names} was complete`);
362
+ }
153
363
  const usage = { inputTokens, outputTokens };
154
364
  yield { type: 'usage', usage };
155
- yield { type: 'done', usage };
365
+ yield {
366
+ type: 'done',
367
+ ...(toolCalls.length > 0 ? { toolCalls } : {}),
368
+ ...(stopReason ? { stopReason } : {}),
369
+ usage,
370
+ };
156
371
  }
157
372
  }
158
373
  async function safeText(res) {
@@ -22,8 +22,14 @@ export interface AgentBackendConfig {
22
22
  tools?: IAgentTool[];
23
23
  /**
24
24
  * Tool approval gate (§6) applied to backend *and* frontend (split-loop)
25
- * tools. Omitted = allow everything (back-compat). Pass a
26
- * `DefaultToolAuthorizer` to get the policy-driven review flow.
25
+ * tools. Omitted = allow everything (back-compat).
26
+ *
27
+ * An authorizer that *reviews* (rather than auto-allowing) suspends the run
28
+ * until an approve/deny arrives, and only `createAgentChannelRoutes` carries
29
+ * those: its `POST /commands` relays the full `AgentCommand` set. Mounting a
30
+ * reviewing authorizer behind `createAgentRoutes` alone — whose `/chat` SSE
31
+ * relays only `token`/`usage`/`turn-end` — leaves the caller with a request
32
+ * that never completes, because nothing can deliver the decision.
27
33
  */
28
34
  authorizer?: IToolAuthorizer;
29
35
  }
@@ -1 +1 @@
1
- {"version":3,"file":"create-agent-backend.d.ts","sourceRoot":"","sources":["../src/create-agent-backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,0BAA0B;IAC1B,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB;;;;OAIG;IACH,UAAU,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EAAE,eAAe,CAAC;IACxB,SAAS,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACpC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC5B,0EAA0E;IAC1E,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAgC3E"}
1
+ {"version":3,"file":"create-agent-backend.d.ts","sourceRoot":"","sources":["../src/create-agent-backend.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,gBAAgB,EAChB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAqB,KAAK,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACtD,OAAO,EAAkB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAE1D,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,MAAM,EAAE,iBAAiB,CAAC;IAC1B,gDAAgD;IAChD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,SAAS,CAAC,EAAE,uBAAuB,CAAC;IACpC,qEAAqE;IACrE,SAAS,CAAC,EAAE,cAAc,EAAE,CAAC;IAC7B,0BAA0B;IAC1B,KAAK,CAAC,EAAE,UAAU,EAAE,CAAC;IACrB;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,eAAe,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,YAAY,CAAC;IACtB,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EAAE,eAAe,CAAC;IACxB,SAAS,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACpC,KAAK,EAAE,QAAQ,CAAC,UAAU,CAAC,CAAC;IAC5B,0EAA0E;IAC1E,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,YAAY,CAgC3E"}
@@ -5,21 +5,70 @@
5
5
  *
6
6
  * This is the backend analogue of the frontend's `StoreFsMemory` — same
7
7
  * `IAgentMemory` contract, different substrate (node `fs` vs the virtual store).
8
+ *
9
+ * Durability model (issue #53):
10
+ * - **Atomic writes.** Every write goes to a unique `<file>.<pid>.<n>.tmp` in
11
+ * the *same* directory, is flushed with `fsync`, and is then `rename`d over
12
+ * the target. `rename` within a directory is atomic, so a reader (or a crash)
13
+ * never observes a half-written conversation — it sees the previous complete
14
+ * file or the new complete file. The temp file is removed if anything fails.
15
+ * - **Per-conversation serialisation.** Every read-modify-write method runs
16
+ * through a promise chain keyed by `(agentId, sessionId)`, so concurrent
17
+ * `append()`s apply in call order instead of racing and dropping messages.
18
+ * The chain entry is dropped once it drains, so the map cannot grow forever.
19
+ *
20
+ * The on-disk format is unchanged from 0.9.3: a single pretty-printed JSON
21
+ * `Conversation` document per file. That keeps every existing reader (and the
22
+ * fixtures) working; the cost is O(n) bytes rewritten per append, which is
23
+ * accepted for now — correctness first, an append-only log can come later
24
+ * behind the same class.
8
25
  */
9
- import type { AgentMessage, Conversation, ConversationSummary, ConversationUsage } from '@hamak/ai-agent';
26
+ import type { AgentMessage, CompactionCheckpoint, Conversation, ConversationSummary, ConversationUsage, InterAgentMessage, PendingApproval } from '@hamak/ai-agent';
10
27
  import type { IAgentMemory } from '@hamak/ai-agent/spi';
11
28
  export declare class FileAgentMemory implements IAgentMemory {
12
29
  private readonly root;
30
+ /**
31
+ * Tail of the in-flight operation chain per `(agentId, sessionId)`. Never
32
+ * rejects (failures are swallowed on the chain copy so one failed write does
33
+ * not poison the queue); the caller still gets the real rejection.
34
+ */
35
+ private readonly chains;
13
36
  constructor(root: string);
14
37
  private dir;
15
38
  private file;
39
+ private key;
40
+ /**
41
+ * Run `task` after every previously queued task for this conversation. Errors
42
+ * propagate to the caller but do not break the chain for later tasks.
43
+ */
44
+ private serialize;
16
45
  get(agentId: string, sessionId: string): Promise<Conversation | null>;
17
46
  load(agentId: string, sessionId: string): Promise<AgentMessage[]>;
47
+ /**
48
+ * Write `data` to `file` atomically: temp file in the same directory, fsync,
49
+ * rename. The temp file is cleaned up on every failure path.
50
+ */
51
+ private writeFileAtomic;
18
52
  private write;
53
+ /**
54
+ * Read-modify-write helper: loads the conversation, hands it to `mutate`, and
55
+ * writes back whatever `mutate` returns (returning `null` writes nothing).
56
+ * Always runs inside the per-conversation chain.
57
+ */
58
+ private mutate;
19
59
  append(agentId: string, sessionId: string, message: AgentMessage): Promise<void>;
20
60
  update(agentId: string, sessionId: string, messageId: string, changes: Partial<Pick<AgentMessage, 'content' | 'toolCalls'>>): Promise<void>;
21
61
  patch(agentId: string, sessionId: string, changes: Partial<Pick<Conversation, 'title' | 'pinned' | 'systemPrompt'>>): Promise<void>;
62
+ /** Replace the pending-approval list (ticket 009 §4). */
63
+ setPendingApprovals(agentId: string, sessionId: string, pending: PendingApproval[]): Promise<void>;
64
+ /** Record an inter-agent communication edge (ticket 009 §5.3). */
65
+ appendInterAgentMessage(agentId: string, sessionId: string, message: InterAgentMessage): Promise<void>;
66
+ /** Replace a conversation wholesale with a domain record (ticket 009 §8). */
67
+ hydrate(agentId: string, sessionId: string, conversation: Conversation): Promise<void>;
68
+ /** Replace the cumulative token usage (absolute, so replays stay idempotent). */
22
69
  setUsage(agentId: string, sessionId: string, usage: ConversationUsage): Promise<void>;
70
+ /** Replace the context-compaction checkpoint (absolute, not a delta). */
71
+ setCompaction(agentId: string, sessionId: string, compaction: CompactionCheckpoint): Promise<void>;
23
72
  reset(agentId: string, sessionId: string): Promise<void>;
24
73
  list(agentId: string): Promise<ConversationSummary[]>;
25
74
  }
@@ -1 +1 @@
1
- {"version":3,"file":"file-agent-memory.d.ts","sourceRoot":"","sources":["../src/file-agent-memory.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAE1G,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAoBxD,qBAAa,eAAgB,YAAW,YAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,MAAM;IAEzC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,IAAI;IAIN,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAUrE,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;YAIzD,KAAK;IAKb,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBhF,MAAM,CACV,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,WAAW,CAAC,CAAC,GAC5D,OAAO,CAAC,IAAI,CAAC;IAUV,KAAK,CACT,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,GAAG,QAAQ,GAAG,cAAc,CAAC,CAAC,GACxE,OAAO,CAAC,IAAI,CAAC;IAMV,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAMrF,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxD,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;CAuB5D"}
1
+ {"version":3,"file":"file-agent-memory.d.ts","sourceRoot":"","sources":["../src/file-agent-memory.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACpB,YAAY,EACZ,mBAAmB,EACnB,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,EAChB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAuBxD,qBAAa,eAAgB,YAAW,YAAY;IAQtC,OAAO,CAAC,QAAQ,CAAC,IAAI;IAPjC;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;gBAE9B,IAAI,EAAE,MAAM;IAEzC,OAAO,CAAC,GAAG;IAIX,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,GAAG;IAIX;;;OAGG;IACH,OAAO,CAAC,SAAS;IAkBX,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAUrE,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAIvE;;;OAGG;YACW,eAAe;YA8Bf,KAAK;IAKnB;;;;OAIG;IACH,OAAO,CAAC,MAAM;IAaR,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAsBhF,MAAM,CACV,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,WAAW,CAAC,CAAC,GAC5D,OAAO,CAAC,IAAI,CAAC;IAWV,KAAK,CACT,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,OAAO,GAAG,QAAQ,GAAG,cAAc,CAAC,CAAC,GACxE,OAAO,CAAC,IAAI,CAAC;IAMhB,yDAAyD;IACnD,mBAAmB,CACvB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,eAAe,EAAE,GACzB,OAAO,CAAC,IAAI,CAAC;IAShB,kEAAkE;IAC5D,uBAAuB,CAC3B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,iBAAiB,GACzB,OAAO,CAAC,IAAI,CAAC;IAShB,6EAA6E;IACvE,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC;IAI5F,iFAAiF;IAC3E,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAM3F,yEAAyE;IACnE,aAAa,CACjB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,oBAAoB,GAC/B,OAAO,CAAC,IAAI,CAAC;IAMV,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMxD,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;CAuB5D"}
@@ -5,6 +5,23 @@
5
5
  *
6
6
  * This is the backend analogue of the frontend's `StoreFsMemory` — same
7
7
  * `IAgentMemory` contract, different substrate (node `fs` vs the virtual store).
8
+ *
9
+ * Durability model (issue #53):
10
+ * - **Atomic writes.** Every write goes to a unique `<file>.<pid>.<n>.tmp` in
11
+ * the *same* directory, is flushed with `fsync`, and is then `rename`d over
12
+ * the target. `rename` within a directory is atomic, so a reader (or a crash)
13
+ * never observes a half-written conversation — it sees the previous complete
14
+ * file or the new complete file. The temp file is removed if anything fails.
15
+ * - **Per-conversation serialisation.** Every read-modify-write method runs
16
+ * through a promise chain keyed by `(agentId, sessionId)`, so concurrent
17
+ * `append()`s apply in call order instead of racing and dropping messages.
18
+ * The chain entry is dropped once it drains, so the map cannot grow forever.
19
+ *
20
+ * The on-disk format is unchanged from 0.9.3: a single pretty-printed JSON
21
+ * `Conversation` document per file. That keeps every existing reader (and the
22
+ * fixtures) working; the cost is O(n) bytes rewritten per append, which is
23
+ * accepted for now — correctness first, an append-only log can come later
24
+ * behind the same class.
8
25
  */
9
26
  import { promises as fs } from 'node:fs';
10
27
  import path from 'node:path';
@@ -25,9 +42,17 @@ function safeSegment(seg) {
25
42
  }
26
43
  return seg;
27
44
  }
45
+ /** Process-wide temp-file counter, so two instances never collide on a name. */
46
+ let tmpCounter = 0;
28
47
  export class FileAgentMemory {
29
48
  constructor(root) {
30
49
  this.root = root;
50
+ /**
51
+ * Tail of the in-flight operation chain per `(agentId, sessionId)`. Never
52
+ * rejects (failures are swallowed on the chain copy so one failed write does
53
+ * not poison the queue); the caller still gets the real rejection.
54
+ */
55
+ this.chains = new Map();
31
56
  }
32
57
  dir(agentId) {
33
58
  return path.join(this.root, safeSegment(agentId));
@@ -35,6 +60,28 @@ export class FileAgentMemory {
35
60
  file(agentId, sessionId) {
36
61
  return path.join(this.dir(agentId), `${safeSegment(sessionId)}.json`);
37
62
  }
63
+ key(agentId, sessionId) {
64
+ return `${agentId}\u0000${sessionId}`;
65
+ }
66
+ /**
67
+ * Run `task` after every previously queued task for this conversation. Errors
68
+ * propagate to the caller but do not break the chain for later tasks.
69
+ */
70
+ serialize(agentId, sessionId, task) {
71
+ // Validate eagerly so traversal attempts reject before touching the queue.
72
+ this.file(agentId, sessionId);
73
+ const key = this.key(agentId, sessionId);
74
+ const prev = this.chains.get(key) ?? Promise.resolve();
75
+ const run = prev.then(task);
76
+ const settled = run.then(() => undefined, () => undefined);
77
+ this.chains.set(key, settled);
78
+ void settled.then(() => {
79
+ // Only the last task in the chain clears the entry.
80
+ if (this.chains.get(key) === settled)
81
+ this.chains.delete(key);
82
+ });
83
+ return run;
84
+ }
38
85
  async get(agentId, sessionId) {
39
86
  try {
40
87
  const raw = await fs.readFile(this.file(agentId, sessionId), 'utf8');
@@ -49,56 +96,133 @@ export class FileAgentMemory {
49
96
  async load(agentId, sessionId) {
50
97
  return (await this.get(agentId, sessionId))?.messages ?? [];
51
98
  }
99
+ /**
100
+ * Write `data` to `file` atomically: temp file in the same directory, fsync,
101
+ * rename. The temp file is cleaned up on every failure path.
102
+ */
103
+ async writeFileAtomic(file, data) {
104
+ tmpCounter += 1;
105
+ const tmp = `${file}.${process.pid}.${tmpCounter}.tmp`;
106
+ try {
107
+ const handle = await fs.open(tmp, 'w');
108
+ try {
109
+ await handle.writeFile(data, 'utf8');
110
+ await handle.sync();
111
+ }
112
+ finally {
113
+ await handle.close();
114
+ }
115
+ await fs.rename(tmp, file);
116
+ }
117
+ catch (err) {
118
+ await fs.rm(tmp, { force: true }).catch(() => undefined);
119
+ throw err;
120
+ }
121
+ // Best-effort: flush the directory entry so the rename itself is durable.
122
+ // Not supported everywhere (Windows), hence the swallow.
123
+ try {
124
+ const dirHandle = await fs.open(path.dirname(file), 'r');
125
+ try {
126
+ await dirHandle.sync();
127
+ }
128
+ finally {
129
+ await dirHandle.close();
130
+ }
131
+ }
132
+ catch {
133
+ /* ignore */
134
+ }
135
+ }
52
136
  async write(agentId, sessionId, conv) {
53
137
  await fs.mkdir(this.dir(agentId), { recursive: true });
54
- await fs.writeFile(this.file(agentId, sessionId), JSON.stringify(conv, null, 2), 'utf8');
138
+ await this.writeFileAtomic(this.file(agentId, sessionId), JSON.stringify(conv, null, 2));
139
+ }
140
+ /**
141
+ * Read-modify-write helper: loads the conversation, hands it to `mutate`, and
142
+ * writes back whatever `mutate` returns (returning `null` writes nothing).
143
+ * Always runs inside the per-conversation chain.
144
+ */
145
+ mutate(agentId, sessionId, mutate) {
146
+ return this.serialize(agentId, sessionId, async () => {
147
+ const existing = await this.get(agentId, sessionId);
148
+ const next = mutate(existing);
149
+ if (!next)
150
+ return;
151
+ await this.write(agentId, sessionId, next);
152
+ });
55
153
  }
56
154
  async append(agentId, sessionId, message) {
57
- const now = nowIso();
58
- const existing = await this.get(agentId, sessionId);
59
- if (existing) {
60
- existing.messages.push(message);
61
- existing.updatedAt = now;
62
- if (existing.title === '' && message.role === 'user') {
63
- existing.title = deriveTitle(message.content);
155
+ return this.mutate(agentId, sessionId, (existing) => {
156
+ const now = nowIso();
157
+ if (existing) {
158
+ existing.messages.push(message);
159
+ existing.updatedAt = now;
160
+ if (existing.title === '' && message.role === 'user') {
161
+ existing.title = deriveTitle(message.content);
162
+ }
163
+ return existing;
64
164
  }
65
- await this.write(agentId, sessionId, existing);
66
- return;
67
- }
68
- await this.write(agentId, sessionId, {
69
- schemaVersion: CONVERSATION_SCHEMA_VERSION,
70
- id: sessionId,
71
- title: message.role === 'user' ? deriveTitle(message.content) : '',
72
- messages: [message],
73
- createdAt: now,
74
- updatedAt: now,
165
+ return {
166
+ schemaVersion: CONVERSATION_SCHEMA_VERSION,
167
+ id: sessionId,
168
+ title: message.role === 'user' ? deriveTitle(message.content) : '',
169
+ messages: [message],
170
+ createdAt: now,
171
+ updatedAt: now,
172
+ };
75
173
  });
76
174
  }
77
175
  async update(agentId, sessionId, messageId, changes) {
78
- const existing = await this.get(agentId, sessionId);
79
- if (!existing)
80
- return;
81
- const idx = existing.messages.findIndex((m) => m.id === messageId);
82
- if (idx === -1)
83
- return;
84
- existing.messages[idx] = { ...existing.messages[idx], ...changes };
85
- existing.updatedAt = nowIso();
86
- await this.write(agentId, sessionId, existing);
176
+ return this.mutate(agentId, sessionId, (existing) => {
177
+ if (!existing)
178
+ return null;
179
+ const idx = existing.messages.findIndex((m) => m.id === messageId);
180
+ if (idx === -1)
181
+ return null;
182
+ existing.messages[idx] = { ...existing.messages[idx], ...changes };
183
+ existing.updatedAt = nowIso();
184
+ return existing;
185
+ });
87
186
  }
88
187
  async patch(agentId, sessionId, changes) {
89
- const existing = await this.get(agentId, sessionId);
90
- if (!existing)
91
- return;
92
- await this.write(agentId, sessionId, { ...existing, ...changes, updatedAt: nowIso() });
188
+ return this.mutate(agentId, sessionId, (existing) => existing ? { ...existing, ...changes, updatedAt: nowIso() } : null);
189
+ }
190
+ /** Replace the pending-approval list (ticket 009 §4). */
191
+ async setPendingApprovals(agentId, sessionId, pending) {
192
+ return this.mutate(agentId, sessionId, (existing) => {
193
+ if (!existing)
194
+ return null;
195
+ existing.pendingApprovals = pending;
196
+ existing.updatedAt = nowIso();
197
+ return existing;
198
+ });
199
+ }
200
+ /** Record an inter-agent communication edge (ticket 009 §5.3). */
201
+ async appendInterAgentMessage(agentId, sessionId, message) {
202
+ return this.mutate(agentId, sessionId, (existing) => {
203
+ if (!existing)
204
+ return null;
205
+ existing.interAgentMessages = [...(existing.interAgentMessages ?? []), message];
206
+ existing.updatedAt = nowIso();
207
+ return existing;
208
+ });
209
+ }
210
+ /** Replace a conversation wholesale with a domain record (ticket 009 §8). */
211
+ async hydrate(agentId, sessionId, conversation) {
212
+ return this.mutate(agentId, sessionId, () => ({ ...conversation }));
93
213
  }
214
+ /** Replace the cumulative token usage (absolute, so replays stay idempotent). */
94
215
  async setUsage(agentId, sessionId, usage) {
95
- const existing = await this.get(agentId, sessionId);
96
- if (!existing)
97
- return;
98
- await this.write(agentId, sessionId, { ...existing, usage, updatedAt: nowIso() });
216
+ return this.mutate(agentId, sessionId, (existing) => existing ? { ...existing, usage, updatedAt: nowIso() } : null);
217
+ }
218
+ /** Replace the context-compaction checkpoint (absolute, not a delta). */
219
+ async setCompaction(agentId, sessionId, compaction) {
220
+ return this.mutate(agentId, sessionId, (existing) => existing ? { ...existing, compaction, updatedAt: nowIso() } : null);
99
221
  }
100
222
  async reset(agentId, sessionId) {
101
- await fs.rm(this.file(agentId, sessionId), { force: true });
223
+ return this.serialize(agentId, sessionId, async () => {
224
+ await fs.rm(this.file(agentId, sessionId), { force: true });
225
+ });
102
226
  }
103
227
  async list(agentId) {
104
228
  let names;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hamak/ai-agent-backend",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Backend for @hamak/ai-agent: hosted-model providers (Anthropic), durable memory, and an Express agent-chat route",
@@ -21,8 +21,10 @@
21
21
  "scripts": {
22
22
  "build": "tsc && node ../../../../scripts/fix-esm-extensions.mjs ./dist",
23
23
  "clean": "rm -rf dist",
24
- "test": "vitest run",
25
- "test:watch": "vitest"
24
+ "typecheck": "tsc -p tsconfig.typecheck.json",
25
+ "test": "npm run typecheck && vitest run",
26
+ "test:watch": "vitest",
27
+ "prepublishOnly": "node ../../../../scripts/check-publish-integrity.mjs"
26
28
  },
27
29
  "exports": {
28
30
  ".": {