@aitne/daemon 0.1.4 → 0.1.7

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.
Files changed (272) hide show
  1. package/dist/adapters/notification-manager.d.ts +12 -0
  2. package/dist/adapters/notification-manager.d.ts.map +1 -1
  3. package/dist/adapters/notification-manager.js +39 -1
  4. package/dist/adapters/notification-manager.js.map +1 -1
  5. package/dist/api/routes/agent.d.ts.map +1 -1
  6. package/dist/api/routes/agent.js +7 -0
  7. package/dist/api/routes/agent.js.map +1 -1
  8. package/dist/api/routes/commands.d.ts.map +1 -1
  9. package/dist/api/routes/commands.js +16 -13
  10. package/dist/api/routes/commands.js.map +1 -1
  11. package/dist/api/routes/context.d.ts.map +1 -1
  12. package/dist/api/routes/context.js +13 -2
  13. package/dist/api/routes/context.js.map +1 -1
  14. package/dist/api/routes/dashboard.d.ts.map +1 -1
  15. package/dist/api/routes/dashboard.js +28 -0
  16. package/dist/api/routes/dashboard.js.map +1 -1
  17. package/dist/api/routes/fs.d.ts +23 -0
  18. package/dist/api/routes/fs.d.ts.map +1 -0
  19. package/dist/api/routes/fs.js +156 -0
  20. package/dist/api/routes/fs.js.map +1 -0
  21. package/dist/api/routes/fs.logic.d.ts +62 -0
  22. package/dist/api/routes/fs.logic.d.ts.map +1 -0
  23. package/dist/api/routes/fs.logic.js +137 -0
  24. package/dist/api/routes/fs.logic.js.map +1 -0
  25. package/dist/api/routes/health.d.ts.map +1 -1
  26. package/dist/api/routes/health.js +4 -2
  27. package/dist/api/routes/health.js.map +1 -1
  28. package/dist/api/routes/integrations.d.ts.map +1 -1
  29. package/dist/api/routes/integrations.js +8 -6
  30. package/dist/api/routes/integrations.js.map +1 -1
  31. package/dist/api/routes/metrics.d.ts +1 -0
  32. package/dist/api/routes/metrics.d.ts.map +1 -1
  33. package/dist/api/routes/metrics.js +24 -0
  34. package/dist/api/routes/metrics.js.map +1 -1
  35. package/dist/api/routes/observations.d.ts.map +1 -1
  36. package/dist/api/routes/observations.js +538 -25
  37. package/dist/api/routes/observations.js.map +1 -1
  38. package/dist/api/routes/skills.d.ts +9 -1
  39. package/dist/api/routes/skills.d.ts.map +1 -1
  40. package/dist/api/routes/skills.js +38 -16
  41. package/dist/api/routes/skills.js.map +1 -1
  42. package/dist/api/routes/wiki.d.ts +4 -0
  43. package/dist/api/routes/wiki.d.ts.map +1 -0
  44. package/dist/api/routes/wiki.js +1075 -0
  45. package/dist/api/routes/wiki.js.map +1 -0
  46. package/dist/api/server.d.ts +13 -0
  47. package/dist/api/server.d.ts.map +1 -1
  48. package/dist/api/server.js +27 -1
  49. package/dist/api/server.js.map +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +26 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/core/agent-core.d.ts +25 -0
  54. package/dist/core/agent-core.d.ts.map +1 -1
  55. package/dist/core/agent-core.js.map +1 -1
  56. package/dist/core/backends/backend-router.d.ts +5 -1
  57. package/dist/core/backends/backend-router.d.ts.map +1 -1
  58. package/dist/core/backends/backend-router.js +10 -1
  59. package/dist/core/backends/backend-router.js.map +1 -1
  60. package/dist/core/backends/claude-code-core.d.ts.map +1 -1
  61. package/dist/core/backends/claude-code-core.js +62 -4
  62. package/dist/core/backends/claude-code-core.js.map +1 -1
  63. package/dist/core/backends/claude-tool-collection.d.ts +1 -1
  64. package/dist/core/backends/claude-tool-collection.d.ts.map +1 -1
  65. package/dist/core/backends/claude-tool-collection.js +327 -65
  66. package/dist/core/backends/claude-tool-collection.js.map +1 -1
  67. package/dist/core/backends/codex-core.d.ts.map +1 -1
  68. package/dist/core/backends/codex-core.js +36 -0
  69. package/dist/core/backends/codex-core.js.map +1 -1
  70. package/dist/core/backends/gemini-cli-core.d.ts +24 -5
  71. package/dist/core/backends/gemini-cli-core.d.ts.map +1 -1
  72. package/dist/core/backends/gemini-cli-core.js +62 -30
  73. package/dist/core/backends/gemini-cli-core.js.map +1 -1
  74. package/dist/core/backends/plan-presets.d.ts +3 -1
  75. package/dist/core/backends/plan-presets.d.ts.map +1 -1
  76. package/dist/core/backends/plan-presets.js +42 -2
  77. package/dist/core/backends/plan-presets.js.map +1 -1
  78. package/dist/core/bang-commands/commands-help.d.ts +5 -0
  79. package/dist/core/bang-commands/commands-help.d.ts.map +1 -0
  80. package/dist/core/bang-commands/commands-help.js +69 -0
  81. package/dist/core/bang-commands/commands-help.js.map +1 -0
  82. package/dist/core/bang-commands/commands-wiki.d.ts +75 -0
  83. package/dist/core/bang-commands/commands-wiki.d.ts.map +1 -0
  84. package/dist/core/bang-commands/commands-wiki.js +574 -0
  85. package/dist/core/bang-commands/commands-wiki.js.map +1 -0
  86. package/dist/core/bang-commands/index.d.ts +4 -2
  87. package/dist/core/bang-commands/index.d.ts.map +1 -1
  88. package/dist/core/bang-commands/index.js +15 -1
  89. package/dist/core/bang-commands/index.js.map +1 -1
  90. package/dist/core/bang-commands/registry.d.ts +47 -4
  91. package/dist/core/bang-commands/registry.d.ts.map +1 -1
  92. package/dist/core/bang-commands/registry.js +85 -15
  93. package/dist/core/bang-commands/registry.js.map +1 -1
  94. package/dist/core/context-builder.d.ts +17 -0
  95. package/dist/core/context-builder.d.ts.map +1 -1
  96. package/dist/core/context-builder.js +64 -6
  97. package/dist/core/context-builder.js.map +1 -1
  98. package/dist/core/daemon-api-cli.d.ts.map +1 -1
  99. package/dist/core/daemon-api-cli.js +50 -2
  100. package/dist/core/daemon-api-cli.js.map +1 -1
  101. package/dist/core/dispatcher-message-handler.d.ts.map +1 -1
  102. package/dist/core/dispatcher-message-handler.js +10 -0
  103. package/dist/core/dispatcher-message-handler.js.map +1 -1
  104. package/dist/core/dispatcher-morning-routine.d.ts.map +1 -1
  105. package/dist/core/dispatcher-morning-routine.js +17 -2
  106. package/dist/core/dispatcher-morning-routine.js.map +1 -1
  107. package/dist/core/dispatcher-result-processor.d.ts +23 -0
  108. package/dist/core/dispatcher-result-processor.d.ts.map +1 -1
  109. package/dist/core/dispatcher-result-processor.js +124 -5
  110. package/dist/core/dispatcher-result-processor.js.map +1 -1
  111. package/dist/core/dispatcher-scheduled-tasks.d.ts.map +1 -1
  112. package/dist/core/dispatcher-scheduled-tasks.js +114 -80
  113. package/dist/core/dispatcher-scheduled-tasks.js.map +1 -1
  114. package/dist/core/dispatcher-types.d.ts +116 -1
  115. package/dist/core/dispatcher-types.d.ts.map +1 -1
  116. package/dist/core/dispatcher-types.js.map +1 -1
  117. package/dist/core/dispatcher.d.ts +36 -0
  118. package/dist/core/dispatcher.d.ts.map +1 -1
  119. package/dist/core/dispatcher.js +94 -1
  120. package/dist/core/dispatcher.js.map +1 -1
  121. package/dist/core/integration-lifecycle.d.ts.map +1 -1
  122. package/dist/core/integration-lifecycle.js +6 -8
  123. package/dist/core/integration-lifecycle.js.map +1 -1
  124. package/dist/core/metrics.d.ts +127 -0
  125. package/dist/core/metrics.d.ts.map +1 -1
  126. package/dist/core/metrics.js +256 -1
  127. package/dist/core/metrics.js.map +1 -1
  128. package/dist/core/prompts.d.ts +2 -1
  129. package/dist/core/prompts.d.ts.map +1 -1
  130. package/dist/core/prompts.js +40 -0
  131. package/dist/core/prompts.js.map +1 -1
  132. package/dist/core/roadmap-validate.js +13 -1
  133. package/dist/core/roadmap-validate.js.map +1 -1
  134. package/dist/core/routine-acquisition-plan.d.ts +51 -0
  135. package/dist/core/routine-acquisition-plan.d.ts.map +1 -1
  136. package/dist/core/routine-acquisition-plan.js +111 -12
  137. package/dist/core/routine-acquisition-plan.js.map +1 -1
  138. package/dist/core/routine-fetch-window-retry.d.ts +109 -0
  139. package/dist/core/routine-fetch-window-retry.d.ts.map +1 -0
  140. package/dist/core/routine-fetch-window-retry.js +210 -0
  141. package/dist/core/routine-fetch-window-retry.js.map +1 -0
  142. package/dist/core/routine-fetch-window-runner.d.ts +258 -32
  143. package/dist/core/routine-fetch-window-runner.d.ts.map +1 -1
  144. package/dist/core/routine-fetch-window-runner.js +1115 -185
  145. package/dist/core/routine-fetch-window-runner.js.map +1 -1
  146. package/dist/core/routine-windows.d.ts +19 -4
  147. package/dist/core/routine-windows.d.ts.map +1 -1
  148. package/dist/core/routine-windows.js +47 -0
  149. package/dist/core/routine-windows.js.map +1 -1
  150. package/dist/core/scheduler.d.ts +50 -2
  151. package/dist/core/scheduler.d.ts.map +1 -1
  152. package/dist/core/scheduler.js +88 -7
  153. package/dist/core/scheduler.js.map +1 -1
  154. package/dist/core/skill-curation/declarations.d.ts.map +1 -1
  155. package/dist/core/skill-curation/declarations.js +11 -12
  156. package/dist/core/skill-curation/declarations.js.map +1 -1
  157. package/dist/core/skill-source-paths.d.ts +14 -0
  158. package/dist/core/skill-source-paths.d.ts.map +1 -0
  159. package/dist/core/skill-source-paths.js +82 -0
  160. package/dist/core/skill-source-paths.js.map +1 -0
  161. package/dist/core/skills-compiler.d.ts +18 -0
  162. package/dist/core/skills-compiler.d.ts.map +1 -1
  163. package/dist/core/skills-compiler.js +65 -18
  164. package/dist/core/skills-compiler.js.map +1 -1
  165. package/dist/core/skills-manifest.d.ts.map +1 -1
  166. package/dist/core/skills-manifest.js +46 -0
  167. package/dist/core/skills-manifest.js.map +1 -1
  168. package/dist/core/system-reset.d.ts +25 -0
  169. package/dist/core/system-reset.d.ts.map +1 -1
  170. package/dist/core/system-reset.js +47 -0
  171. package/dist/core/system-reset.js.map +1 -1
  172. package/dist/core/wiki/approval-queue.d.ts +31 -0
  173. package/dist/core/wiki/approval-queue.d.ts.map +1 -0
  174. package/dist/core/wiki/approval-queue.js +44 -0
  175. package/dist/core/wiki/approval-queue.js.map +1 -0
  176. package/dist/core/wiki/bridge.d.ts +74 -0
  177. package/dist/core/wiki/bridge.d.ts.map +1 -0
  178. package/dist/core/wiki/bridge.js +405 -0
  179. package/dist/core/wiki/bridge.js.map +1 -0
  180. package/dist/core/wiki/compile-lock.d.ts +42 -0
  181. package/dist/core/wiki/compile-lock.d.ts.map +1 -0
  182. package/dist/core/wiki/compile-lock.js +55 -0
  183. package/dist/core/wiki/compile-lock.js.map +1 -0
  184. package/dist/core/wiki/compile-preview.d.ts +8 -0
  185. package/dist/core/wiki/compile-preview.d.ts.map +1 -0
  186. package/dist/core/wiki/compile-preview.js +200 -0
  187. package/dist/core/wiki/compile-preview.js.map +1 -0
  188. package/dist/core/wiki/cost-estimate.d.ts +30 -0
  189. package/dist/core/wiki/cost-estimate.d.ts.map +1 -0
  190. package/dist/core/wiki/cost-estimate.js +243 -0
  191. package/dist/core/wiki/cost-estimate.js.map +1 -0
  192. package/dist/core/wiki/dispatcher.d.ts +48 -0
  193. package/dist/core/wiki/dispatcher.d.ts.map +1 -0
  194. package/dist/core/wiki/dispatcher.js +92 -0
  195. package/dist/core/wiki/dispatcher.js.map +1 -0
  196. package/dist/core/wiki/git-precompile.d.ts +86 -0
  197. package/dist/core/wiki/git-precompile.d.ts.map +1 -0
  198. package/dist/core/wiki/git-precompile.js +96 -0
  199. package/dist/core/wiki/git-precompile.js.map +1 -0
  200. package/dist/core/wiki/import-migrate.d.ts +38 -0
  201. package/dist/core/wiki/import-migrate.d.ts.map +1 -0
  202. package/dist/core/wiki/import-migrate.js +310 -0
  203. package/dist/core/wiki/import-migrate.js.map +1 -0
  204. package/dist/core/wiki/import-probe.d.ts +76 -0
  205. package/dist/core/wiki/import-probe.d.ts.map +1 -0
  206. package/dist/core/wiki/import-probe.js +245 -0
  207. package/dist/core/wiki/import-probe.js.map +1 -0
  208. package/dist/core/wiki/index-cache.d.ts +39 -0
  209. package/dist/core/wiki/index-cache.d.ts.map +1 -0
  210. package/dist/core/wiki/index-cache.js +152 -0
  211. package/dist/core/wiki/index-cache.js.map +1 -0
  212. package/dist/core/wiki/multi-url-dispatch.d.ts +52 -0
  213. package/dist/core/wiki/multi-url-dispatch.d.ts.map +1 -0
  214. package/dist/core/wiki/multi-url-dispatch.js +72 -0
  215. package/dist/core/wiki/multi-url-dispatch.js.map +1 -0
  216. package/dist/core/wiki/wiki-fts.d.ts +75 -0
  217. package/dist/core/wiki/wiki-fts.d.ts.map +1 -0
  218. package/dist/core/wiki/wiki-fts.js +265 -0
  219. package/dist/core/wiki/wiki-fts.js.map +1 -0
  220. package/dist/core/wiki/workspaces.d.ts +101 -0
  221. package/dist/core/wiki/workspaces.d.ts.map +1 -0
  222. package/dist/core/wiki/workspaces.js +352 -0
  223. package/dist/core/wiki/workspaces.js.map +1 -0
  224. package/dist/core/wiki/write-strategy.d.ts +70 -0
  225. package/dist/core/wiki/write-strategy.d.ts.map +1 -0
  226. package/dist/core/wiki/write-strategy.js +112 -0
  227. package/dist/core/wiki/write-strategy.js.map +1 -0
  228. package/dist/core/workdir.d.ts +8 -1
  229. package/dist/core/workdir.d.ts.map +1 -1
  230. package/dist/core/workdir.js +4 -1
  231. package/dist/core/workdir.js.map +1 -1
  232. package/dist/db/schema.d.ts.map +1 -1
  233. package/dist/db/schema.js +122 -0
  234. package/dist/db/schema.js.map +1 -1
  235. package/dist/db/wiki-store.d.ts +3 -0
  236. package/dist/db/wiki-store.d.ts.map +1 -0
  237. package/dist/db/wiki-store.js +7 -0
  238. package/dist/db/wiki-store.js.map +1 -0
  239. package/dist/index.js +87 -4
  240. package/dist/index.js.map +1 -1
  241. package/dist/messaging/setup-welcome-dm.d.ts +30 -0
  242. package/dist/messaging/setup-welcome-dm.d.ts.map +1 -0
  243. package/dist/messaging/setup-welcome-dm.js +86 -0
  244. package/dist/messaging/setup-welcome-dm.js.map +1 -0
  245. package/dist/messaging/url-extract.d.ts +8 -0
  246. package/dist/messaging/url-extract.d.ts.map +1 -0
  247. package/dist/messaging/url-extract.js +41 -0
  248. package/dist/messaging/url-extract.js.map +1 -0
  249. package/dist/observers/delegated-sync-worker.d.ts +33 -25
  250. package/dist/observers/delegated-sync-worker.d.ts.map +1 -1
  251. package/dist/observers/delegated-sync-worker.js +38 -31
  252. package/dist/observers/delegated-sync-worker.js.map +1 -1
  253. package/dist/observers/imminent-event-scheduler.d.ts +20 -7
  254. package/dist/observers/imminent-event-scheduler.d.ts.map +1 -1
  255. package/dist/observers/imminent-event-scheduler.js +134 -29
  256. package/dist/observers/imminent-event-scheduler.js.map +1 -1
  257. package/dist/safety/always-disallowed.d.ts +65 -0
  258. package/dist/safety/always-disallowed.d.ts.map +1 -1
  259. package/dist/safety/always-disallowed.js +106 -10
  260. package/dist/safety/always-disallowed.js.map +1 -1
  261. package/dist/safety/audit.d.ts +46 -1
  262. package/dist/safety/audit.d.ts.map +1 -1
  263. package/dist/safety/audit.js +79 -16
  264. package/dist/safety/audit.js.map +1 -1
  265. package/dist/safety/risk-classifier.d.ts.map +1 -1
  266. package/dist/safety/risk-classifier.js +29 -0
  267. package/dist/safety/risk-classifier.js.map +1 -1
  268. package/dist/settings/runtime-settings.d.ts +12 -1
  269. package/dist/settings/runtime-settings.d.ts.map +1 -1
  270. package/dist/settings/runtime-settings.js +59 -1
  271. package/dist/settings/runtime-settings.js.map +1 -1
  272. package/package.json +2 -2
