@konneal/engine 0.2.52 → 0.2.54

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-SU4FO7TN.js";
3
+ } from "./chunk-OPJXHEZU.js";
4
4
  import "./chunk-TPV5GPY2.js";
5
5
  import "./chunk-SPKPH54J.js";
6
6
  import "./chunk-A3QHHUN5.js";
@@ -2181,13 +2181,31 @@ async function handleAsk(env, ctx, req, tier, key) {
2181
2181
  if (body?.image != null && !userImage) {
2182
2182
  return err(400, "invalid_image", "image must be a data URL (data:image/png|jpeg|webp|gif;base64,\u2026) up to 6 MB");
2183
2183
  }
2184
+ let storedImage = null;
2185
+ const attachIds = Array.isArray(body?.attachment_ids) ? body.attachment_ids.filter((x) => typeof x === "string" && /^[0-9a-f-]{36}$/i.test(x)).slice(0, 2) : [];
2186
+ if (attachIds.length && env.DB && member) {
2187
+ for (const aid of attachIds) {
2188
+ const row = await env.DB.prepare("SELECT mime, r2_key FROM attachments WHERE id = ?1 AND sub = ?2").bind(aid, member.sub).first();
2189
+ if (!row) continue;
2190
+ try {
2191
+ const obj = await env.CHAT_UPLOADS.get(row.r2_key);
2192
+ if (!obj) continue;
2193
+ const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
2194
+ storedImage = `data:${row.mime};base64,${b64}`;
2195
+ break;
2196
+ } catch {
2197
+ }
2198
+ }
2199
+ }
2200
+ const effectiveImage = userImage ?? storedImage;
2184
2201
  const budget = num(env, "INPUT_TOKEN_BUDGET", LIMITS.inputTokenBudget);
2185
2202
  const { kept: keptHistory, overflow } = splitHistory(history, budget);
2186
2203
  const summary = overflow.length >= 2 ? await summarizeHistory(env, MODELS.understand, overflow) ?? void 0 : void 0;
2187
2204
  let retrieved;
2188
2205
  const fresh = freshRequested(body);
2189
2206
  const gen = await corpusGen(env.CACHE);
2190
- const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
2207
+ const withImage = effectiveImage || storedImage;
2208
+ const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
2191
2209
  const wantsStream = body?.stream === true || tier === "anon" && body?.stream !== false;
2192
2210
  if (cached) {
2193
2211
  telemetry(env, ctx, tier, "ask", null, true, (cached.value.answer ?? "").length, cached.value.query_hash, q.lang, "exact", telemetryMeta());
@@ -2204,6 +2222,21 @@ async function handleAsk(env, ctx, req, tier, key) {
2204
2222
  const warmQuery = retrievalQuery(q.query, prev);
2205
2223
  const warmEmbed = embedWarm(env, warmQuery);
2206
2224
  const conversationId = typeof body?.conversation_id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(body.conversation_id) ? body.conversation_id : null;
2225
+ if (!effectiveImage && !attachIds.length && conversationId && env.DB && member) {
2226
+ const prior = await env.DB.prepare(
2227
+ "SELECT m.attachment_id AS aid, a.mime, a.r2_key FROM messages m JOIN attachments a ON a.id = m.attachment_id WHERE m.conversation_id = ?1 AND m.role = 'user' AND m.attachment_id IS NOT NULL ORDER BY m.created_at DESC LIMIT 1"
2228
+ ).bind(conversationId, member.sub).first();
2229
+ if (prior) {
2230
+ try {
2231
+ const obj = await env.CHAT_UPLOADS.get(prior.r2_key);
2232
+ if (obj) {
2233
+ const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
2234
+ storedImage = `data:${prior.mime};base64,${b64}`;
2235
+ }
2236
+ } catch {
2237
+ }
2238
+ }
2239
+ }
2207
2240
  let convEntities = [];
2208
2241
  if (conversationId) {
2209
2242
  try {
@@ -2651,18 +2684,18 @@ Answer account questions from these records ONLY: name the record when you use i
2651
2684
  budget
2652
2685
  );
2653
2686
  await attachFigureImages(env, messages, usedHits, q.query);
2654
- if (userImage) {
2687
+ if (withImage) {
2655
2688
  const wantsMarkings = /\b(mark(ing|ings)?|nameplate|label|inscription|engrav|sticker|plate)\b|certificat/i.test(q.query);
2656
2689
  const last = messages[messages.length - 1];
2657
2690
  const note = wantsMarkings ? "\n\n(The user attached a photo with this question. First transcribe every inscription you can actually read in the image \u2014 names, model references, accuracy classes, numeric values with their units, certificate or approval numbers \u2014 quoting them verbatim. Then answer the question from the numbered passages, citing the requirement each interpretation rests on. If an inscription is unreadable, say so; never invent a marking.)" : "\n\n(The user attached an image with this question; interpret it directly when answering.)";
2658
2691
  if (Array.isArray(last.content)) {
2659
2692
  const textPart = last.content.find((p) => p.type === "text");
2660
2693
  if (textPart) textPart.text += note;
2661
- last.content = [...last.content, { type: "image_url", image_url: { url: userImage } }];
2694
+ last.content = [...last.content, { type: "image_url", image_url: { url: withImage } }];
2662
2695
  } else {
2663
2696
  last.content = [
2664
2697
  { type: "text", text: last.content + note },
2665
- { type: "image_url", image_url: { url: userImage } }
2698
+ { type: "image_url", image_url: { url: withImage } }
2666
2699
  ];
2667
2700
  }
2668
2701
  console.log("user image attached to generation");
@@ -2875,7 +2908,6 @@ export {
2875
2908
  handleMemories,
2876
2909
  scoreFaithfulness,
2877
2910
  checkQuoteAnchors,
2878
- standardForDocNumber,
2879
2911
  bindModelNode,
2880
2912
  modelGroundingBlock,
2881
2913
  scoreJudge,
@@ -3,7 +3,7 @@ export interface OpenApiRoute {
3
3
  pattern: string;
4
4
  operationId: string;
5
5
  }
6
- export type OpenApiOperationId = "askAnonymous" | "askKeyed" | "search" | "searchKeyed" | "absence" | "absenceKeyed" | "verify" | "verifyKeyed" | "research" | "researchKeyed" | "mcp" | "datasets" | "keyUsage" | "health" | "listConversations" | "createConversation" | "getConversation" | "renameConversation" | "deleteConversation" | "appendMessage" | "shareConversation" | "getShared" | "uploadAttachment" | "listAttachments" | "getAttachment" | "deleteAttachment" | "listMemories" | "createMemory" | "deleteMemory" | "listProjects" | "createProject" | "deleteProject" | "listProjectFiles" | "attachProjectFile" | "detachProjectFile" | "laneQuery" | "laneKeyed" | "feedback" | "adminEnrich" | "adminEnrichAlias" | "adminSection" | "adminSectionAlias" | "adminVectors" | "adminCaption" | "adminJudge" | "adminJudgeAlias" | "adminRevokeKey" | "authMe" | "authLogin" | "authCallback" | "authLogout" | "authLogoutLink" | "adminStats" | "adminListKeys" | "adminCreateKey";
6
+ export type OpenApiOperationId = "askAnonymous" | "askKeyed" | "search" | "searchKeyed" | "verify" | "verifyKeyed" | "research" | "researchKeyed" | "mcp" | "datasets" | "keyUsage" | "health" | "listConversations" | "createConversation" | "getConversation" | "renameConversation" | "deleteConversation" | "appendMessage" | "shareConversation" | "getShared" | "uploadAttachment" | "listAttachments" | "getAttachment" | "deleteAttachment" | "listMemories" | "createMemory" | "deleteMemory" | "listProjects" | "createProject" | "deleteProject" | "listProjectFiles" | "attachProjectFile" | "detachProjectFile" | "laneQuery" | "laneKeyed" | "feedback" | "adminEnrich" | "adminEnrichAlias" | "adminSection" | "adminSectionAlias" | "adminVectors" | "adminCaption" | "adminJudge" | "adminJudgeAlias" | "adminRevokeKey" | "authMe" | "authLogin" | "authCallback" | "authLogout" | "authLogoutLink" | "adminStats" | "adminListKeys" | "adminCreateKey";
7
7
  export declare const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & {
8
8
  operationId: OpenApiOperationId;
9
9
  })[];
@@ -86,43 +86,6 @@ export interface paths {
86
86
  patch?: never;
87
87
  trace?: never;
88
88
  };
89
- "/api/absence": {
90
- parameters: {
91
- query?: never;
92
- header?: never;
93
- path?: never;
94
- cookie?: never;
95
- };
96
- get?: never;
97
- put?: never;
98
- /**
99
- * Request a provable absence certificate
100
- * @description Enumerates the entire machine-readable model of the named standard and reports how many nodes were examined and whether any matched the topic. A verdict of absent is an enumeration certificate that states its scope, not a statement that a search found nothing.
101
- */
102
- post: operations["absence"];
103
- delete?: never;
104
- options?: never;
105
- head?: never;
106
- patch?: never;
107
- trace?: never;
108
- };
109
- "/v1/absence": {
110
- parameters: {
111
- query?: never;
112
- header?: never;
113
- path?: never;
114
- cookie?: never;
115
- };
116
- get?: never;
117
- put?: never;
118
- /** Request a provable absence certificate with an API key */
119
- post: operations["absenceKeyed"];
120
- delete?: never;
121
- options?: never;
122
- head?: never;
123
- patch?: never;
124
- trace?: never;
125
- };
126
89
  "/api/verify": {
127
90
  parameters: {
128
91
  query?: never;
@@ -1089,58 +1052,6 @@ export interface operations {
1089
1052
  };
1090
1053
  };
1091
1054
  };
1092
- absence: {
1093
- parameters: {
1094
- query?: never;
1095
- header?: never;
1096
- path?: never;
1097
- cookie?: never;
1098
- };
1099
- requestBody: {
1100
- content: {
1101
- "application/json": {
1102
- /** @description The publication, for example R 60. */
1103
- standard: string;
1104
- /** @description The topic whose absence is to be proven. */
1105
- topic: string;
1106
- };
1107
- };
1108
- };
1109
- responses: {
1110
- /** @description The enumeration certificate. */
1111
- 200: {
1112
- headers: {
1113
- [name: string]: unknown;
1114
- };
1115
- content?: never;
1116
- };
1117
- };
1118
- };
1119
- absenceKeyed: {
1120
- parameters: {
1121
- query?: never;
1122
- header?: never;
1123
- path?: never;
1124
- cookie?: never;
1125
- };
1126
- requestBody: {
1127
- content: {
1128
- "application/json": {
1129
- standard: string;
1130
- topic: string;
1131
- };
1132
- };
1133
- };
1134
- responses: {
1135
- /** @description The enumeration certificate. */
1136
- 200: {
1137
- headers: {
1138
- [name: string]: unknown;
1139
- };
1140
- content?: never;
1141
- };
1142
- };
1143
- };
1144
1055
  verify: {
1145
1056
  parameters: {
1146
1057
  query?: never;
@@ -8,9 +8,8 @@ import {
8
8
  handleMemories,
9
9
  modelGroundingBlock,
10
10
  scoreFaithfulness,
11
- scoreJudge,
12
- standardForDocNumber
13
- } from "../../chunk-SU4FO7TN.js";
11
+ scoreJudge
12
+ } from "../../chunk-OPJXHEZU.js";
14
13
  import {
15
14
  buildMessages,
16
15
  cfBlobs,
@@ -25,7 +24,6 @@ import {
25
24
  handleLogin,
26
25
  handleLogout,
27
26
  handleMe,
28
- namedDocumentIn,
29
27
  opCfg,
30
28
  opMemberCanWrite,
31
29
  opTokenMember,
@@ -103,11 +101,11 @@ async function handleConversations(env, sub, req, route) {
103
101
  let msgs;
104
102
  try {
105
103
  msgs = await env.DB.prepare(
106
- "SELECT id, role, content, citations, model, context_applied, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
104
+ "SELECT id, role, content, citations, model, context_applied, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
107
105
  ).bind(id).all();
108
106
  } catch {
109
107
  msgs = await env.DB.prepare(
110
- "SELECT id, role, content, citations, model, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
108
+ "SELECT id, role, content, citations, model, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC"
111
109
  ).bind(id).all();
112
110
  }
113
111
  return json({
@@ -151,6 +149,12 @@ async function handleAppendMessage(env, sub, req, convId) {
151
149
  if (role !== "user" && role !== "assistant" || !content.trim() || content.length > LIMITS.maxOutputTokens * 4) {
152
150
  return err(400, "invalid_input", "role (user|assistant) and content are required");
153
151
  }
152
+ let attachmentId = null;
153
+ if (typeof body?.attachment_id === "string" && body.attachment_id) {
154
+ const owned = await env.DB.prepare("SELECT id FROM attachments WHERE id = ?1 AND sub = ?2").bind(body.attachment_id, sub).first();
155
+ if (!owned) return err(404, "not_found", "No such attachment");
156
+ attachmentId = body.attachment_id;
157
+ }
154
158
  let citations2 = null;
155
159
  if (body?.citations != null) {
156
160
  if (!Array.isArray(body.citations) || body.citations.length > 16) {
@@ -166,8 +170,8 @@ async function handleAppendMessage(env, sub, req, convId) {
166
170
  try {
167
171
  await env.DB.batch([
168
172
  env.DB.prepare(
169
- "INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)"
170
- ).bind(mid, convId, role, content, citations2, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, now),
173
+ "INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, attachment_id, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"
174
+ ).bind(mid, convId, role, content, citations2, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, attachmentId, now),
171
175
  env.DB.prepare("UPDATE conversations SET updated_at = ?1 WHERE id = ?2 AND sub = ?3").bind(now, convId, sub)
172
176
  ]);
173
177
  } catch (e) {
@@ -954,7 +958,7 @@ async function handleMcp(env, ctx, req, tier, key) {
954
958
  // stream:false forces the JSON lane (anon defaults to SSE)
955
959
  body: JSON.stringify({ ...args, stream: false })
956
960
  });
957
- const res = name === "ask" ? await (await import("../../ask-27TMC7PV.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-PBOODCMQ.js")).handleSearch(env, ctx, inner, tier, key);
961
+ const res = name === "ask" ? await (await import("../../ask-3TAC56RC.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-PBOODCMQ.js")).handleSearch(env, ctx, inner, tier, key);
958
962
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
959
963
  });
960
964
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
@@ -1014,16 +1018,6 @@ var OPENAPI_SURFACE = [
1014
1018
  "pattern": "/v1/search",
1015
1019
  "operationId": "searchKeyed"
1016
1020
  },
1017
- {
1018
- "method": "POST",
1019
- "pattern": "/api/absence",
1020
- "operationId": "absence"
1021
- },
1022
- {
1023
- "method": "POST",
1024
- "pattern": "/v1/absence",
1025
- "operationId": "absenceKeyed"
1026
- },
1027
1021
  {
1028
1022
  "method": "POST",
1029
1023
  "pattern": "/api/verify",
@@ -1450,38 +1444,6 @@ async function adminStatsRoute(c) {
1450
1444
  pruned: "telemetry >90d"
1451
1445
  }, 200, corsHeaders(req));
1452
1446
  }
1453
- async function absenceRoute(c) {
1454
- const { env, req } = c;
1455
- const t = await tierFor(c);
1456
- if (t instanceof Response) return t;
1457
- const body = await readJson(req);
1458
- const namedStd = namedDocumentIn(String(body?.standard ?? ""));
1459
- const standard = standardForDocNumber(namedStd?.doc_number ?? String(body?.standard ?? "").trim());
1460
- const topic = String(body?.topic ?? "").trim().toLowerCase();
1461
- if (!standard || !topic) return err(400, "invalid_input", "standard and topic are required");
1462
- try {
1463
- const nodes = (await env.DB.prepare("SELECT node_id, kind, name, content FROM model_nodes WHERE standard = ?1").bind(standard).all()).results ?? [];
1464
- const tokens = topic.split(/\s+/).filter((t2) => t2.length > 2);
1465
- const matches = [];
1466
- for (const n of nodes) {
1467
- const hay = `${n.name ?? ""} ${n.content ?? ""}`.toLowerCase();
1468
- if (tokens.some((tok) => hay.includes(tok))) {
1469
- matches.push({ node_id: n.node_id, kind: n.kind });
1470
- }
1471
- }
1472
- const chunks = await env.DB.prepare("SELECT COUNT(*) AS n FROM chunks WHERE corpus = 'smart-model' AND (docidentifier LIKE ?1 OR doc_id LIKE ?2)").bind(`%${body?.standard}%`, `%${body?.standard}%`).first();
1473
- return json({
1474
- standard,
1475
- topic,
1476
- enumerated: { model_nodes: nodes.length, smart_model_chunks: chunks?.n ?? 0 },
1477
- matches: matches.slice(0, 20),
1478
- verdict: matches.length === 0 ? "absent" : "present",
1479
- scope: `the machine-readable model of ${standard} (all model nodes) \u2014 the enumeration is exhaustive over that scope; prose outside the modeled families is not claimed`
1480
- });
1481
- } catch (e) {
1482
- return err(502, "absence_failed", String(e).slice(0, 200));
1483
- }
1484
- }
1485
1447
  async function verifyRoute(c) {
1486
1448
  const { env, req } = c;
1487
1449
  const t = await tierFor(c);
@@ -1721,8 +1683,6 @@ var OPENAPI_HANDLERS = {
1721
1683
  askKeyed: askRoute,
1722
1684
  search: searchRoute,
1723
1685
  searchKeyed: searchRoute,
1724
- absence: absenceRoute,
1725
- absenceKeyed: absenceRoute,
1726
1686
  verify: verifyRoute,
1727
1687
  verifyKeyed: verifyRoute,
1728
1688
  research: researchRoute,
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.52",
30
+ "version": "0.2.54",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -0,0 +1,4 @@
1
+ -- Tier-2 image reuse (TODO.new-era/8): a stored attachment rides its user
2
+ -- message, so follow-up turns can re-attach it server-side within the
3
+ -- conversation's ownership
4
+ ALTER TABLE messages ADD COLUMN attachment_id TEXT;
@@ -178,58 +178,6 @@ paths:
178
178
  responses:
179
179
  '200':
180
180
  description: The ranked hits.
181
- /api/absence:
182
- post:
183
- tags: [Evidence]
184
- summary: Request a provable absence certificate
185
- description: >-
186
- Enumerates the entire machine-readable model of the named standard and
187
- reports how many nodes were examined and whether any matched the
188
- topic. A verdict of absent is an enumeration certificate that
189
- states its scope, not a statement that a search found nothing.
190
- operationId: absence
191
- requestBody:
192
- required: true
193
- content:
194
- application/json:
195
- schema:
196
- type: object
197
- required: [standard, topic]
198
- properties:
199
- standard:
200
- type: string
201
- description: The publication, for example R 60.
202
- topic:
203
- type: string
204
- description: The topic whose absence is to be proven.
205
- examples:
206
- packaging:
207
- value:
208
- standard: R 60
209
- topic: packaging
210
- responses:
211
- '200':
212
- description: The enumeration certificate.
213
- /v1/absence:
214
- post:
215
- tags: [Evidence]
216
- summary: Request a provable absence certificate with an API key
217
- operationId: absenceKeyed
218
- security:
219
- - bearerAuth: []
220
- requestBody:
221
- required: true
222
- content:
223
- application/json:
224
- schema:
225
- type: object
226
- required: [standard, topic]
227
- properties:
228
- standard: { type: string }
229
- topic: { type: string }
230
- responses:
231
- '200':
232
- description: The enumeration certificate.
233
181
  /api/verify:
234
182
  post:
235
183
  tags: [Evidence]
@@ -258,3 +258,6 @@ CREATE TABLE IF NOT EXISTS attachments (
258
258
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
259
259
  );
260
260
  CREATE INDEX IF NOT EXISTS idx_attachments_sub ON attachments (sub);
261
+
262
+ -- Tier-2 image reuse: the user message's stored attachment
263
+ ALTER TABLE messages ADD COLUMN attachment_id TEXT;
@@ -379,6 +379,31 @@ async function handleAsk(
379
379
  if (body?.image != null && !userImage) {
380
380
  return err(400, "invalid_image", "image must be a data URL (data:image/png|jpeg|webp|gif;base64,…) up to 6 MB");
381
381
  }
382
+ // Tier-2 image reuse (TODO.new-era/8): stored attachments materialize
383
+ // as the question's image. Explicit attachment_ids win (member-gated,
384
+ // ownership-checked, at most one — the multimodal shape rule); with
385
+ // none given, the conversation's most recent image re-attaches itself,
386
+ // so "identify this" works turns after the upload.
387
+ let storedImage: string | null = null;
388
+ const attachIds: string[] = Array.isArray(body?.attachment_ids)
389
+ ? body.attachment_ids.filter((x: unknown) => typeof x === "string" && /^[0-9a-f-]{36}$/i.test(x)).slice(0, 2)
390
+ : [];
391
+ if (attachIds.length && env.DB && member) {
392
+ for (const aid of attachIds) {
393
+ const row = await env.DB.prepare("SELECT mime, r2_key FROM attachments WHERE id = ?1 AND sub = ?2").bind(aid, member.sub).first<{ mime: string; r2_key: string }>();
394
+ if (!row) continue;
395
+ try {
396
+ const obj = await (env as any).CHAT_UPLOADS.get(row.r2_key);
397
+ if (!obj) continue;
398
+ const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
399
+ storedImage = `data:${row.mime};base64,${b64}`;
400
+ break; // the multimodal shape rule: one image per question
401
+ } catch {
402
+ /* the object's gone — ask without it */
403
+ }
404
+ }
405
+ }
406
+ const effectiveImage = userImage ?? storedImage;
382
407
  // history compaction: turns beyond the budget slice are summarized into a
383
408
  // continuity block (below) instead of silently dropped
384
409
  const budget = num(env as any, "INPUT_TOKEN_BUDGET", LIMITS.inputTokenBudget);
@@ -395,7 +420,8 @@ async function handleAsk(
395
420
  // answer caches; corpus surgery bumps it (scripts/invalidate_answer_
396
421
  // cache.py) and old-generation entries miss (oimlsmart/rag#72)
397
422
  const gen = await corpusGen(env.CACHE);
398
- const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
423
+ const withImage = effectiveImage || storedImage;
424
+ const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
399
425
  const wantsStream = body?.stream === true || (tier === "anon" && body?.stream !== false);
400
426
 
401
427
  if (cached) {
@@ -424,6 +450,25 @@ async function handleAsk(
424
450
  // (present when the client passes conversation_id) make pronoun
425
451
  // follow-ups O(1) — "it / the 2017 one" resolve against the map
426
452
  const conversationId = typeof body?.conversation_id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(body.conversation_id) ? body.conversation_id : null;
453
+
454
+ // Tier-2 continuation: no new image, but the conversation carries one —
455
+ // its most recent stored image re-attaches to this question
456
+ if (!effectiveImage && !attachIds.length && conversationId && env.DB && member) {
457
+ const prior = await env.DB.prepare(
458
+ "SELECT m.attachment_id AS aid, a.mime, a.r2_key FROM messages m JOIN attachments a ON a.id = m.attachment_id WHERE m.conversation_id = ?1 AND m.role = 'user' AND m.attachment_id IS NOT NULL ORDER BY m.created_at DESC LIMIT 1",
459
+ ).bind(conversationId, member.sub).first<{ aid: string; mime: string; r2_key: string }>();
460
+ if (prior) {
461
+ try {
462
+ const obj = await (env as any).CHAT_UPLOADS.get(prior.r2_key);
463
+ if (obj) {
464
+ const b64 = btoa(String.fromCharCode(...new Uint8Array(await obj.arrayBuffer())));
465
+ storedImage = `data:${prior.mime};base64,${b64}`;
466
+ }
467
+ } catch {
468
+ /* the object's gone */
469
+ }
470
+ }
471
+ }
427
472
  let convEntities: Array<{ entity: string; kind: string }> = [];
428
473
  if (conversationId) {
429
474
  try {
@@ -1081,7 +1126,7 @@ async function handleAsk(
1081
1126
  budget,
1082
1127
  );
1083
1128
  await attachFigureImages(env, messages, usedHits, q.query);
1084
- if (userImage) {
1129
+ if (withImage) {
1085
1130
  // the user's own image rides on the question message — retrieval stays
1086
1131
  // text-driven; the answer model reads the image as question context.
1087
1132
  // A markings/nameplate/certificate photo gets a TRANSCRIPTION note:
@@ -1096,11 +1141,11 @@ async function handleAsk(
1096
1141
  if (Array.isArray(last.content)) {
1097
1142
  const textPart = last.content.find((p: any) => p.type === "text");
1098
1143
  if (textPart) textPart.text += note;
1099
- last.content = [...last.content, { type: "image_url", image_url: { url: userImage } }] as unknown as string;
1144
+ last.content = [...last.content, { type: "image_url", image_url: { url: withImage } }] as unknown as string;
1100
1145
  } else {
1101
1146
  last.content = [
1102
1147
  { type: "text", text: last.content + note },
1103
- { type: "image_url", image_url: { url: userImage } },
1148
+ { type: "image_url", image_url: { url: withImage } },
1104
1149
  ] as unknown as string;
1105
1150
  }
1106
1151
  console.log("user image attached to generation");
@@ -66,13 +66,13 @@ export async function handleConversations(
66
66
  let msgs: any;
67
67
  try {
68
68
  msgs = await env.DB.prepare(
69
- "SELECT id, role, content, citations, model, context_applied, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
69
+ "SELECT id, role, content, citations, model, context_applied, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
70
70
  )
71
71
  .bind(id)
72
72
  .all();
73
73
  } catch {
74
74
  msgs = await env.DB.prepare(
75
- "SELECT id, role, content, citations, model, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
75
+ "SELECT id, role, content, citations, model, attachment_id, created_at FROM messages WHERE conversation_id = ?1 ORDER BY created_at ASC",
76
76
  )
77
77
  .bind(id)
78
78
  .all();
@@ -124,6 +124,14 @@ export async function handleAppendMessage(env: any, sub: string, req: Request, c
124
124
  if ((role !== "user" && role !== "assistant") || !content.trim() || content.length > LIMITS.maxOutputTokens * 4) {
125
125
  return err(400, "invalid_input", "role (user|assistant) and content are required");
126
126
  }
127
+ // Tier-2 image reuse: the message may carry a stored attachment id —
128
+ // the member's own, validated against the attachments table
129
+ let attachmentId: string | null = null;
130
+ if (typeof body?.attachment_id === "string" && body.attachment_id) {
131
+ const owned = await env.DB.prepare("SELECT id FROM attachments WHERE id = ?1 AND sub = ?2").bind(body.attachment_id, sub).first();
132
+ if (!owned) return err(404, "not_found", "No such attachment");
133
+ attachmentId = body.attachment_id;
134
+ }
127
135
  let citations: string | null = null;
128
136
  if (body?.citations != null) {
129
137
  if (!Array.isArray(body.citations) || body.citations.length > 16) {
@@ -142,8 +150,8 @@ export async function handleAppendMessage(env: any, sub: string, req: Request, c
142
150
  try {
143
151
  await env.DB.batch([
144
152
  env.DB.prepare(
145
- "INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)",
146
- ).bind(mid, convId, role, content, citations, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, now),
153
+ "INSERT INTO messages (id, conversation_id, role, content, citations, model, context_applied, attachment_id, created_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
154
+ ).bind(mid, convId, role, content, citations, typeof body?.model === "string" ? body.model.slice(0, 80) : null, applied ? JSON.stringify(applied) : null, attachmentId, now),
147
155
  env.DB.prepare("UPDATE conversations SET updated_at = ?1 WHERE id = ?2 AND sub = ?3").bind(now, convId, sub),
148
156
  ]);
149
157
  } catch (e) {
@@ -13,8 +13,7 @@ import { embed } from "./ai";
13
13
  import { ftsMatchQuery } from "./lexical";
14
14
  import { scoreFaithfulness } from "./faithfulness";
15
15
  import { checkQuoteAnchors } from "./anchors";
16
- import { namedDocumentIn } from "./context";
17
- import { standardForDocNumber, bindModelNode, modelGroundingBlock } from "./modelplane";
16
+ import { bindModelNode, modelGroundingBlock } from "./modelplane";
18
17
  import { refCodec } from "./codecs";
19
18
  import { entitlementScope, standardKeysFrom } from "./requestScope";
20
19
  import type { Env } from "./env";
@@ -261,40 +260,6 @@ async function adminStatsRoute(c: RouteContext): Promise<Response> {
261
260
  }, 200, corsHeaders(req));
262
261
  }
263
262
 
264
- // ── Provable absence (TODO.era3/02): deterministic enumeration proof ──
265
- async function absenceRoute(c: RouteContext): Promise<Response> {
266
- const { env, req } = c;
267
- const t = await tierFor(c);
268
- if (t instanceof Response) return t;
269
- const body = await readJson(req);
270
- const namedStd = namedDocumentIn(String(body?.standard ?? ""));
271
- const standard = standardForDocNumber(namedStd?.doc_number ?? String(body?.standard ?? "").trim());
272
- const topic = String(body?.topic ?? "").trim().toLowerCase();
273
- if (!standard || !topic) return err(400, "invalid_input", "standard and topic are required");
274
- try {
275
- const nodes = (await env.DB.prepare("SELECT node_id, kind, name, content FROM model_nodes WHERE standard = ?1").bind(standard).all()).results ?? [];
276
- const tokens = topic.split(/\s+/).filter((t: string) => t.length > 2);
277
- const matches: unknown[] = [];
278
- for (const n of nodes as any[]) {
279
- const hay = `${n.name ?? ""} ${n.content ?? ""}`.toLowerCase();
280
- if (tokens.some((tok: string) => hay.includes(tok))) {
281
- matches.push({ node_id: n.node_id, kind: n.kind });
282
- }
283
- }
284
- const chunks = (await env.DB.prepare("SELECT COUNT(*) AS n FROM chunks WHERE corpus = 'smart-model' AND (docidentifier LIKE ?1 OR doc_id LIKE ?2)").bind(`%${body?.standard}%`, `%${body?.standard}%`).first()) as any;
285
- return json({
286
- standard,
287
- topic,
288
- enumerated: { model_nodes: nodes.length, smart_model_chunks: chunks?.n ?? 0 },
289
- matches: matches.slice(0, 20),
290
- verdict: matches.length === 0 ? "absent" : "present",
291
- scope: `the machine-readable model of ${standard} (all model nodes) — the enumeration is exhaustive over that scope; prose outside the modeled families is not claimed`,
292
- });
293
- } catch (e) {
294
- return err(502, "absence_failed", String(e).slice(0, 200));
295
- }
296
- }
297
-
298
263
  // ── Self-verification (TODO.era3/03): the deterministic battery, exposed ──
299
264
  async function verifyRoute(c: RouteContext): Promise<Response> {
300
265
  const { env, req } = c;
@@ -595,8 +560,6 @@ const OPENAPI_HANDLERS: Record<OpenApiOperationId, RouteHandler> = {
595
560
  askKeyed: askRoute,
596
561
  search: searchRoute,
597
562
  searchKeyed: searchRoute,
598
- absence: absenceRoute,
599
- absenceKeyed: absenceRoute,
600
563
  verify: verifyRoute,
601
564
  verifyKeyed: verifyRoute,
602
565
  research: researchRoute,
@@ -11,8 +11,6 @@ export type OpenApiOperationId =
11
11
  | "askKeyed"
12
12
  | "search"
13
13
  | "searchKeyed"
14
- | "absence"
15
- | "absenceKeyed"
16
14
  | "verify"
17
15
  | "verifyKeyed"
18
16
  | "research"
@@ -84,16 +82,6 @@ export const OPENAPI_SURFACE: readonly (Omit<OpenApiRoute, "operationId"> & { op
84
82
  "pattern": "/v1/search",
85
83
  "operationId": "searchKeyed"
86
84
  },
87
- {
88
- "method": "POST",
89
- "pattern": "/api/absence",
90
- "operationId": "absence"
91
- },
92
- {
93
- "method": "POST",
94
- "pattern": "/v1/absence",
95
- "operationId": "absenceKeyed"
96
- },
97
85
  {
98
86
  "method": "POST",
99
87
  "pattern": "/api/verify",