@pasko70/pibo 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +20 -6
  2. package/compute-image/Dockerfile +47 -0
  3. package/compute-image/Dockerfile.dockerignore +5 -0
  4. package/dist/agent-runtime/context-build.js +26 -2
  5. package/dist/agent-runtime/contract.js +78 -28
  6. package/dist/agent-runtime/portable-history.js +19 -9
  7. package/dist/agent-runtime/registry.js +8 -2
  8. package/dist/agent-runtime/routed-session.js +120 -15
  9. package/dist/agent-runtimes/codex-native/adapter.js +12 -0
  10. package/dist/agent-runtimes/codex-native/resource-delivery.js +35 -7
  11. package/dist/agent-runtimes/omp/adapter.js +3 -7
  12. package/dist/agent-runtimes/pi/adapter.js +5 -3
  13. package/dist/agent-runtimes/pi/routed-session.js +59 -12
  14. package/dist/agent-runtimes/pi/runtime.js +6 -0
  15. package/dist/apps/chat/agent-profiles.js +31 -2
  16. package/dist/apps/chat/agent-store.js +98 -6
  17. package/dist/apps/chat/chat-request-normalizers.js +31 -0
  18. package/dist/apps/chat/data/chat-data-mappers.js +61 -11
  19. package/dist/apps/chat/data/project-service.js +119 -15
  20. package/dist/apps/chat/data/room-service.js +21 -0
  21. package/dist/apps/chat/data/session-query-service.js +33 -10
  22. package/dist/apps/chat/data/timeline-query-service.js +60 -35
  23. package/dist/apps/chat/loop-api.js +10 -2
  24. package/dist/apps/chat/output-compactor.js +144 -21
  25. package/dist/apps/chat/output-event-policy.js +179 -2
  26. package/dist/apps/chat/stream.js +16 -7
  27. package/dist/apps/chat/trace-v2.js +1 -0
  28. package/dist/apps/chat/web-app.js +317 -86
  29. package/dist/apps/chat-ui/assets/{dist-BDIATCQt.js → dist-CUQJqggN.js} +1 -1
  30. package/dist/apps/chat-ui/assets/{dist-mqCviI-c.js → dist-ClUlQWYN.js} +1 -1
  31. package/dist/apps/chat-ui/assets/{dist-JnW4v8UE.js → dist-Djul9BmZ.js} +1 -1
  32. package/dist/apps/chat-ui/assets/{dist-lRiLP9rr.js → dist-GD0JGdCi.js} +1 -1
  33. package/dist/apps/chat-ui/assets/{dist-BBDMeU5-.js → dist-W5HOqHym.js} +1 -1
  34. package/dist/apps/chat-ui/assets/index-BcjkX-iP.js +228 -0
  35. package/dist/apps/chat-ui/assets/index-CmqRSbBU.css +1 -0
  36. package/dist/apps/chat-ui/index.html +2 -2
  37. package/dist/apps/chat-vscode-web/assets/index-JvrPUGvI.js +43 -0
  38. package/dist/apps/chat-vscode-web/index.html +1 -1
  39. package/dist/apps/cli-ui/InkSessionApp.js +111 -12
  40. package/dist/apps/cli-ui/cliSessionsCommand.js +34 -8
  41. package/dist/cli-session/localSessionSource.js +153 -161
  42. package/dist/cli.js +7 -11
  43. package/dist/compute/cli.js +35 -16
  44. package/dist/compute/docker.js +341 -94
  45. package/dist/config/config.js +3 -0
  46. package/dist/core/context-build.js +7 -16
  47. package/dist/core/output-persistence-retry.js +484 -0
  48. package/dist/core/output-render-sequence.js +331 -0
  49. package/dist/core/profiles.js +24 -0
  50. package/dist/core/provider-recovery.js +7 -1
  51. package/dist/core/session-model.js +37 -1
  52. package/dist/core/session-router.js +78 -110
  53. package/dist/data/ingest-service.js +91 -9
  54. package/dist/data/navigation-store.js +5 -5
  55. package/dist/data/pibo-store.js +8 -1
  56. package/dist/data/schema.js +243 -3
  57. package/dist/data/session-store.js +3 -3
  58. package/dist/debug/agents.js +91 -89
  59. package/dist/debug/index.js +46 -2
  60. package/dist/debug/pty.js +127 -48
  61. package/dist/gateway/cli.js +4 -0
  62. package/dist/gateway/web.js +8 -8
  63. package/dist/local/client.js +3 -2
  64. package/dist/loops/channel.js +3 -1
  65. package/dist/loops/cli.js +2 -1
  66. package/dist/loops/service.js +90 -11
  67. package/dist/loops/store.js +100 -74
  68. package/dist/mcp/agent-context.js +13 -26
  69. package/dist/mcp/commands/info.js +3 -1
  70. package/dist/mcp/config-command.js +46 -2
  71. package/dist/mcp/config.js +18 -4
  72. package/dist/plugins/context-files-store.js +36 -6
  73. package/dist/plugins/context-files.js +52 -1
  74. package/dist/plugins/registry.js +2 -0
  75. package/dist/plugins/user-profile-resources.js +22 -0
  76. package/dist/previews/base-url.js +34 -0
  77. package/dist/previews/config.js +2 -32
  78. package/dist/ralph/store.js +5 -0
  79. package/dist/reliability/store.js +378 -106
  80. package/dist/session-ui/terminalRows.js +8 -4
  81. package/dist/sessions/pibo-data-store.js +145 -0
  82. package/dist/sessions/store.js +42 -0
  83. package/dist/setup/cli.js +422 -6
  84. package/dist/setup/installation-profiles.js +464 -0
  85. package/dist/shared/deterministic-digest.js +94 -0
  86. package/dist/shared/trace-engine.js +54 -0
  87. package/dist/shared/trace-event-projection.js +124 -67
  88. package/dist/shared/trace-history.js +47 -12
  89. package/dist/shared/trace-live-reducer.js +23 -4
  90. package/dist/shared/trace-nodes.js +17 -28
  91. package/dist/shared/trace-order.js +41 -5
  92. package/dist/shared/trace-page-merge.js +44 -14
  93. package/dist/shared/trace-patch-nodes.js +2 -0
  94. package/dist/shared/trace-subagent-links.js +4 -1
  95. package/dist/shared/trace-tool-identity.js +15 -8
  96. package/dist/signals/projector.js +28 -6
  97. package/dist/subagents/observation-query.js +121 -0
  98. package/dist/subagents/tool.js +7 -3
  99. package/dist/tools/agent-browser-leases.js +58 -23
  100. package/dist/tools/agent-browser-wrapper.js +1 -1
  101. package/dist/tools/browser-use-leases.js +129 -16
  102. package/dist/tools/browser-use-wrapper.js +1 -1
  103. package/dist/tools/index.js +27 -11
  104. package/dist/tools/python-runtime.js +10 -3
  105. package/dist/tools/registry.js +1 -1
  106. package/dist/tools/runtime/node-backend.js +16 -5
  107. package/dist/tools/runtime/node-worker-source.js +112 -33
  108. package/dist/tools/runtime/python-backend.js +16 -5
  109. package/dist/tools/runtime/python-worker-source.js +167 -16
  110. package/dist/tools/runtime/registry.js +23 -10
  111. package/dist/user-skills/store.js +0 -1
  112. package/docs/ops/vscode-extension-release.md +9 -1
  113. package/npm-shrinkwrap.json +9 -2
  114. package/package.json +9 -2
  115. package/scripts/docker-entrypoint.sh +46 -0
  116. package/scripts/prepare-agent-browser-wrapper.sh +70 -0
  117. package/scripts/prepare-browser-use-wrapper.sh +255 -0
  118. package/dist/apps/chat-ui/assets/index-0XOOVxLP.js +0 -228
  119. package/dist/apps/chat-ui/assets/index-DV7_CgsC.css +0 -1
  120. package/dist/apps/chat-vscode-web/assets/index-Dtw2Z7jz.js +0 -43
  121. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  122. package/dist/apps/vscode-artifacts/pibo-vscode-ext-3.0.1.vsix +0 -0
