@rubytech/create-maxy 1.0.692 → 1.0.694

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 (62) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-search/dist/index.d.ts +127 -0
  3. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-search/dist/index.js +393 -0
  5. package/payload/platform/lib/graph-search/dist/index.js.map +1 -0
  6. package/payload/platform/lib/graph-search/src/__tests__/bm25-only.test.ts +129 -0
  7. package/payload/platform/lib/graph-search/src/__tests__/escape-and-normalise.test.ts +53 -0
  8. package/payload/platform/lib/graph-search/src/__tests__/hybrid.test.ts +190 -0
  9. package/payload/platform/lib/graph-search/src/index.ts +498 -0
  10. package/payload/platform/lib/graph-search/tsconfig.json +9 -0
  11. package/payload/platform/lib/graph-search/vitest.config.ts +9 -0
  12. package/payload/platform/lib/graph-write/dist/index.d.ts +61 -0
  13. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -0
  14. package/payload/platform/lib/graph-write/dist/index.js +97 -0
  15. package/payload/platform/lib/graph-write/dist/index.js.map +1 -0
  16. package/payload/platform/lib/graph-write/src/index.ts +167 -0
  17. package/payload/platform/lib/graph-write/tsconfig.json +8 -0
  18. package/payload/platform/package.json +2 -2
  19. package/payload/platform/plugins/admin/mcp/dist/index.js +69 -15
  20. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  21. package/payload/platform/plugins/contacts/mcp/dist/index.js +27 -3
  22. package/payload/platform/plugins/contacts/mcp/dist/index.js.map +1 -1
  23. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.d.ts +4 -0
  24. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.d.ts.map +1 -1
  25. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.js +10 -6
  26. package/payload/platform/plugins/contacts/mcp/dist/tools/contact-create.js.map +1 -1
  27. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.d.ts +2 -0
  28. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.d.ts.map +1 -1
  29. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.js +43 -36
  30. package/payload/platform/plugins/contacts/mcp/dist/tools/group-create.js.map +1 -1
  31. package/payload/platform/plugins/docs/references/memory-guide.md +6 -0
  32. package/payload/platform/plugins/memory/mcp/dist/index.js +44 -3
  33. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  34. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts +3 -32
  35. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  36. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +18 -381
  37. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  38. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts +9 -5
  39. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  40. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +10 -23
  41. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  42. package/payload/platform/plugins/memory/references/graph-primitives.md +1 -1
  43. package/payload/platform/plugins/scheduling/mcp/dist/index.js +8 -1
  44. package/payload/platform/plugins/scheduling/mcp/dist/index.js.map +1 -1
  45. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts +2 -0
  46. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.d.ts.map +1 -1
  47. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js +24 -10
  48. package/payload/platform/plugins/scheduling/mcp/dist/tools/schedule-event.js.map +1 -1
  49. package/payload/platform/plugins/tasks/mcp/dist/index.js +8 -2
  50. package/payload/platform/plugins/tasks/mcp/dist/index.js.map +1 -1
  51. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts +2 -0
  52. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts.map +1 -1
  53. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js +45 -18
  54. package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js.map +1 -1
  55. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js +12 -2
  56. package/payload/platform/plugins/workflows/mcp/dist/tools/workflow-execute.js.map +1 -1
  57. package/payload/platform/scripts/logs-read.sh +63 -9
  58. package/payload/platform/scripts/logs-read.test.sh +212 -0
  59. package/payload/server/chunk-IAIGB5WN.js +11406 -0
  60. package/payload/server/chunk-Q6NDXCM6.js +11448 -0
  61. package/payload/server/maxy-edge.js +1 -1
  62. package/payload/server/server.js +656 -21
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Write doctrine (Task 673): a node without at least one adjacency is noise,
3
+ * not knowledge. `writeNodeWithEdges` is the single primitive every graph
4
+ * writer should call; it rejects zero-relationship writes, verifies every
5
+ * relationship target resolves before the node is created, and commits the
6
+ * node + all edges in one managed transaction. Provenance is stamped on the
7
+ * node as flattened `createdBy*` properties (Neo4j does not persist nested
8
+ * maps, and flat props are queryable — `MATCH (n) WHERE n.createdBySession
9
+ * = $id RETURN n` is the forensic entry point).
10
+ *
11
+ * Rejection paths (every one emits a stderr log the admin server pipes to
12
+ * server.log, so orphan pressure is visible per-write not just in the
13
+ * hourly [graph-health] signal):
14
+ * - zero relationships → `[graph-write] reject reason=zero-relationships`
15
+ * - unresolved target id → `[graph-write] reject reason=unresolved-target`
16
+ *
17
+ * The `createdBy.agent` field is advisory, not authoritative — it is sourced
18
+ * from the MCP server's AGENT_SLUG env var, which is set by the trusted
19
+ * spawning code (admin server / workflow runner). A misconfigured spawner
20
+ * will write "unknown"; that's the signal something is wrong. Do not use
21
+ * this field for access control — it is forensic, not a security boundary.
22
+ */
23
+
24
+ import type { Session } from "neo4j-driver";
25
+
26
+ export interface GraphRelationship {
27
+ type: string;
28
+ direction: "outgoing" | "incoming";
29
+ targetNodeId: string;
30
+ }
31
+
32
+ export interface CreatedBy {
33
+ /** Agent slug (e.g. "maxy-admin", "whatsapp-public") for LLM-tool writes. */
34
+ agent?: string;
35
+ /** Session correlation id — same string across every node written in a conversation turn. */
36
+ session?: string;
37
+ /** MCP tool name ("memory-write", "contact-create", ...) for LLM-tool writes. */
38
+ tool?: string;
39
+ /** Subsystem name ("workflow-execute", "persist-tool-call", ...) for system writes. */
40
+ source?: string;
41
+ }
42
+
43
+ export interface WriteNodeWithEdgesParams {
44
+ session: Session;
45
+ labels: string[];
46
+ props: Record<string, unknown>;
47
+ /** At least one relationship is required — zero-rel writes are rejected. */
48
+ relationships: GraphRelationship[];
49
+ createdBy: CreatedBy;
50
+ }
51
+
52
+ export interface WriteNodeResult {
53
+ nodeId: string;
54
+ labels: string[];
55
+ edgesCreated: number;
56
+ }
57
+
58
+ /** Stamp flattened provenance into node properties. */
59
+ export function stampCreatedBy(
60
+ props: Record<string, unknown>,
61
+ createdBy: CreatedBy
62
+ ): Record<string, unknown> {
63
+ return {
64
+ ...props,
65
+ createdByAgent: createdBy.agent ?? "unknown",
66
+ createdBySession: createdBy.session ?? "unknown",
67
+ createdByTool: createdBy.tool ?? null,
68
+ createdBySource: createdBy.source ?? null,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Enforce the write doctrine: node + ≥1 edge, transactional, provenance stamped.
74
+ * The label and relationship type strings are interpolated into Cypher — the
75
+ * caller must constrain them via Zod/schema before calling. Backticks are
76
+ * stripped to defeat the only Cypher escape char.
77
+ */
78
+ export async function writeNodeWithEdges(
79
+ params: WriteNodeWithEdgesParams
80
+ ): Promise<WriteNodeResult> {
81
+ const { session, labels, props, relationships, createdBy } = params;
82
+
83
+ const agentLabel = createdBy.agent ?? createdBy.source ?? "unknown";
84
+ const labelCsv = labels.join(",");
85
+
86
+ if (!relationships || relationships.length < 1) {
87
+ process.stderr.write(
88
+ `[graph-write] reject reason=zero-relationships labels=${labelCsv} agent=${agentLabel}\n`
89
+ );
90
+ throw new Error(
91
+ "Write doctrine violated: a node must be created with at least one relationship. See .docs/neo4j.md (Write doctrine)."
92
+ );
93
+ }
94
+
95
+ const labelStr = labels.map((l) => `\`${l.replace(/`/g, "")}\``).join(":");
96
+ const nodeProps = stampCreatedBy(props, createdBy);
97
+
98
+ return await session.executeWrite(async (tx) => {
99
+ const targetIds = relationships.map((r) => r.targetNodeId);
100
+ const check = await tx.run(
101
+ `UNWIND $ids AS id MATCH (t) WHERE elementId(t) = id RETURN count(DISTINCT t) AS found`,
102
+ { ids: targetIds }
103
+ );
104
+ const found = check.records[0].get("found").toNumber();
105
+ const uniqueRequested = new Set(targetIds).size;
106
+ if (found !== uniqueRequested) {
107
+ process.stderr.write(
108
+ `[graph-write] reject reason=unresolved-target labels=${labelCsv} agent=${agentLabel} requested=${uniqueRequested} found=${found}\n`
109
+ );
110
+ throw new Error(
111
+ `Write doctrine violated: ${uniqueRequested - found} of ${uniqueRequested} relationship target(s) did not resolve (elementId mismatch). No node created.`
112
+ );
113
+ }
114
+
115
+ const nodeRes = await tx.run(
116
+ `CREATE (n:${labelStr} $props) RETURN elementId(n) AS nodeId, labels(n) AS nodeLabels`,
117
+ { props: nodeProps }
118
+ );
119
+ const nodeId = nodeRes.records[0].get("nodeId") as string;
120
+ const nodeLabels = nodeRes.records[0].get("nodeLabels") as string[];
121
+
122
+ // Neo4j Community's default isolation is read-committed (not snapshot)
123
+ // — a target elementId that resolved during the pre-check can be
124
+ // deleted by a concurrent transaction before the per-edge CREATE below
125
+ // runs. If that happens, `MATCH (a), (b)` returns 0 rows and the CREATE
126
+ // quietly produces no edge. Per-edge counter inspection catches the
127
+ // race: any zero-result CREATE throws inside the transaction callback,
128
+ // so `executeWrite` rolls the node back — no silent-orphan path.
129
+ let edgesCreated = 0;
130
+ for (const rel of relationships) {
131
+ const type = rel.type.replace(/`/g, "");
132
+ const q =
133
+ rel.direction === "outgoing"
134
+ ? `MATCH (a), (b) WHERE elementId(a) = $from AND elementId(b) = $to CREATE (a)-[:\`${type}\`]->(b)`
135
+ : `MATCH (a), (b) WHERE elementId(a) = $from AND elementId(b) = $to CREATE (b)-[:\`${type}\`]->(a)`;
136
+ const r = await tx.run(q, { from: nodeId, to: rel.targetNodeId });
137
+ const created = r.summary.counters.updates().relationshipsCreated;
138
+ if (created === 0) {
139
+ process.stderr.write(
140
+ `[graph-write] reject reason=unresolved-target-on-create labels=${labelCsv} agent=${agentLabel} relType=${rel.type} targetId=${rel.targetNodeId}\n`
141
+ );
142
+ throw new Error(
143
+ `Write doctrine violated: relationship CREATE to target ${rel.targetNodeId} produced 0 edges (target likely deleted concurrently after pre-check). Transaction rolled back.`
144
+ );
145
+ }
146
+ edgesCreated += created;
147
+ }
148
+
149
+ if (edgesCreated !== relationships.length) {
150
+ // Defensive: should be unreachable given the per-edge check above.
151
+ // The rollback throws loudly so regression shows rather than a
152
+ // silent commit with edgesCreated < requested.
153
+ process.stderr.write(
154
+ `[graph-write] reject reason=edge-count-mismatch labels=${labelCsv} agent=${agentLabel} requested=${relationships.length} created=${edgesCreated}\n`
155
+ );
156
+ throw new Error(
157
+ `Write doctrine violated: expected ${relationships.length} edges, created ${edgesCreated}. Transaction rolled back.`
158
+ );
159
+ }
160
+
161
+ process.stderr.write(
162
+ `[graph-write] accepted labels=${labelCsv} edges=${edgesCreated} createdByAgent=${createdBy.agent ?? "unknown"} createdByTool=${createdBy.tool ?? createdBy.source ?? "unknown"}\n`
163
+ );
164
+
165
+ return { nodeId, labels: nodeLabels, edgesCreated };
166
+ });
167
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -6,8 +6,8 @@
6
6
  "plugins/*/mcp"
7
7
  ],
8
8
  "scripts": {
9
- "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
- "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json",
9
+ "build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
10
+ "build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json",
11
11
  "build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
12
12
  "build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
13
13
  "build:telegram": "tsc -p plugins/telegram/mcp/tsconfig.json",
@@ -6,7 +6,7 @@ import { z } from "zod";
6
6
  import { readFile, writeFile } from "node:fs/promises";
7
7
  import { resolve, join } from "node:path";
8
8
  import { execFileSync } from "node:child_process";
9
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
9
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
10
10
  import { writeKey, validateKey, hasKey, keyFilePath, deleteKey } from "../../../../lib/anthropic-key/dist/index.js";
11
11
  import { deviceUrlBlock } from "../../../../lib/device-url/dist/index.js";
12
12
  import { createHash, randomInt, randomUUID } from "node:crypto";
@@ -968,6 +968,23 @@ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=syst
968
968
  // Precedes the sessionKey grep path because a conversationId is the
969
969
  // tightest identity and answers single-conversation questions without
970
970
  // a grep pass across every log file.
971
+ //
972
+ // Task 671: resolves two filename shapes from the same identifier —
973
+ // FULL: {prefix}-{conversationId}.log (post 1→2-user-turn flush)
974
+ // PREFLUSH: {prefix}-preflush-{id:0:12}.log (pre-flush, first turn;
975
+ // slice is of the sessionKey)
976
+ // Full is tried first; if absent, preflush is the fallback. If both
977
+ // exist, full wins and the preflush sibling is logged to server.log
978
+ // as stale (best-effort housekeeping signal).
979
+ //
980
+ // Identity note: for the abrupt-exit case this branch exists to solve,
981
+ // the operator passes the sessionKey (no conversationId was ever
982
+ // assigned). For post-flush retrievals the operator passes the
983
+ // conversationId and the FULL file is expected to exist.
984
+ //
985
+ // MIRROR: keep the two-shape contract in sync with:
986
+ // - platform/ui/app/lib/logs-read-resolve.ts (canonical TS helper + tests)
987
+ // - platform/scripts/logs-read.sh (per_conversation_mode)
971
988
  if (conversationId) {
972
989
  if (!existsSync(LOG_DIR)) {
973
990
  return { content: [{ type: "text", text: `Log directory does not exist: ${LOG_DIR}` }] };
@@ -986,13 +1003,39 @@ server.tool("logs-read", "Read recent logs. Task 532: the stream logs (type=syst
986
1003
  isError: true,
987
1004
  };
988
1005
  }
989
- const fileName = `${prefix}-${conversationId}.log`;
990
- const filePath = resolve(LOG_DIR, fileName);
991
- if (!existsSync(filePath)) {
992
- return { content: [{ type: "text", text: `No log file found for conversationId=${conversationId} type=${resolvedType} at ${filePath}. If the conversation has not yet spawned a subprocess (first turn mid-init) the file will not exist.` }] };
1006
+ const fullName = `${prefix}-${conversationId}.log`;
1007
+ const preflushName = `${prefix}-preflush-${conversationId.slice(0, 12)}.log`;
1008
+ const fullPath = resolve(LOG_DIR, fullName);
1009
+ const preflushPath = resolve(LOG_DIR, preflushName);
1010
+ // Pass 1: full first. If present, emit stale-preflush signal on sibling.
1011
+ if (existsSync(fullPath)) {
1012
+ if (existsSync(preflushPath)) {
1013
+ // Best-effort housekeeping emit — retrieval must not fail if
1014
+ // server.log is unwritable.
1015
+ try {
1016
+ const ts = new Date().toISOString();
1017
+ appendFileSync(resolve(CONFIG_DIR, "logs", "server.log"), `${ts} [logs-read] stale-preflush-detected path=${preflushPath}\n`);
1018
+ }
1019
+ catch {
1020
+ // swallow
1021
+ }
1022
+ }
1023
+ const result = execFileSync("tail", ["-n", String(lines), fullPath], { timeout: 5000 }).toString();
1024
+ return { content: [{ type: "text", text: `# ${fullName} (matched_shape=full)\n\n${result}` }] };
1025
+ }
1026
+ // Pass 2: preflush fallback.
1027
+ if (existsSync(preflushPath)) {
1028
+ const result = execFileSync("tail", ["-n", String(lines), preflushPath], { timeout: 5000 }).toString();
1029
+ return { content: [{ type: "text", text: `# ${preflushName} (matched_shape=preflush)\n\n${result}` }] };
993
1030
  }
994
- const result = execFileSync("tail", ["-n", String(lines), filePath], { timeout: 5000 }).toString();
995
- return { content: [{ type: "text", text: `# ${fileName}\n\n${result}` }] };
1031
+ // Neither shape present: enumerate both filenames in the miss
1032
+ // message so the reader sees exactly what was tried.
1033
+ return {
1034
+ content: [{
1035
+ type: "text",
1036
+ text: `No log file found for conversationId=${conversationId} type=${resolvedType}. tried=[${fullPath}, ${preflushPath}] reason=file-not-found-in-either-shape`,
1037
+ }],
1038
+ };
996
1039
  }
997
1040
  if (!existsSync(LOG_DIR)) {
998
1041
  return { content: [{ type: "text", text: `Log directory does not exist: ${LOG_DIR}` }] };
@@ -2451,17 +2494,22 @@ function cleanupPendingAction(actionId) {
2451
2494
  }
2452
2495
  }
2453
2496
  async function persistApprovalToolCall(opts) {
2497
+ // Write doctrine (Task 673): a ToolCall without its owning Conversation is
2498
+ // noise — every admin tool call belongs to a chat. When conversationId is
2499
+ // missing or its Conversation cannot be matched, we log and skip the
2500
+ // persist rather than create an orphan ToolCall node. The legacy
2501
+ // OPTIONAL-MATCH + FOREACH pattern created orphans silently.
2502
+ if (!opts.conversationId) {
2503
+ console.error(`[approval] ToolCall skipped (no conversationId): tool=${opts.toolName} state=${opts.approvalState}`);
2504
+ return;
2505
+ }
2454
2506
  const session = getSession();
2455
2507
  try {
2456
2508
  const optionalFields = [
2457
2509
  opts.originalInput != null ? ", originalInput: $originalInput" : "",
2458
- opts.conversationId != null ? ", conversationId: $conversationId" : "",
2459
2510
  ].join("");
2460
- // Link to Conversation node when conversationId is available
2461
- const conversationLink = opts.conversationId
2462
- ? `\nWITH tc\nOPTIONAL MATCH (c:Conversation {conversationId: $conversationId})\nFOREACH (_ IN CASE WHEN c IS NOT NULL THEN [1] ELSE [] END | CREATE (c)-[:HAS_TOOL_CALL]->(tc))`
2463
- : "";
2464
- await session.run(`CREATE (tc:ToolCall {
2511
+ const res = await session.run(`MATCH (c:Conversation {conversationId: $conversationId})
2512
+ CREATE (c)-[:HAS_TOOL_CALL]->(tc:ToolCall {
2465
2513
  callId: $callId,
2466
2514
  toolName: $toolName,
2467
2515
  pluginName: $pluginName,
@@ -2471,10 +2519,12 @@ async function persistApprovalToolCall(opts) {
2471
2519
  agentType: 'admin',
2472
2520
  accountId: $accountId,
2473
2521
  approvalState: $approvalState,
2522
+ conversationId: $conversationId,
2523
+ createdBySource: 'admin-approval',
2474
2524
  startedAt: datetime($startedAt),
2475
2525
  completedAt: datetime($completedAt)
2476
2526
  ${optionalFields}
2477
- })${conversationLink}`, {
2527
+ })`, {
2478
2528
  callId: randomUUID(),
2479
2529
  toolName: opts.toolName,
2480
2530
  pluginName: opts.pluginName,
@@ -2483,11 +2533,15 @@ async function persistApprovalToolCall(opts) {
2483
2533
  isError: opts.isError,
2484
2534
  accountId: ACCOUNT_ID,
2485
2535
  approvalState: opts.approvalState,
2536
+ conversationId: opts.conversationId,
2486
2537
  startedAt: new Date().toISOString(),
2487
2538
  completedAt: new Date().toISOString(),
2488
2539
  ...(opts.originalInput != null ? { originalInput: opts.originalInput.slice(0, 300) } : {}),
2489
- ...(opts.conversationId != null ? { conversationId: opts.conversationId } : {}),
2490
2540
  });
2541
+ if (res.summary.counters.updates().nodesCreated === 0) {
2542
+ console.error(`[approval] ToolCall skipped (conversation ${opts.conversationId} not found): tool=${opts.toolName}`);
2543
+ return;
2544
+ }
2491
2545
  console.error(`[approval] ToolCall persisted: ${opts.toolName} state=${opts.approvalState}`);
2492
2546
  }
2493
2547
  catch (err) {