@@ -3,7 +3,7 @@ import { INTEGRATION_KEYS, isIntegrationKey, } from "@aitne/shared";
3
3
  import { consumeObservations, getNoveltyDistribution, getObservationStats, getPendingObservations, getSummaryStatusCounts, recordObservation, } from "../../db/observations.js";
4
4
  import { readIntegrationFlipLock } from "../../core/integration-lifecycle.js";
5
5
  import { createLogger } from "../../logging.js";
6
- import { readJsonBody } from "../json-body.js";
6
+ import { DEFAULT_JSON_BODY_MAX_BYTES, readJsonBody } from "../json-body.js";
7
7
  const logger = createLogger("observations-api");
8
8
  function parseBoolean(value, defaultValue) {
9
9
  if (value === undefined)
@@ -222,32 +222,151 @@ export function createObservationRoutes(deps) {
222
222
  * old mode label. Sources outside the integration registry (Obsidian,
223
223
  * Git, messaging adapter) are never rejected.
224
224
  */
225
+ const RECORD_EXPECTED_SHAPE = '{"source": string, "ref": string, "changeType"?: "created"|"modified"|"deleted", "actor"?: "agent"|"system", "payload"?: unknown}';
226
+ const RECORD_EXAMPLE = '{"source":"roadmap_candidate:travel","ref":"trip-portland-2026-summer","changeType":"created","actor":"agent","payload":{"note":"DM mentioned Portland trip"}}';
225
227
  app.post("/observations", async (c) => {
226
- const parsedBody = await readJsonBody(c);
227
- if (!parsedBody.ok)
228
- return parsedBody.response;
229
- const body = parsedBody.body;
230
- if (!body ||
231
- typeof body.source !== "string" ||
232
- typeof body.ref !== "string" ||
233
- (typeof body.changeType !== "string" &&
234
- body.changeType !== undefined)) {
235
- return c.json({ error: "validation_error" }, 400);
228
+ // Peek at the raw body BEFORE delegating to `readJsonBody` so we can
229
+ // turn a query-string-shaped body ("limit=30", "actor=user&limit=20")
230
+ // into a method-confusion hint. Production telemetry showed the
231
+ // hourly_check agent sending `POST /api/observations` with body
232
+ // `limit=30`, expecting it to fetch. Forwarding readJsonBody's
233
+ // generic "Unexpected token 'l'" message gave the agent no signal
234
+ // that the right call was `GET /api/observations?limit=30`.
235
+ //
236
+ // Size cap mirrors `readJsonBody`'s defense-in-depth: declared
237
+ // Content-Length AND post-read byteLength are both checked against
238
+ // `DEFAULT_JSON_BODY_MAX_BYTES` (1 MiB). Without this an inline
239
+ // reader would happily buffer a malicious 10 MB body and pin RAM.
240
+ const declared = c.req.header("content-length");
241
+ if (declared !== undefined) {
242
+ const declaredN = Number.parseInt(declared, 10);
243
+ if (Number.isFinite(declaredN) && declaredN > DEFAULT_JSON_BODY_MAX_BYTES) {
244
+ return c.json({
245
+ error: "body_too_large",
246
+ maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
247
+ actualBytes: declaredN,
248
+ }, 413);
249
+ }
250
+ }
251
+ let raw;
252
+ try {
253
+ raw = await c.req.text();
254
+ }
255
+ catch (err) {
256
+ const detail = err instanceof Error ? err.message : String(err);
257
+ return c.json({ error: "invalid_json_body", message: detail }, 400);
258
+ }
259
+ const actualBytes = Buffer.byteLength(raw, "utf-8");
260
+ if (actualBytes > DEFAULT_JSON_BODY_MAX_BYTES) {
261
+ return c.json({
262
+ error: "body_too_large",
263
+ maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
264
+ actualBytes,
265
+ }, 413);
266
+ }
267
+ const trimmed = raw.trim();
268
+ if (trimmed.length > 0 && trimmed[0] !== "{" && trimmed[0] !== "[") {
269
+ // A bare `key=value(&key=value)+` body shape is unambiguous:
270
+ // there is no JSON document that starts with a bare identifier
271
+ // followed by `=`. Suggest the GET form verbatim so the agent
272
+ // can copy-paste it.
273
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(trimmed)) {
274
+ return c.json({
275
+ error: "method_confusion",
276
+ message: "POST /api/observations records a new observation (JSON body). Your body looks like a query string — did you mean to GET?",
277
+ hint: `Use GET /api/observations?${trimmed} to fetch, or POST with a JSON body matching expectedShape to record.`,
278
+ expectedShape: RECORD_EXPECTED_SHAPE,
279
+ example: RECORD_EXAMPLE,
280
+ }, 400);
281
+ }
282
+ return c.json({
283
+ error: "invalid_json_body",
284
+ message: `Body must be a JSON object starting with '{' — received '${trimmed.slice(0, 32)}…'`,
285
+ expectedShape: RECORD_EXPECTED_SHAPE,
286
+ example: RECORD_EXAMPLE,
287
+ }, 400);
288
+ }
289
+ let body;
290
+ try {
291
+ body = trimmed.length === 0 ? null : JSON.parse(trimmed);
292
+ }
293
+ catch (err) {
294
+ const detail = err instanceof Error ? err.message : String(err);
295
+ return c.json({
296
+ error: "invalid_json_body",
297
+ message: detail,
298
+ expectedShape: RECORD_EXPECTED_SHAPE,
299
+ example: RECORD_EXAMPLE,
300
+ }, 400);
301
+ }
302
+ const issues = [];
303
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
304
+ return c.json({
305
+ error: "validation_error",
306
+ message: "Body must be a JSON object",
307
+ expectedShape: RECORD_EXPECTED_SHAPE,
308
+ example: RECORD_EXAMPLE,
309
+ }, 400);
310
+ }
311
+ if (typeof body.source !== "string" || body.source.length === 0) {
312
+ issues.push({
313
+ field: "source",
314
+ expected: "non-empty string",
315
+ got: body.source === undefined ? "missing" : typeof body.source,
316
+ hint: "Use a registry-aware prefix like 'roadmap_candidate:<subkind>' or an integration key like 'gmail:<account>'",
317
+ });
318
+ }
319
+ if (typeof body.ref !== "string" || body.ref.length === 0) {
320
+ issues.push({
321
+ field: "ref",
322
+ expected: "non-empty string",
323
+ got: body.ref === undefined ? "missing" : typeof body.ref,
324
+ hint: "A stable identifier within the source — e.g. message id, file path, candidate slug",
325
+ });
326
+ }
327
+ if (typeof body.changeType !== "string" &&
328
+ body.changeType !== undefined) {
329
+ issues.push({
330
+ field: "changeType",
331
+ expected: "'created' | 'modified' | 'deleted' (optional, defaults to 'created')",
332
+ got: typeof body.changeType,
333
+ });
236
334
  }
335
+ if (issues.length > 0) {
336
+ return c.json({
337
+ error: "validation_error",
338
+ message: "Request body failed schema validation",
339
+ expectedShape: RECORD_EXPECTED_SHAPE,
340
+ example: RECORD_EXAMPLE,
341
+ issues,
342
+ }, 400);
343
+ }
344
+ // The `issues` array above guarantees `body.source` and `body.ref`
345
+ // are non-empty strings; cast for the rest of the handler.
346
+ const source = body.source;
347
+ const ref = body.ref;
237
348
  const changeType = typeof body.changeType === "string" ? body.changeType : "created";
238
349
  if (!["created", "modified", "deleted"].includes(changeType)) {
239
- return c.json({ error: "invalid_change_type" }, 400);
350
+ return c.json({
351
+ error: "invalid_change_type",
352
+ message: `'changeType' must be one of 'created', 'modified', 'deleted' — received '${changeType}'`,
353
+ hint: "Omit the field to default to 'created'",
354
+ }, 400);
240
355
  }
241
356
  const actor = typeof body.actor === "string" ? body.actor : "agent";
242
357
  if (!["agent", "system"].includes(actor)) {
243
- return c.json({ error: "invalid_actor" }, 400);
358
+ return c.json({
359
+ error: "invalid_actor",
360
+ message: `'actor' must be 'agent' or 'system' — received '${actor}'`,
361
+ hint: "User-originated observations come in through the vault / mail watchers; this endpoint only accepts agent/system writes",
362
+ }, 400);
244
363
  }
245
364
  // §11.3.1 — defensive flip-lock check. The integration key inferred
246
365
  // from the source is the same key the PATCH route would have locked.
247
366
  // If a flip is mid-flight, return 409 so the agent retries after the
248
367
  // drain completes. Sources that don't map to a registered integration
249
368
  // are pass-through (Obsidian / Git / messaging never lock).
250
- const lockedKey = inferIntegrationKeyFromSource(body.source);
369
+ const lockedKey = inferIntegrationKeyFromSource(source);
251
370
  if (lockedKey) {
252
371
  const lock = readIntegrationFlipLock(db, lockedKey);
253
372
  if (lock) {
@@ -261,15 +380,15 @@ export function createObservationRoutes(deps) {
261
380
  }
262
381
  }
263
382
  const result = recordObservation(db, {
264
- source: body.source,
265
- ref: body.ref,
383
+ source,
384
+ ref,
266
385
  changeType: changeType,
267
386
  actor: actor,
268
387
  payload: body.payload,
269
388
  });
270
389
  logger.info({
271
- source: body.source,
272
- ref: body.ref,
390
+ source,
391
+ ref,
273
392
  actor,
274
393
  contentHash: result.contentHash,
275
394
  action: result.action,
@@ -296,22 +415,416 @@ export function createObservationRoutes(deps) {
296
415
  action: result.action,
297
416
  });
298
417
  });
418
+ /**
419
+ * POST /observations/batch — record many agent-originated observations in a
420
+ * single transaction.
421
+ *
422
+ * Reason this exists: the routine.fetch_window pre-pass on Haiku posts
423
+ * many observations per integration window (~20 mail messages, ~6 calendar
424
+ * events). Calling `POST /observations` once per item collided with two
425
+ * orthogonal safety layers in `claude-tool-collection.ts:bashCurlHook`:
426
+ *
427
+ * 1. The "one curl per Bash invocation" cap blocks `cat | bash`, chained
428
+ * `curl … ; curl …`, and `for` loops containing curl.
429
+ * 2. URL extraction strips heredoc bodies before validation, so a
430
+ * `cat > /tmp/script.sh << 'EOF' … curl http://localhost:8321/… EOF`
431
+ * batching shape blocks with "curl command must contain an explicit
432
+ * localhost URL" — the URL lives in the stdin payload, not argv.
433
+ *
434
+ * Production telemetry on 2026-05-13 morning routine showed Haiku
435
+ * burning four budget cycles and posting zero observations because every
436
+ * batching shape it tried was blocked, leaving `today.md` empty. The
437
+ * single-curl-with-array endpoint resolves the cardinality mismatch
438
+ * without weakening either hook.
439
+ *
440
+ * Body: `{ "observations": [...] }` with up to 200 entries per call.
441
+ * Per-item validation mirrors POST /observations exactly. The whole
442
+ * batch executes inside one `db.transaction()`; any per-item failure is
443
+ * recorded in the response and the rest of the batch proceeds.
444
+ *
445
+ * Response is always 200 (or 400 for a malformed envelope). Per-item
446
+ * outcomes live in `results[*].status` so the agent doesn't retry the
447
+ * whole batch on a partial failure.
448
+ */
449
+ const BATCH_MAX_OBSERVATIONS = 200;
450
+ const BATCH_EXPECTED_SHAPE = '{"observations": [{"source": string, "ref": string, "changeType"?: "created"|"modified"|"deleted", "actor"?: "agent"|"system", "payload"?: unknown}, ...]}';
451
+ const BATCH_EXAMPLE = '{"observations":[{"source":"google_calendar:primary","ref":"evt-1","payload":{"kind":"calendar","providerId":"primary","raw":{"title":"…"}}},{"source":"google_calendar:primary","ref":"evt-2","payload":{"kind":"calendar","providerId":"primary","raw":{"title":"…"}}}]}';
452
+ function validateBatchItem(item, index) {
453
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
454
+ return {
455
+ ok: false,
456
+ result: {
457
+ index,
458
+ status: "validation_error",
459
+ error: "item must be a JSON object",
460
+ hint: BATCH_EXPECTED_SHAPE,
461
+ },
462
+ };
463
+ }
464
+ const obj = item;
465
+ if (typeof obj.source !== "string" || obj.source.length === 0) {
466
+ return {
467
+ ok: false,
468
+ result: {
469
+ index,
470
+ status: "validation_error",
471
+ error: "'source' must be a non-empty string",
472
+ hint: "Use 'gmail:<account>', 'google_calendar:<calendarId>', 'notion:<dbId>', etc.",
473
+ },
474
+ };
475
+ }
476
+ if (typeof obj.ref !== "string" || obj.ref.length === 0) {
477
+ return {
478
+ ok: false,
479
+ result: {
480
+ index,
481
+ status: "validation_error",
482
+ source: obj.source,
483
+ error: "'ref' must be a non-empty string",
484
+ hint: "Stable id within the source — e.g. message id, event id",
485
+ },
486
+ };
487
+ }
488
+ const changeType = typeof obj.changeType === "string" ? obj.changeType : "created";
489
+ if (!["created", "modified", "deleted"].includes(changeType)) {
490
+ return {
491
+ ok: false,
492
+ result: {
493
+ index,
494
+ status: "validation_error",
495
+ source: obj.source,
496
+ ref: obj.ref,
497
+ error: `'changeType' must be 'created'|'modified'|'deleted' — received '${changeType}'`,
498
+ },
499
+ };
500
+ }
501
+ const actor = typeof obj.actor === "string" ? obj.actor : "agent";
502
+ if (!["agent", "system"].includes(actor)) {
503
+ return {
504
+ ok: false,
505
+ result: {
506
+ index,
507
+ status: "validation_error",
508
+ source: obj.source,
509
+ ref: obj.ref,
510
+ error: `'actor' must be 'agent' or 'system' — received '${actor}'`,
511
+ },
512
+ };
513
+ }
514
+ return {
515
+ ok: true,
516
+ source: obj.source,
517
+ ref: obj.ref,
518
+ changeType: changeType,
519
+ actor: actor,
520
+ payload: obj.payload,
521
+ };
522
+ }
523
+ app.post("/observations/batch", async (c) => {
524
+ // Size-cap defense mirrors POST /observations.
525
+ const declared = c.req.header("content-length");
526
+ if (declared !== undefined) {
527
+ const declaredN = Number.parseInt(declared, 10);
528
+ if (Number.isFinite(declaredN) && declaredN > DEFAULT_JSON_BODY_MAX_BYTES) {
529
+ return c.json({
530
+ error: "body_too_large",
531
+ maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
532
+ actualBytes: declaredN,
533
+ }, 413);
534
+ }
535
+ }
536
+ let raw;
537
+ try {
538
+ raw = await c.req.text();
539
+ }
540
+ catch (err) {
541
+ const detail = err instanceof Error ? err.message : String(err);
542
+ return c.json({ error: "invalid_json_body", message: detail }, 400);
543
+ }
544
+ const actualBytes = Buffer.byteLength(raw, "utf-8");
545
+ if (actualBytes > DEFAULT_JSON_BODY_MAX_BYTES) {
546
+ return c.json({
547
+ error: "body_too_large",
548
+ maxBytes: DEFAULT_JSON_BODY_MAX_BYTES,
549
+ actualBytes,
550
+ }, 413);
551
+ }
552
+ let envelope;
553
+ try {
554
+ envelope = raw.trim().length === 0 ? null : JSON.parse(raw);
555
+ }
556
+ catch (err) {
557
+ const detail = err instanceof Error ? err.message : String(err);
558
+ return c.json({
559
+ error: "invalid_json_body",
560
+ message: detail,
561
+ expectedShape: BATCH_EXPECTED_SHAPE,
562
+ example: BATCH_EXAMPLE,
563
+ }, 400);
564
+ }
565
+ if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
566
+ return c.json({
567
+ error: "validation_error",
568
+ message: "Body must be a JSON object with an 'observations' array",
569
+ expectedShape: BATCH_EXPECTED_SHAPE,
570
+ example: BATCH_EXAMPLE,
571
+ }, 400);
572
+ }
573
+ if (!Array.isArray(envelope.observations)) {
574
+ return c.json({
575
+ error: "validation_error",
576
+ message: "'observations' must be an array",
577
+ expectedShape: BATCH_EXPECTED_SHAPE,
578
+ example: BATCH_EXAMPLE,
579
+ hint: "Wrap your observation objects in an 'observations' array — POST {\"observations\":[…]}",
580
+ }, 400);
581
+ }
582
+ if (envelope.observations.length === 0) {
583
+ // Empty batch is a documented no-op so the pre-pass can emit a
584
+ // zero-event window without a 400 stutter.
585
+ return c.json({
586
+ results: [],
587
+ fetched: 0,
588
+ posted: 0,
589
+ duplicates: 0,
590
+ errors: 0,
591
+ });
592
+ }
593
+ if (envelope.observations.length > BATCH_MAX_OBSERVATIONS) {
594
+ return c.json({
595
+ error: "batch_too_large",
596
+ message: `Batch size ${envelope.observations.length} exceeds maximum ${BATCH_MAX_OBSERVATIONS}`,
597
+ maxItems: BATCH_MAX_OBSERVATIONS,
598
+ hint: "Split the batch into chunks of at most 200 items.",
599
+ }, 400);
600
+ }
601
+ const results = [];
602
+ let posted = 0;
603
+ let duplicates = 0;
604
+ let errorCount = 0;
605
+ // Single explicit transaction wraps the whole batch — better-sqlite3
606
+ // transactions amortise the per-statement overhead to ~30-100x for
607
+ // bulk inserts, which is the primary perf win this endpoint offers
608
+ // beyond bypassing the bashCurlHook one-curl cap. The flip-lock check
609
+ // stays per-item so a mixed-integration batch (defensive — the
610
+ // pre-pass scopes each sub-session to one integration, but the
611
+ // endpoint must hold under any caller) does not block on the first
612
+ // unrelated lock.
613
+ const writeBatch = db.transaction((items) => {
614
+ for (let i = 0; i < items.length; i++) {
615
+ const validated = validateBatchItem(items[i], i);
616
+ if (!validated.ok) {
617
+ results.push(validated.result);
618
+ errorCount += 1;
619
+ continue;
620
+ }
621
+ const lockedKey = inferIntegrationKeyFromSource(validated.source);
622
+ if (lockedKey) {
623
+ const lock = readIntegrationFlipLock(db, lockedKey);
624
+ if (lock) {
625
+ logger.warn({ source: validated.source, ref: validated.ref, lockedKey, lock }, "Batch observation write rejected — integration flip lock held");
626
+ results.push({
627
+ index: i,
628
+ status: "flip_locked",
629
+ source: validated.source,
630
+ ref: validated.ref,
631
+ error: `Integration '${lockedKey}' flip in progress`,
632
+ });
633
+ errorCount += 1;
634
+ continue;
635
+ }
636
+ }
637
+ const result = recordObservation(db, {
638
+ source: validated.source,
639
+ ref: validated.ref,
640
+ changeType: validated.changeType,
641
+ actor: validated.actor,
642
+ payload: validated.payload,
643
+ });
644
+ if (result.action === "duplicate") {
645
+ duplicates += 1;
646
+ results.push({
647
+ index: i,
648
+ status: "duplicate",
649
+ source: validated.source,
650
+ ref: validated.ref,
651
+ contentHash: result.contentHash,
652
+ id: result.id,
653
+ });
654
+ }
655
+ else {
656
+ posted += 1;
657
+ results.push({
658
+ index: i,
659
+ status: result.action,
660
+ source: validated.source,
661
+ ref: validated.ref,
662
+ contentHash: result.contentHash,
663
+ id: result.id,
664
+ });
665
+ }
666
+ }
667
+ });
668
+ writeBatch(envelope.observations);
669
+ logger.info({
670
+ count: envelope.observations.length,
671
+ posted,
672
+ duplicates,
673
+ errors: errorCount,
674
+ }, "Observations batch recorded via API");
675
+ return c.json({
676
+ results,
677
+ fetched: envelope.observations.length,
678
+ posted,
679
+ duplicates,
680
+ errors: errorCount,
681
+ });
682
+ });
683
+ /**
684
+ * Field-level validation contract for `POST /observations/consume`.
685
+ *
686
+ * Production telemetry (2026-05) showed a single Stage-3 hourly_check
687
+ * burning $0.58 / 25 turns retrying this endpoint with shape variants
688
+ * (`correlation_id` snake_case, stringified ids, the angle-bracket
689
+ * placeholder copied verbatim, per-id paths, etc.). The legacy
690
+ * `{ error: "validation_error" }` response gave the agent zero signal
691
+ * about which field was wrong, so it would mutate a random field and
692
+ * retry. Returning the full schema + the specific issue + a one-line
693
+ * hint lets the agent self-correct on the next turn instead of the
694
+ * eighth.
695
+ */
696
+ const CONSUME_EXPECTED_SHAPE = '{"ids": number[], "correlationId": string}';
697
+ const CONSUME_EXAMPLE = '{"ids":[14,17],"correlationId":"hourly-2026-04-23T15:00:00Z-7af3"}';
299
698
  app.post("/observations/consume", async (c) => {
300
699
  const parsedBody = await readJsonBody(c);
301
700
  if (!parsedBody.ok)
302
701
  return parsedBody.response;
303
702
  const body = parsedBody.body;
304
- if (!body || !Array.isArray(body.ids) || typeof body.correlationId !== "string") {
305
- return c.json({ error: "validation_error" }, 400);
703
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
704
+ return c.json({
705
+ error: "validation_error",
706
+ message: "Body must be a JSON object",
707
+ expectedShape: CONSUME_EXPECTED_SHAPE,
708
+ example: CONSUME_EXAMPLE,
709
+ }, 400);
710
+ }
711
+ const issues = [];
712
+ if (body.correlation_id !== undefined &&
713
+ body.correlationId === undefined) {
714
+ issues.push({
715
+ field: "correlationId",
716
+ expected: "string (camelCase)",
717
+ got: "received 'correlation_id' (snake_case) instead",
718
+ hint: "Rename the field to camelCase 'correlationId' — value is the verbatim id from the <event_correlation_id> tag in your prompt context",
719
+ });
720
+ }
721
+ else if (typeof body.correlationId !== "string") {
722
+ issues.push({
723
+ field: "correlationId",
724
+ expected: "string",
725
+ got: body.correlationId === undefined
726
+ ? "missing"
727
+ : typeof body.correlationId,
728
+ hint: "Copy verbatim from <event_correlation_id>…</event_correlation_id> in your prompt context",
729
+ });
730
+ }
731
+ else if (body.correlationId.trim().length === 0) {
732
+ issues.push({
733
+ field: "correlationId",
734
+ expected: "non-empty string",
735
+ got: "empty string",
736
+ hint: "Copy verbatim from <event_correlation_id>…</event_correlation_id> in your prompt context",
737
+ });
738
+ }
739
+ else if (body.correlationId.startsWith("<") &&
740
+ body.correlationId.endsWith(">")) {
741
+ issues.push({
742
+ field: "correlationId",
743
+ expected: "verbatim correlation id",
744
+ got: `placeholder text '${body.correlationId}'`,
745
+ hint: "Use the real id from <event_correlation_id>…</event_correlation_id>, not the angle-bracket placeholder",
746
+ });
306
747
  }
307
- const ids = body.ids.filter((id) => typeof id === "number" && Number.isInteger(id));
308
- if (ids.length !== body.ids.length) {
309
- return c.json({ error: "validation_error" }, 400);
748
+ if (!Array.isArray(body.ids)) {
749
+ issues.push({
750
+ field: "ids",
751
+ expected: "number[]",
752
+ got: body.ids === undefined ? "missing" : typeof body.ids,
753
+ hint: "Array of integer observation row ids — e.g. [14, 17]",
754
+ });
310
755
  }
311
- const result = consumeObservations(db, ids, body.correlationId);
312
- logger.info({ consumed: result.consumed, correlationId: body.correlationId }, "Observations consumed");
756
+ else if (body.ids.length > 0) {
757
+ // Empty array is a documented no-op (preserves the legacy
758
+ // contract: `consumeObservations` returns `{consumed:0,notFound:[]}`
759
+ // for an empty list). Only validate element shape when the array
760
+ // actually has content.
761
+ const stringIds = body.ids.filter((id) => typeof id === "string");
762
+ if (stringIds.length > 0) {
763
+ issues.push({
764
+ field: "ids",
765
+ expected: "number[]",
766
+ got: `array contains strings (e.g. ${JSON.stringify(stringIds.slice(0, 3))})`,
767
+ hint: 'Use integers, not strings — [14, 17] not ["14", "17"]',
768
+ });
769
+ }
770
+ else {
771
+ const nonInt = body.ids.find((id) => typeof id !== "number" || !Number.isInteger(id));
772
+ if (nonInt !== undefined) {
773
+ issues.push({
774
+ field: "ids",
775
+ expected: "number[] (integers)",
776
+ got: `array contains non-integer value ${JSON.stringify(nonInt)}`,
777
+ hint: "ids must be integer observation row ids returned by GET /api/observations",
778
+ });
779
+ }
780
+ }
781
+ }
782
+ if (issues.length > 0) {
783
+ return c.json({
784
+ error: "validation_error",
785
+ message: "Request body failed schema validation",
786
+ expectedShape: CONSUME_EXPECTED_SHAPE,
787
+ example: CONSUME_EXAMPLE,
788
+ issues,
789
+ }, 400);
790
+ }
791
+ const ids = body.ids;
792
+ const correlationId = body.correlationId;
793
+ const result = consumeObservations(db, ids, correlationId);
794
+ logger.info({ consumed: result.consumed, correlationId }, "Observations consumed");
313
795
  return c.json(result);
314
796
  });
797
+ /**
798
+ * Helpful 405 for the per-id consume shape the agent has reached for in
799
+ * production (`POST /api/observations/:id/consume`). Without an explicit
800
+ * handler this path falls through to Hono's 404 with body
801
+ * `"404 Not Found"`, which gives the agent nothing to act on. Returning
802
+ * a 405 with the canonical bulk-endpoint hint pulls the agent back onto
803
+ * the correct shape on the next turn.
804
+ */
805
+ app.all("/observations/:id/consume", (c) => {
806
+ const id = c.req.param("id");
807
+ return c.json({
808
+ error: "use_bulk_endpoint",
809
+ message: "Per-id consume is not supported. Use the bulk endpoint with a single-element ids array.",
810
+ expectedShape: CONSUME_EXPECTED_SHAPE,
811
+ example: CONSUME_EXAMPLE,
812
+ hint: `POST /api/observations/consume with body {"ids":[${id}],"correlationId":"<copy from <event_correlation_id>>"}`,
813
+ }, 405);
814
+ });
815
+ /**
816
+ * Helpful 405 for `GET /api/observations/consume`. The bulk consume is
817
+ * POST-only — without this handler the request 404s with no actionable
818
+ * detail, and the agent's recovery loop produced 8x retries in one
819
+ * routine.hourly_check session.
820
+ */
821
+ app.get("/observations/consume", (c) => c.json({
822
+ error: "method_not_allowed",
823
+ message: "GET is not supported on /api/observations/consume — use POST.",
824
+ expectedShape: CONSUME_EXPECTED_SHAPE,
825
+ example: CONSUME_EXAMPLE,
826
+ hint: `POST /api/observations/consume with body ${CONSUME_EXAMPLE}`,
827
+ }, 405, { Allow: "POST" }));
315
828
  app.get("/observations/stats", (c) => {
316
829
  const stats = getObservationStats(db);
317
830
  // cost-reduction-structural §A telemetry — surface summarizer health