@@ -6,6 +6,17 @@ import { DatabaseSync } from "node:sqlite";
6
6
  import { DEFAULT_AGENT_RUNTIME_INSTANCE_ID, DEFAULT_BUILTIN_TOOL_NAMES } from "../../core/profiles.js";
7
7
  import { isPiboThinkingLevel } from "../../core/thinking.js";
8
8
  import { findPiPackage } from "../../pi-packages/store.js";
9
+ export class CustomAgentTargetReferenceError extends Error {
10
+ targetProfileName;
11
+ dependentProfileNames;
12
+ constructor(targetProfileName, dependentProfileNames) {
13
+ const sortedNames = [...dependentProfileNames].sort((left, right) => left.localeCompare(right));
14
+ super(`Custom agent "${targetProfileName}" is targeted by custom agents: ${sortedNames.join(", ")}`);
15
+ this.name = "CustomAgentTargetReferenceError";
16
+ this.targetProfileName = targetProfileName;
17
+ this.dependentProfileNames = sortedNames;
18
+ }
19
+ }
9
20
  const CUSTOM_AGENT_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
10
21
  export function previewCustomAgentCreate(input, options = {}) {
11
22
  const now = options.now ?? new Date().toISOString();
@@ -27,6 +38,7 @@ export function previewCustomAgentCreate(input, options = {}) {
27
38
  mcpServers: uniqueStrings(input.mcpServers ?? []),
28
39
  piPackages: sanitizePiPackages(input.piPackages ?? []),
29
40
  mainModel: sanitizeModelProfile(input.mainModel),
41
+ mainModelFallbacks: sanitizeModelFallbacks(input.mainModelFallbacks ?? [], input.mainModel),
30
42
  subagentModel: sanitizeModelProfile(input.subagentModel),
31
43
  thinkingLevel: sanitizeThinkingLevel(input.thinkingLevel),
32
44
  mainThinkingLevel: sanitizeThinkingLevel(input.mainThinkingLevel),
@@ -61,6 +73,7 @@ export function previewCustomAgentUpdate(existing, input, options = {}) {
61
73
  mcpServers: input.mcpServers ? uniqueStrings(input.mcpServers) : existing.mcpServers,
62
74
  piPackages: input.piPackages ? sanitizePiPackages(input.piPackages) : existing.piPackages,
63
75
  mainModel: input.mainModel === undefined ? existing.mainModel : sanitizeModelProfile(input.mainModel),
76
+ mainModelFallbacks: sanitizeModelFallbacks(input.mainModelFallbacks ?? existing.mainModelFallbacks, input.mainModel === undefined ? existing.mainModel : input.mainModel),
64
77
  subagentModel: input.subagentModel === undefined ? existing.subagentModel : sanitizeModelProfile(input.subagentModel),
65
78
  thinkingLevel: input.thinkingLevel === undefined ? existing.thinkingLevel : sanitizeThinkingLevel(input.thinkingLevel),
66
79
  mainThinkingLevel: input.mainThinkingLevel === undefined ? existing.mainThinkingLevel : sanitizeThinkingLevel(input.mainThinkingLevel),
@@ -113,6 +126,7 @@ export class CustomAgentStore {
113
126
  mcp_servers_json TEXT NOT NULL DEFAULT '[]',
114
127
  pi_packages_json TEXT NOT NULL DEFAULT '[]',
115
128
  main_model_json TEXT,
129
+ main_model_fallbacks_json TEXT NOT NULL DEFAULT '[]',
116
130
  subagent_model_json TEXT,
117
131
  thinking_level TEXT,
118
132
  main_thinking_level TEXT,
@@ -140,6 +154,7 @@ export class CustomAgentStore {
140
154
  this.migrateRuntimeColumns();
141
155
  this.migratePiPackagesColumn();
142
156
  this.migrateModelColumns();
157
+ this.migrateModelFallbackColumns();
143
158
  this.migrateThinkingLevelColumn();
144
159
  this.migrateThinkingOptionColumns();
145
160
  this.migrateBuiltinToolNamesColumn();
@@ -230,6 +245,7 @@ export class CustomAgentStore {
230
245
  mcp_servers_json = ?,
231
246
  pi_packages_json = ?,
232
247
  main_model_json = ?,
248
+ main_model_fallbacks_json = ?,
233
249
  subagent_model_json = ?,
234
250
  thinking_level = ?,
235
251
  main_thinking_level = ?,
@@ -245,7 +261,7 @@ export class CustomAgentStore {
245
261
  updated_at = ?
246
262
  WHERE id = ?
247
263
  `)
248
- .run(updated.profileName, updated.displayName, updated.description ?? null, updated.folderId ?? null, updated.runtimeInstanceId, JSON.stringify(updated.runtimeOptions), serializeBoolean(updated.nativeSubagents), JSON.stringify(updated.nativeTools), JSON.stringify(updated.skills), JSON.stringify(updated.contextFiles), JSON.stringify(sanitizeSubagents(updated.subagents)), JSON.stringify(updated.mcpServers), JSON.stringify(updated.piPackages), updated.mainModel ? JSON.stringify(updated.mainModel) : null, updated.subagentModel ? JSON.stringify(updated.subagentModel) : null, updated.thinkingLevel ?? null, updated.mainThinkingLevel ?? null, updated.subagentThinkingLevel ?? null, serializeBoolean(updated.fast), serializeBoolean(updated.mainFast), serializeBoolean(updated.subagentFast), updated.builtinTools, JSON.stringify(updated.builtinToolNames), updated.autoContextFiles ? 1 : 0, updated.runControl ? 1 : 0, updated.goalControl ? 1 : 0, updated.updatedAt, id);
264
+ .run(updated.profileName, updated.displayName, updated.description ?? null, updated.folderId ?? null, updated.runtimeInstanceId, JSON.stringify(updated.runtimeOptions), serializeBoolean(updated.nativeSubagents), JSON.stringify(updated.nativeTools), JSON.stringify(updated.skills), JSON.stringify(updated.contextFiles), JSON.stringify(sanitizeSubagents(updated.subagents)), JSON.stringify(updated.mcpServers), JSON.stringify(updated.piPackages), updated.mainModel ? JSON.stringify(updated.mainModel) : null, JSON.stringify(updated.mainModelFallbacks), updated.subagentModel ? JSON.stringify(updated.subagentModel) : null, updated.thinkingLevel ?? null, updated.mainThinkingLevel ?? null, updated.subagentThinkingLevel ?? null, serializeBoolean(updated.fast), serializeBoolean(updated.mainFast), serializeBoolean(updated.subagentFast), updated.builtinTools, JSON.stringify(updated.builtinToolNames), updated.autoContextFiles ? 1 : 0, updated.runControl ? 1 : 0, updated.goalControl ? 1 : 0, updated.updatedAt, id);
249
265
  return this.get(id);
250
266
  }
251
267
  setArchived(id, archived) {
@@ -259,10 +275,42 @@ export class CustomAgentStore {
259
275
  .run(archivedAt, new Date().toISOString(), id);
260
276
  return this.get(id);
261
277
  }
278
+ listReferencingAgents(id) {
279
+ this.migrateLegacyProfileNames();
280
+ const target = this.get(id);
281
+ if (!target)
282
+ return [];
283
+ const targetNames = new Set([
284
+ target.profileName,
285
+ target.id,
286
+ `custom-agent:${target.id}`,
287
+ ...target.profileAliases,
288
+ ]);
289
+ return this.list({ includeArchived: true })
290
+ .filter((agent) => agent.id !== id && agent.subagents.some((subagent) => targetNames.has(subagent.targetProfile)))
291
+ .sort((left, right) => left.profileName.localeCompare(right.profileName));
292
+ }
262
293
  delete(id) {
263
- this.db.prepare("DELETE FROM chat_agent_profile_aliases WHERE agent_id = ?").run(id);
264
- const result = this.db.prepare("DELETE FROM chat_agents WHERE id = ?").run(id);
265
- return Number(result.changes ?? 0) > 0;
294
+ this.db.exec("BEGIN IMMEDIATE");
295
+ try {
296
+ const target = this.get(id);
297
+ if (!target) {
298
+ this.db.exec("COMMIT");
299
+ return false;
300
+ }
301
+ const dependents = this.listReferencingAgents(id);
302
+ if (dependents.length > 0) {
303
+ throw new CustomAgentTargetReferenceError(target.profileName, dependents.map((agent) => agent.profileName));
304
+ }
305
+ this.db.prepare("DELETE FROM chat_agent_profile_aliases WHERE agent_id = ?").run(id);
306
+ const result = this.db.prepare("DELETE FROM chat_agents WHERE id = ?").run(id);
307
+ this.db.exec("COMMIT");
308
+ return Number(result.changes ?? 0) > 0;
309
+ }
310
+ catch (error) {
311
+ this.db.exec("ROLLBACK");
312
+ throw error;
313
+ }
266
314
  }
267
315
  close() {
268
316
  this.db.close();
@@ -286,6 +334,7 @@ export class CustomAgentStore {
286
334
  mcp_servers_json,
287
335
  pi_packages_json,
288
336
  main_model_json,
337
+ main_model_fallbacks_json,
289
338
  subagent_model_json,
290
339
  thinking_level,
291
340
  main_thinking_level,
@@ -301,9 +350,9 @@ export class CustomAgentStore {
301
350
  created_at,
302
351
  updated_at,
303
352
  archived_at
304
- ) VALUES (${Array.from({ length: 30 }, () => "?").join(", ")})
353
+ ) VALUES (${Array.from({ length: 31 }, () => "?").join(", ")})
305
354
  `)
306
- .run(agent.id, agent.profileName, agent.displayName, agent.description ?? null, agent.folderId ?? null, agent.runtimeInstanceId, JSON.stringify(agent.runtimeOptions), serializeBoolean(agent.nativeSubagents), JSON.stringify(agent.nativeTools), JSON.stringify(agent.skills), JSON.stringify(agent.contextFiles), JSON.stringify(sanitizeSubagents(agent.subagents)), JSON.stringify(agent.mcpServers), JSON.stringify(agent.piPackages), agent.mainModel ? JSON.stringify(agent.mainModel) : null, agent.subagentModel ? JSON.stringify(agent.subagentModel) : null, agent.thinkingLevel ?? null, agent.mainThinkingLevel ?? null, agent.subagentThinkingLevel ?? null, serializeBoolean(agent.fast), serializeBoolean(agent.mainFast), serializeBoolean(agent.subagentFast), agent.builtinTools, JSON.stringify(agent.builtinToolNames), agent.autoContextFiles ? 1 : 0, agent.runControl ? 1 : 0, agent.goalControl ? 1 : 0, agent.createdAt, agent.updatedAt, agent.archivedAt ?? null);
355
+ .run(agent.id, agent.profileName, agent.displayName, agent.description ?? null, agent.folderId ?? null, agent.runtimeInstanceId, JSON.stringify(agent.runtimeOptions), serializeBoolean(agent.nativeSubagents), JSON.stringify(agent.nativeTools), JSON.stringify(agent.skills), JSON.stringify(agent.contextFiles), JSON.stringify(sanitizeSubagents(agent.subagents)), JSON.stringify(agent.mcpServers), JSON.stringify(agent.piPackages), agent.mainModel ? JSON.stringify(agent.mainModel) : null, JSON.stringify(agent.mainModelFallbacks), agent.subagentModel ? JSON.stringify(agent.subagentModel) : null, agent.thinkingLevel ?? null, agent.mainThinkingLevel ?? null, agent.subagentThinkingLevel ?? null, serializeBoolean(agent.fast), serializeBoolean(agent.mainFast), serializeBoolean(agent.subagentFast), agent.builtinTools, JSON.stringify(agent.builtinToolNames), agent.autoContextFiles ? 1 : 0, agent.runControl ? 1 : 0, agent.goalControl ? 1 : 0, agent.createdAt, agent.updatedAt, agent.archivedAt ?? null);
307
356
  }
308
357
  requireFolderExists(folderId) {
309
358
  if (folderId === undefined || folderId === null)
@@ -363,6 +412,15 @@ export class CustomAgentStore {
363
412
  .prepare("UPDATE chat_agents SET profile_name = ?, display_name = ? WHERE id = ?")
364
413
  .run(nextName, nextName, row.id);
365
414
  }
415
+ this.db.prepare(`
416
+ DELETE FROM chat_agent_profile_aliases
417
+ WHERE EXISTS (
418
+ SELECT 1
419
+ FROM chat_agents
420
+ WHERE chat_agents.profile_name = chat_agent_profile_aliases.old_profile_name
421
+ AND chat_agents.id != chat_agent_profile_aliases.agent_id
422
+ )
423
+ `).run();
366
424
  }
367
425
  migrateAgentFolderColumn() {
368
426
  if (!this.tableColumns().has("folder_id")) {
@@ -458,6 +516,12 @@ export class CustomAgentStore {
458
516
  this.db.prepare("ALTER TABLE chat_agents ADD COLUMN subagent_model_json TEXT").run();
459
517
  }
460
518
  }
519
+ migrateModelFallbackColumns() {
520
+ const columns = new Set(this.db.prepare("PRAGMA table_info(chat_agents)").all().map((column) => column.name));
521
+ if (!columns.has("main_model_fallbacks_json")) {
522
+ this.db.prepare("ALTER TABLE chat_agents ADD COLUMN main_model_fallbacks_json TEXT NOT NULL DEFAULT '[]'").run();
523
+ }
524
+ }
461
525
  migrateThinkingLevelColumn() {
462
526
  const columns = new Set(this.db.prepare("PRAGMA table_info(chat_agents)").all().map((column) => column.name));
463
527
  if (!columns.has("thinking_level")) {
@@ -649,6 +713,7 @@ function agentFromRow(row, profileAliases) {
649
713
  mcpServers: parseStringArray(row.mcp_servers_json),
650
714
  piPackages: parseStringArray(row.pi_packages_json),
651
715
  mainModel: parseModelProfile(row.main_model_json),
716
+ mainModelFallbacks: parseModelFallbacks(row.main_model_fallbacks_json, row.main_model_json),
652
717
  subagentModel: parseModelProfile(row.subagent_model_json),
653
718
  thinkingLevel: sanitizeThinkingLevel(row.thinking_level),
654
719
  mainThinkingLevel: sanitizeThinkingLevel(row.main_thinking_level),
@@ -774,6 +839,30 @@ function sanitizeModelProfile(value) {
774
839
  return undefined;
775
840
  return { provider, id };
776
841
  }
842
+ function parseModelFallbacks(value, primaryValue) {
843
+ try {
844
+ return sanitizeModelFallbacks(JSON.parse(value), parseModelProfile(primaryValue));
845
+ }
846
+ catch {
847
+ return [];
848
+ }
849
+ }
850
+ function sanitizeModelFallbacks(value, primary) {
851
+ if (!Array.isArray(value))
852
+ return [];
853
+ const primaryModel = sanitizeModelProfile(primary);
854
+ const seen = new Set(primaryModel ? [`${primaryModel.provider}\u0000${primaryModel.id}`] : []);
855
+ return value.flatMap((item) => {
856
+ const model = sanitizeModelProfile(item);
857
+ if (!model)
858
+ return [];
859
+ const key = `${model.provider}\u0000${model.id}`;
860
+ if (seen.has(key))
861
+ return [];
862
+ seen.add(key);
863
+ return [model];
864
+ });
865
+ }
777
866
  function sanitizeSubagents(value) {
778
867
  return value.flatMap((item) => {
779
868
  if (!item || typeof item !== "object" || Array.isArray(item))
@@ -790,6 +879,9 @@ function sanitizeSubagents(value) {
790
879
  const model = sanitizeModelProfile(candidate.model);
791
880
  if (model)
792
881
  subagent.model = model;
882
+ const modelFallbacks = sanitizeModelFallbacks(candidate.modelFallbacks, model);
883
+ if (modelFallbacks.length > 0)
884
+ subagent.modelFallbacks = modelFallbacks;
793
885
  const thinkingLevel = sanitizeThinkingLevel(candidate.thinkingLevel);
794
886
  if (thinkingLevel)
795
887
  subagent.thinkingLevel = thinkingLevel;
@@ -304,6 +304,23 @@ export function normalizeModelProfile(value, fieldName) {
304
304
  }
305
305
  return { provider, id };
306
306
  }
307
+ export function normalizeModelFallbacks(value, fieldName, primary) {
308
+ if (value === undefined || value === null)
309
+ return [];
310
+ if (!Array.isArray(value))
311
+ throw new PiboWebHttpError(`${fieldName} must be an array`, 400);
312
+ const seen = new Set(primary ? [`${primary.provider}\u0000${primary.id}`] : []);
313
+ return value.flatMap((item, index) => {
314
+ const model = normalizeModelProfile(item, `${fieldName}[${index}]`);
315
+ if (!model)
316
+ throw new PiboWebHttpError(`${fieldName}[${index}] must define a provider and model`, 400);
317
+ const key = `${model.provider}\u0000${model.id}`;
318
+ if (seen.has(key))
319
+ return [];
320
+ seen.add(key);
321
+ return [model];
322
+ });
323
+ }
307
324
  export function normalizeMcpServerDescriptionBody(value) {
308
325
  if (typeof value !== "string")
309
326
  throw new PiboWebHttpError("MCP server description must be a string", 400);
@@ -404,6 +421,9 @@ export function normalizeAgentSubagents(value) {
404
421
  const model = normalizeModelProfile(raw.model, "subagent model");
405
422
  if (model)
406
423
  subagent.model = model;
424
+ const modelFallbacks = normalizeModelFallbacks(raw.modelFallbacks, "subagent modelFallbacks", model);
425
+ if (modelFallbacks.length > 0)
426
+ subagent.modelFallbacks = modelFallbacks;
407
427
  const thinkingLevel = normalizeThinkingLevel(raw.thinkingLevel, "subagent thinkingLevel");
408
428
  if (thinkingLevel)
409
429
  subagent.thinkingLevel = thinkingLevel;
@@ -529,6 +549,13 @@ export function normalizeStreamingFixtureTraceSnapshots(value) {
529
549
  return value;
530
550
  throw new PiboWebHttpError("traceSnapshots must be a boolean", 400);
531
551
  }
552
+ export function normalizeStreamingFixtureToolInterleaving(value) {
553
+ if (value === undefined)
554
+ return false;
555
+ if (typeof value === "boolean")
556
+ return value;
557
+ throw new PiboWebHttpError("toolInterleaving must be a boolean", 400);
558
+ }
532
559
  export function normalizeStreamingFixtureSuppressLiveDeltas(value) {
533
560
  if (value === undefined)
534
561
  return false;
@@ -702,6 +729,7 @@ export function createAgentInput(body) {
702
729
  mcpServers: normalizeNameArray(body.mcpServers, "mcpServers"),
703
730
  piPackages: normalizeRegisteredPiPackages(body.piPackages),
704
731
  mainModel: normalizeModelProfile(body.mainModel, "mainModel"),
732
+ mainModelFallbacks: normalizeModelFallbacks(body.mainModelFallbacks, "mainModelFallbacks", normalizeModelProfile(body.mainModel, "mainModel")),
705
733
  subagentModel: normalizeModelProfile(body.subagentModel, "subagentModel"),
706
734
  thinkingLevel: normalizeThinkingLevel(body.thinkingLevel, "thinkingLevel"),
707
735
  mainThinkingLevel: normalizeThinkingLevel(body.mainThinkingLevel, "mainThinkingLevel"),
@@ -748,6 +776,9 @@ export function createAgentUpdate(body) {
748
776
  if (body.mainModel !== undefined) {
749
777
  update.mainModel = body.mainModel === null ? null : normalizeModelProfile(body.mainModel, "mainModel");
750
778
  }
779
+ if (body.mainModelFallbacks !== undefined) {
780
+ update.mainModelFallbacks = normalizeModelFallbacks(body.mainModelFallbacks, "mainModelFallbacks", update.mainModel ?? undefined);
781
+ }
751
782
  if (body.subagentModel !== undefined) {
752
783
  update.subagentModel = body.subagentModel === null ? null : normalizeModelProfile(body.subagentModel, "subagentModel");
753
784
  }
@@ -1,6 +1,7 @@
1
1
  import { normalizeSessionErrorDetails } from "../../../core/session-errors.js";
2
2
  import { roomWorkspaceFromMetadata } from "../types/rooms.js";
3
3
  import { tracePayloadRefForStoredPayload } from "../trace-v2.js";
4
+ import { qualifiedToolNodeId } from "../../../shared/trace-tool-identity.js";
4
5
  const MAX_TRACE_EVENT_HYDRATION_BYTES = 1024 * 1024;
5
6
  export function storedPiboEventFromV2Row(row, payloadStore) {
6
7
  const attributes = parseJsonObject(row.attributes_json);
@@ -16,7 +17,8 @@ export function storedPiboEventFromV2Row(row, payloadStore) {
16
17
  ...payloadIdentity,
17
18
  })
18
19
  : undefined;
19
- return { id: String(row.stream_id), piboSessionId: row.session_id ?? undefined, eventSequence: row.session_sequence ?? undefined, eventId: row.event_id ?? undefined, streamId: row.stream_id, storedPayloadRef, type: row.type, createdAt: row.created_at, payload };
20
+ const renderSequence = numberAttribute(attributes, "renderSequence") ?? payload.renderSequence;
21
+ return { id: String(row.stream_id), piboSessionId: row.session_id ?? undefined, eventSequence: row.session_sequence ?? undefined, renderSequence, eventId: row.event_id ?? undefined, streamId: row.stream_id, storedPayloadRef, type: row.type, createdAt: row.created_at, payload };
20
22
  }
21
23
  export function storedChatEventFromV2Row(row, payloadStore) {
22
24
  const attributes = parseJsonObject(row.attributes_json);
@@ -24,12 +26,22 @@ export function storedChatEventFromV2Row(row, payloadStore) {
24
26
  }
25
27
  function outputPayloadFromV2Row(row, attributes, persistedPayload) {
26
28
  const inlinePayload = attributes.inlinePayload ?? persistedPayload;
27
- if (inlinePayload && typeof inlinePayload === "object" && !Array.isArray(inlinePayload) && typeof inlinePayload.type === "string")
28
- return inlinePayload;
29
+ if (inlinePayload && typeof inlinePayload === "object" && !Array.isArray(inlinePayload) && typeof inlinePayload.type === "string") {
30
+ return compactObject({
31
+ ...inlinePayload,
32
+ renderSequence: numberAttribute(attributes, "renderSequence") ?? inlinePayload.renderSequence,
33
+ });
34
+ }
29
35
  const piboSessionId = row.session_id;
30
36
  if (!piboSessionId)
31
37
  return undefined;
32
- const base = compactObject({ piboSessionId, eventId: row.event_id ?? undefined });
38
+ const eventIdentityScoped = typeof attributes.eventIdentityScoped === "boolean"
39
+ ? attributes.eventIdentityScoped
40
+ : undefined;
41
+ const eventId = eventIdentityScoped === false
42
+ ? undefined
43
+ : stringAttribute(attributes, "semanticEventId") ?? row.event_id ?? undefined;
44
+ const base = compactObject({ piboSessionId, eventId, renderSequence: numberAttribute(attributes, "renderSequence"), toolInvocationOrdinal: numberAttribute(attributes, "toolInvocationOrdinal") });
33
45
  if (row.type === "assistant_message" || row.type === "assistant_delta") {
34
46
  return compactObject({
35
47
  ...base,
@@ -44,9 +56,32 @@ function outputPayloadFromV2Row(row, attributes, persistedPayload) {
44
56
  if (row.type === "message_steered")
45
57
  return { ...base, type: "message_steered", text: stringAttribute(attributes, "inlineText") ?? row.preview_text ?? "", source: stringAttribute(attributes, "source") ?? "user", activeEventId: stringAttribute(attributes, "activeEventId") };
46
58
  if (row.type === "message_started")
47
- return { ...base, type: "message_started", text: row.preview_text ?? "" };
59
+ return compactObject({
60
+ ...base,
61
+ type: "message_started",
62
+ text: stringAttribute(attributes, "inlineText") ?? row.preview_text ?? "",
63
+ source: stringAttribute(attributes, "source"),
64
+ });
48
65
  if (row.type === "message_finished")
49
- return { ...base, type: "message_finished" };
66
+ return compactObject({
67
+ ...base,
68
+ type: "message_finished",
69
+ source: stringAttribute(attributes, "source"),
70
+ });
71
+ if (row.type === "assistant_usage") {
72
+ return compactObject({
73
+ ...base,
74
+ type: "assistant_usage",
75
+ usageIndex: numberAttribute(attributes, "usageIndex"),
76
+ inputTokens: numberAttribute(attributes, "inputTokens"),
77
+ outputTokens: numberAttribute(attributes, "outputTokens"),
78
+ cacheReadTokens: numberAttribute(attributes, "cacheReadTokens"),
79
+ cacheWriteTokens: numberAttribute(attributes, "cacheWriteTokens"),
80
+ reasoningTokens: numberAttribute(attributes, "reasoningTokens"),
81
+ totalTokens: numberAttribute(attributes, "totalTokens") ?? 0,
82
+ costUsd: numberAttribute(attributes, "costUsd"),
83
+ });
84
+ }
50
85
  if (row.type === "thinking_started" || row.type === "thinking_delta" || row.type === "thinking_finished") {
51
86
  return compactObject({
52
87
  ...base,
@@ -74,6 +109,10 @@ function outputPayloadFromV2Row(row, attributes, persistedPayload) {
74
109
  }
75
110
  if (row.type === "execution_result")
76
111
  return { ...base, type: "execution_result", action: row.preview_text ?? stringAttribute(attributes, "action") ?? "execution", result: inlinePayload ?? null };
112
+ if (row.type === "compaction_start")
113
+ return { ...base, type: "compaction_start", compactionIndex: numberAttribute(attributes, "compactionIndex"), reason: stringAttribute(attributes, "reason") ?? row.preview_text ?? "unknown" };
114
+ if (row.type === "compaction_end")
115
+ return { ...base, type: "compaction_end", compactionIndex: numberAttribute(attributes, "compactionIndex"), reason: stringAttribute(attributes, "reason") ?? row.preview_text ?? "unknown", result: inlinePayload, aborted: booleanAttribute(attributes, "aborted") ?? false, errorMessage: stringAttribute(attributes, "errorMessage") };
77
116
  if (row.type === "session_error") {
78
117
  const error = stringAttribute(attributes, "error") ?? row.preview_text ?? "Error";
79
118
  return { ...base, type: "session_error", error, errorDetails: normalizeSessionErrorDetails(error, isRecord(attributes.errorDetails) ? attributes.errorDetails : undefined) };
@@ -100,10 +139,15 @@ function tracePayloadIdentityForEvent(row, attributes) {
100
139
  const toolCallId = stringAttribute(attributes, "toolCallId");
101
140
  if (!toolCallId)
102
141
  return undefined;
142
+ const eventId = stringAttribute(attributes, "semanticEventId") ?? row.event_id;
143
+ if (!eventId)
144
+ return undefined;
145
+ const ordinal = numberAttribute(attributes, "toolInvocationOrdinal") ?? 0;
146
+ const nodeId = qualifiedToolNodeId(toolCallId, eventId, ordinal);
103
147
  if (row.type === "tool_call" || row.type === "tool_execution_started")
104
- return { nodeId: `tool:${toolCallId}`, payloadKind: "input" };
148
+ return { nodeId, payloadKind: "input" };
105
149
  if (row.type === "tool_execution_updated" || row.type === "tool_execution_finished")
106
- return { nodeId: `tool:${toolCallId}`, payloadKind: "output" };
150
+ return { nodeId, payloadKind: "output" };
107
151
  return undefined;
108
152
  }
109
153
  export function sessionFromRow(row) { return { piboSessionId: row.id, piSessionId: row.pi_session_id ?? "", runtimeInstanceId: row.runtime_instance_id ?? undefined, runtimeAdapterId: row.runtime_adapter_id ?? undefined, runtimeBindingState: isRuntimeBindingState(row.binding_state) ? row.binding_state : undefined, nativeSessionId: row.native_session_id ?? undefined, parentId: row.parent_id ?? undefined, profile: row.profile, channel: row.channel, kind: row.kind, createdAt: row.created_at, updatedAt: row.updated_at, lastActivityAt: row.last_activity_at, status: row.status === "running" || row.status === "error" ? row.status : "idle" }; }
@@ -124,9 +168,15 @@ export function compactObject(value) { return Object.fromEntries(Object.entries(
124
168
  export function previewForPayload(payload) { if (typeof payload === "object" && payload && "text" in payload && typeof payload.text === "string")
125
169
  return payload.text.slice(0, 512); if (typeof payload === "string")
126
170
  return payload.slice(0, 512); return undefined; }
127
- export function statusFromOutputEvent(event) { if (event.type === "session_error")
128
- return "error"; if (event.type === "message_finished")
129
- return "idle"; return "running"; }
171
+ export function statusFromOutputEvent(event) {
172
+ if (event.type === "session_error")
173
+ return "error";
174
+ if (event.type === "message_finished")
175
+ return "idle";
176
+ if (event.type === "message_queued" || event.type === "message_steered" || event.type === "message_started")
177
+ return "running";
178
+ return undefined;
179
+ }
130
180
  function isRuntimeBindingState(value) { return value === "unbound" || value === "bound" || value === "missing" || value === "error"; }
131
181
  function actorTypeValue(value) { return value === "user" || value === "assistant" || value === "system" || value === "agent" ? value : undefined; }
132
182
  function retentionClassValue(value) { return value === "live_delta" || value === "chat_message" || value === "audit_event" ? value : "trace_event"; }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdirSync, rmSync, statSync } from "node:fs";
3
- import { dirname, isAbsolute, resolve } from "node:path";
2
+ import { mkdirSync, realpathSync, rmSync, statSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  import { piboHomePath } from "../../../core/pibo-home.js";
6
6
  export class ChatProjectService {
@@ -72,7 +72,7 @@ export class ChatProjectService {
72
72
  const projectFolder = resolve(normalizeProjectFolder(input.projectFolder));
73
73
  this.assertNameAvailable(name);
74
74
  this.assertFolderAvailable(projectFolder);
75
- this.ensureProjectFolderUsable(projectFolder);
75
+ this.ensureProjectFolderUsable(projectFolder, input.createFolder === true);
76
76
  const now = new Date().toISOString();
77
77
  const id = `prj_${randomUUID()}`;
78
78
  this.db.prepare(`INSERT INTO projects (id, name, description, project_folder, configuration_status, metadata_json, created_at, updated_at)
@@ -93,16 +93,26 @@ export class ChatProjectService {
93
93
  return this.getProject(id, { includeArchived: true });
94
94
  }
95
95
  deleteProject(id, options) {
96
- const project = this.requireProject(id, { includeArchived: true });
97
- if (!project.archivedAt)
98
- throw new Error("Archive the project before permanently deleting it.");
99
- if (options.confirmName !== project.name)
100
- throw new Error(`Type \"${project.name}\" to permanently delete this project.`);
101
- this.db.prepare("DELETE FROM project_sessions WHERE project_id = ?").run(id);
102
- this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
103
- if (options.deleteFiles)
104
- rmSync(project.projectFolder, { recursive: true, force: true });
105
- return { deletedProjectId: id };
96
+ this.db.exec("BEGIN IMMEDIATE");
97
+ try {
98
+ const project = this.requireProject(id, { includeArchived: true });
99
+ if (!project.archivedAt)
100
+ throw new Error("Archive the project before permanently deleting it.");
101
+ if (options.confirmName !== project.name)
102
+ throw new Error(`Type \"${project.name}\" to permanently delete this project.`);
103
+ if (options.deleteFiles)
104
+ this.assertFolderDoesNotOverlapAnotherProject(project);
105
+ this.db.prepare("DELETE FROM project_sessions WHERE project_id = ?").run(id);
106
+ this.db.prepare("DELETE FROM projects WHERE id = ?").run(id);
107
+ if (options.deleteFiles)
108
+ rmSync(project.projectFolder, { recursive: true, force: true });
109
+ this.db.exec("COMMIT");
110
+ return { deletedProjectId: id };
111
+ }
112
+ catch (error) {
113
+ this.db.exec("ROLLBACK");
114
+ throw error;
115
+ }
106
116
  }
107
117
  listProjectSessions(projectId, options = {}) {
108
118
  const rows = this.db.prepare(`SELECT * FROM project_sessions WHERE project_id = ? ${options.includeArchived ? "" : "AND archived = 0"} ORDER BY created_at`).all(projectId);
@@ -112,6 +122,60 @@ export class ChatProjectService {
112
122
  const row = this.db.prepare("SELECT * FROM project_sessions WHERE pibo_session_id = ?").get(piboSessionId);
113
123
  return row ? projectSessionFromRow(row) : undefined;
114
124
  }
125
+ deleteProjectSessions(piboSessionIds) {
126
+ const ids = [...new Set(piboSessionIds.filter(Boolean))];
127
+ if (ids.length === 0)
128
+ return 0;
129
+ this.db.exec("BEGIN IMMEDIATE");
130
+ try {
131
+ const deleted = this.deleteProjectSessionsLocked(ids, new Date().toISOString());
132
+ this.db.exec("COMMIT");
133
+ return deleted;
134
+ }
135
+ catch (error) {
136
+ this.db.exec("ROLLBACK");
137
+ throw error;
138
+ }
139
+ }
140
+ async deleteProjectSessionWithCanonicalDelete(piboSessionId, deleteCanonicalSession) {
141
+ this.db.exec("BEGIN IMMEDIATE");
142
+ try {
143
+ this.deleteProjectSessionsLocked([piboSessionId], new Date().toISOString());
144
+ const result = await deleteCanonicalSession();
145
+ this.db.exec("COMMIT");
146
+ return result;
147
+ }
148
+ catch (error) {
149
+ this.db.exec("ROLLBACK");
150
+ throw error;
151
+ }
152
+ }
153
+ deleteProjectSessionsLocked(ids, now) {
154
+ const placeholders = ids.map(() => "?").join(", ");
155
+ const affectedProjects = this.db.prepare(`SELECT DISTINCT id FROM projects
156
+ WHERE current_main_session_id IN (${placeholders})
157
+ UNION
158
+ SELECT DISTINCT project_id AS id FROM project_sessions WHERE pibo_session_id IN (${placeholders})`)
159
+ .all(...ids, ...ids);
160
+ const deleted = this.db.prepare(`DELETE FROM project_sessions WHERE pibo_session_id IN (${placeholders})`).run(...ids);
161
+ for (const project of affectedProjects) {
162
+ const current = this.db.prepare("SELECT current_main_session_id FROM projects WHERE id = ?")
163
+ .get(project.id);
164
+ if (!current?.current_main_session_id)
165
+ continue;
166
+ const currentLink = this.db.prepare("SELECT 1 FROM project_sessions WHERE project_id = ? AND pibo_session_id = ?")
167
+ .get(project.id, current.current_main_session_id);
168
+ if (currentLink)
169
+ continue;
170
+ const replacement = this.db.prepare(`SELECT pibo_session_id FROM project_sessions
171
+ WHERE project_id = ? AND kind = 'main' AND archived = 0
172
+ ORDER BY updated_at DESC, created_at DESC, pibo_session_id DESC
173
+ LIMIT 1`).get(project.id);
174
+ this.db.prepare("UPDATE projects SET current_main_session_id = ?, updated_at = ? WHERE id = ?")
175
+ .run(replacement?.pibo_session_id ?? null, now, project.id);
176
+ }
177
+ return Number(deleted.changes ?? 0);
178
+ }
115
179
  addProjectSession(input) {
116
180
  const existing = this.getProjectSession(input.piboSessionId);
117
181
  const now = new Date().toISOString();
@@ -599,9 +663,17 @@ export class ChatProjectService {
599
663
  if (existing)
600
664
  throw new Error("Project folder already exists");
601
665
  }
602
- ensureProjectFolderUsable(folder) {
666
+ assertFolderDoesNotOverlapAnotherProject(project) {
667
+ const projects = this.db.prepare("SELECT name, project_folder FROM projects WHERE id != ? ORDER BY lower(name), created_at").all(project.id);
668
+ const overlapping = projects.find((other) => pathsOverlap(project.projectFolder, other.project_folder));
669
+ if (overlapping) {
670
+ throw new Error(`Cannot delete project files because its folder overlaps Project \"${overlapping.name}\". Delete without deleting files or choose a non-overlapping folder.`);
671
+ }
672
+ }
673
+ ensureProjectFolderUsable(folder, createFolder) {
603
674
  try {
604
- mkdirSync(folder, { recursive: true });
675
+ if (createFolder)
676
+ mkdirSync(folder, { recursive: true });
605
677
  if (!statSync(folder).isDirectory())
606
678
  throw new Error("Project folder is not a directory");
607
679
  }
@@ -770,6 +842,38 @@ function normalizeProjectFolder(value) {
770
842
  throw new Error("Project folder must be an absolute path, e.g. ~/code/my-project or /home/me/code/my-project");
771
843
  return folder;
772
844
  }
845
+ function pathsOverlap(left, right) {
846
+ if (pathContains(left, right) || pathContains(right, left))
847
+ return true;
848
+ const canonicalLeft = canonicalProjectPath(left);
849
+ const canonicalRight = canonicalProjectPath(right);
850
+ if (!canonicalLeft || !canonicalRight)
851
+ return true;
852
+ return pathContains(canonicalLeft, canonicalRight) || pathContains(canonicalRight, canonicalLeft);
853
+ }
854
+ function canonicalProjectPath(path) {
855
+ let current = resolve(path);
856
+ const missingSegments = [];
857
+ while (true) {
858
+ try {
859
+ return resolve(realpathSync(current), ...missingSegments);
860
+ }
861
+ catch (error) {
862
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
863
+ if (code !== "ENOENT" && code !== "ENOTDIR")
864
+ return undefined;
865
+ const parent = dirname(current);
866
+ if (parent === current)
867
+ return undefined;
868
+ missingSegments.unshift(basename(current));
869
+ current = parent;
870
+ }
871
+ }
872
+ }
873
+ function pathContains(parent, candidate) {
874
+ const pathFromParent = relative(resolve(parent), resolve(candidate));
875
+ return pathFromParent === "" || (pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent));
876
+ }
773
877
  const PROJECT_SESSION_STATES = new Set([
774
878
  "simple_chat",
775
879
  "workflow",
@@ -1,6 +1,12 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { roomWorkspaceFromMetadata } from "../types/rooms.js";
3
3
  import { roomFromRow } from "./chat-data-mappers.js";
4
+ export class PiboRoomHierarchyCycleError extends Error {
5
+ constructor() {
6
+ super("Room parent assignment would create a cycle.");
7
+ this.name = "PiboRoomHierarchyCycleError";
8
+ }
9
+ }
4
10
  export class ChatRoomService {
5
11
  store;
6
12
  constructor(store) {
@@ -17,6 +23,9 @@ export class ChatRoomService {
17
23
  const existing = this.getRoom(id);
18
24
  if (!existing)
19
25
  return undefined;
26
+ if (input.parentRoomId && this.parentAssignmentCreatesCycle(id, input.parentRoomId)) {
27
+ throw new PiboRoomHierarchyCycleError();
28
+ }
20
29
  const metadata = input.metadata ?? existing.metadata;
21
30
  const updated = { ...existing, name: input.name ?? existing.name, topic: input.topic === null ? undefined : input.topic ?? existing.topic, parentRoomId: input.parentRoomId === null ? undefined : input.parentRoomId ?? existing.parentRoomId, retentionPolicyId: input.retentionPolicyId === null ? undefined : input.retentionPolicyId ?? existing.retentionPolicyId, metadata, workspace: roomWorkspaceFromMetadata(metadata), updatedAt: new Date().toISOString() };
22
31
  this.store.db.prepare("UPDATE rooms SET name = ?, topic = ?, parent_room_id = ?, workspace = ?, retention_policy_id = ?, metadata_json = ?, updated_at = ? WHERE id = ?").run(updated.name, updated.topic ?? null, updated.parentRoomId ?? null, updated.workspace ?? null, updated.retentionPolicyId ?? null, JSON.stringify(updated.metadata), updated.updatedAt, id);
@@ -49,4 +58,16 @@ export class ChatRoomService {
49
58
  requireRoom(roomId) { const room = this.getRoom(roomId); if (!room)
50
59
  throw new Error("Room not found"); return room; }
51
60
  close() { }
61
+ parentAssignmentCreatesCycle(roomId, parentRoomId) {
62
+ const roomsById = new Map(this.listRooms().map((room) => [room.id, room]));
63
+ const visited = new Set();
64
+ let currentId = parentRoomId;
65
+ while (currentId && !visited.has(currentId)) {
66
+ if (currentId === roomId)
67
+ return true;
68
+ visited.add(currentId);
69
+ currentId = roomsById.get(currentId)?.parentRoomId;
70
+ }
71
+ return false;
72
+ }
52
73
  }