bopodev-api 0.1.12 → 0.1.14

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,8 +1,20 @@
1
1
  import { Router } from "express";
2
- import { listAuditEvents, listCostEntries, listHeartbeatRuns } from "bopodev-db";
2
+ import { z } from "zod";
3
+ import {
4
+ getHeartbeatRun,
5
+ listAgents,
6
+ listAuditEvents,
7
+ listCostEntries,
8
+ listHeartbeatRunMessages,
9
+ listHeartbeatRuns,
10
+ listModelPricing,
11
+ listPluginRuns,
12
+ upsertModelPricing
13
+ } from "bopodev-db";
3
14
  import type { AppContext } from "../context";
4
- import { sendOk } from "../http";
15
+ import { sendError, sendOk } from "../http";
5
16
  import { requireCompanyScope } from "../middleware/company-scope";
17
+ import { listAgentMemoryFiles, readAgentMemoryFile } from "../services/memory-file-service";
6
18
 
7
19
  export function createObservabilityRouter(ctx: AppContext) {
8
20
  const router = Router();
@@ -30,29 +42,237 @@ export function createObservabilityRouter(ctx: AppContext) {
30
42
  );
31
43
  });
32
44
 
45
+ const modelPricingUpdateSchema = z.object({
46
+ providerType: z.string().min(1),
47
+ modelId: z.string().min(1),
48
+ displayName: z.string().min(1).optional(),
49
+ inputUsdPer1M: z.number().min(0),
50
+ outputUsdPer1M: z.number().min(0),
51
+ currency: z.string().min(1).optional()
52
+ });
53
+
54
+ router.get("/models/pricing", async (req, res) => {
55
+ const rows = await listModelPricing(ctx.db, req.companyId!);
56
+ return sendOk(
57
+ res,
58
+ rows.map((row) => ({
59
+ companyId: row.companyId,
60
+ providerType: row.providerType,
61
+ modelId: row.modelId,
62
+ displayName: row.displayName,
63
+ inputUsdPer1M: typeof row.inputUsdPer1M === "number" ? row.inputUsdPer1M : Number(row.inputUsdPer1M ?? 0),
64
+ outputUsdPer1M: typeof row.outputUsdPer1M === "number" ? row.outputUsdPer1M : Number(row.outputUsdPer1M ?? 0),
65
+ currency: row.currency,
66
+ updatedAt: row.updatedAt?.toISOString?.() ?? null,
67
+ updatedBy: row.updatedBy ?? null
68
+ }))
69
+ );
70
+ });
71
+
72
+ router.put("/models/pricing", async (req, res) => {
73
+ const parsed = modelPricingUpdateSchema.safeParse(req.body);
74
+ if (!parsed.success) {
75
+ return sendError(res, parsed.error.message, 422);
76
+ }
77
+ const payload = parsed.data;
78
+ await upsertModelPricing(ctx.db, {
79
+ companyId: req.companyId!,
80
+ providerType: payload.providerType,
81
+ modelId: payload.modelId,
82
+ displayName: payload.displayName ?? null,
83
+ inputUsdPer1M: payload.inputUsdPer1M.toFixed(6),
84
+ outputUsdPer1M: payload.outputUsdPer1M.toFixed(6),
85
+ currency: payload.currency ?? "USD",
86
+ updatedBy: req.actor?.id ?? null
87
+ });
88
+ return sendOk(res, { ok: true });
89
+ });
90
+
33
91
  router.get("/heartbeats", async (req, res) => {
34
92
  const companyId = req.companyId!;
93
+ const rawLimit = Number(req.query.limit ?? 100);
94
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
95
+ const statusFilter = typeof req.query.status === "string" && req.query.status.trim().length > 0 ? req.query.status.trim() : null;
96
+ const agentFilter = typeof req.query.agentId === "string" && req.query.agentId.trim().length > 0 ? req.query.agentId.trim() : null;
35
97
  const [runs, auditRows] = await Promise.all([
36
- listHeartbeatRuns(ctx.db, companyId),
98
+ listHeartbeatRuns(ctx.db, companyId, limit),
37
99
  listAuditEvents(ctx.db, companyId)
38
100
  ]);
39
- const outcomeByRunId = new Map<string, unknown>();
40
- for (const row of auditRows) {
41
- if (
42
- row.entityType === "heartbeat_run" &&
43
- (row.eventType === "heartbeat.completed" || row.eventType === "heartbeat.failed")
44
- ) {
45
- const payload = parsePayload(row.payloadJson);
46
- if (payload && typeof payload === "object" && "outcome" in payload) {
47
- outcomeByRunId.set(row.entityId, (payload as Record<string, unknown>).outcome ?? null);
48
- }
101
+ const runDetailsByRunId = buildRunDetailsMap(auditRows);
102
+ return sendOk(
103
+ res,
104
+ runs
105
+ .filter((run) => (statusFilter ? run.status === statusFilter : true))
106
+ .filter((run) => (agentFilter ? run.agentId === agentFilter : true))
107
+ .map((run) => {
108
+ const details = runDetailsByRunId.get(run.id);
109
+ const outcome = details?.outcome ?? null;
110
+ return {
111
+ ...serializeRunRow(run, outcome),
112
+ outcome
113
+ };
114
+ })
115
+ );
116
+ });
117
+
118
+ router.get("/heartbeats/:runId", async (req, res) => {
119
+ const companyId = req.companyId!;
120
+ const runId = req.params.runId;
121
+ const [run, auditRows, transcriptResult] = await Promise.all([
122
+ getHeartbeatRun(ctx.db, companyId, runId),
123
+ listAuditEvents(ctx.db, companyId, 500),
124
+ listHeartbeatRunMessages(ctx.db, { companyId, runId, limit: 20 })
125
+ ]);
126
+ if (!run) {
127
+ return sendError(res, "Run not found", 404);
128
+ }
129
+ const runDetailsByRunId = buildRunDetailsMap(auditRows);
130
+ const details = runDetailsByRunId.get(runId) ?? null;
131
+ const trace = toRecord(details?.trace);
132
+ const traceTranscript = Array.isArray(trace?.transcript) ? trace.transcript : [];
133
+ return sendOk(res, {
134
+ run: serializeRunRow(run, details?.outcome ?? null),
135
+ details,
136
+ transcript: {
137
+ hasPersistedMessages: transcriptResult.items.length > 0,
138
+ fallbackFromTrace: transcriptResult.items.length === 0 && traceTranscript.length > 0,
139
+ truncated: traceTranscript.length >= 120
49
140
  }
141
+ });
142
+ });
143
+
144
+ router.get("/heartbeats/:runId/messages", async (req, res) => {
145
+ const companyId = req.companyId!;
146
+ const runId = req.params.runId;
147
+ const rawLimit = Number(req.query.limit ?? 200);
148
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 200;
149
+ const afterRaw = typeof req.query.cursor === "string" ? Number(req.query.cursor) : NaN;
150
+ const afterSequence = Number.isFinite(afterRaw) ? Math.floor(afterRaw) : undefined;
151
+ const signalOnly = req.query.signalOnly !== "false";
152
+ const requestedKinds =
153
+ typeof req.query.kinds === "string"
154
+ ? req.query.kinds
155
+ .split(",")
156
+ .map((value) => value.trim())
157
+ .filter(Boolean)
158
+ : [];
159
+ const allowedKinds = new Set(["system", "assistant", "thinking", "tool_call", "tool_result", "result", "stderr"]);
160
+ const kindFilter = requestedKinds.filter((kind) => allowedKinds.has(kind));
161
+ const [run, result] = await Promise.all([
162
+ getHeartbeatRun(ctx.db, companyId, runId),
163
+ listHeartbeatRunMessages(ctx.db, { companyId, runId, limit, afterSequence })
164
+ ]);
165
+ if (!run) {
166
+ return sendError(res, "Run not found", 404);
50
167
  }
168
+ const filteredItems = result.items
169
+ .filter((message) => (kindFilter.length > 0 ? kindFilter.includes(message.kind) : true))
170
+ .filter((message) => {
171
+ if (!signalOnly) {
172
+ return true;
173
+ }
174
+ if (message.kind === "tool_call" || message.kind === "tool_result" || message.kind === "result") {
175
+ return true;
176
+ }
177
+ return message.signalLevel === "high" || message.signalLevel === "medium";
178
+ });
179
+ const derivedItems = deriveRelevantMessagesFromRawTranscript(result.items, run, kindFilter, signalOnly);
180
+ const responseItems = [...filteredItems, ...derivedItems]
181
+ .sort((a, b) => a.sequence - b.sequence)
182
+ .filter((message, index, array) => {
183
+ const previous = array[index - 1];
184
+ if (!previous) {
185
+ return true;
186
+ }
187
+ return !(
188
+ previous.kind === message.kind &&
189
+ previous.text === message.text &&
190
+ previous.label === message.label &&
191
+ previous.sequence === message.sequence
192
+ );
193
+ });
194
+ return sendOk(res, {
195
+ runId,
196
+ items: responseItems.map((message) => ({
197
+ id: message.id,
198
+ companyId: message.companyId,
199
+ runId: message.runId,
200
+ sequence: message.sequence,
201
+ kind: message.kind,
202
+ label: message.label,
203
+ text: message.text,
204
+ payload: message.payloadJson,
205
+ signalLevel: message.signalLevel ?? undefined,
206
+ groupKey: message.groupKey,
207
+ source: message.source ?? undefined,
208
+ createdAt: message.createdAt.toISOString()
209
+ })),
210
+ nextCursor: result.nextCursor
211
+ });
212
+ });
213
+
214
+ router.get("/memory", async (req, res) => {
215
+ const companyId = req.companyId!;
216
+ const agentIdFilter = typeof req.query.agentId === "string" && req.query.agentId.trim() ? req.query.agentId.trim() : null;
217
+ const rawLimit = Number(req.query.limit ?? 100);
218
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 500) : 100;
219
+ const agents = await listAgents(ctx.db, companyId);
220
+ const targetAgents = agentIdFilter ? agents.filter((agent) => agent.id === agentIdFilter) : agents;
221
+ const fileRows = await Promise.all(
222
+ targetAgents.map(async (agent) => ({
223
+ agentId: agent.id,
224
+ files: await listAgentMemoryFiles({
225
+ companyId,
226
+ agentId: agent.id,
227
+ maxFiles: limit
228
+ })
229
+ }))
230
+ );
231
+ const flattened = fileRows
232
+ .flatMap((row) =>
233
+ row.files.map((file) => ({
234
+ agentId: row.agentId,
235
+ relativePath: file.relativePath,
236
+ path: file.path
237
+ }))
238
+ )
239
+ .slice(0, limit);
240
+ return sendOk(res, {
241
+ items: flattened
242
+ });
243
+ });
244
+
245
+ router.get("/memory/:agentId/file", async (req, res) => {
246
+ const companyId = req.companyId!;
247
+ const agentId = req.params.agentId;
248
+ const relativePath = typeof req.query.path === "string" ? req.query.path.trim() : "";
249
+ if (!relativePath) {
250
+ return sendError(res, "Query parameter 'path' is required.", 422);
251
+ }
252
+ try {
253
+ const file = await readAgentMemoryFile({
254
+ companyId,
255
+ agentId,
256
+ relativePath
257
+ });
258
+ return sendOk(res, file);
259
+ } catch (error) {
260
+ return sendError(res, String(error), 422);
261
+ }
262
+ });
263
+
264
+ router.get("/plugins/runs", async (req, res) => {
265
+ const companyId = req.companyId!;
266
+ const pluginId = typeof req.query.pluginId === "string" && req.query.pluginId.trim() ? req.query.pluginId.trim() : undefined;
267
+ const runId = typeof req.query.runId === "string" && req.query.runId.trim() ? req.query.runId.trim() : undefined;
268
+ const rawLimit = Number(req.query.limit ?? 200);
269
+ const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), 1000) : 200;
270
+ const rows = await listPluginRuns(ctx.db, { companyId, pluginId, runId, limit });
51
271
  return sendOk(
52
272
  res,
53
- runs.map((run) => ({
54
- ...run,
55
- outcome: outcomeByRunId.get(run.id) ?? null
273
+ rows.map((row) => ({
274
+ ...row,
275
+ diagnostics: parsePayload(row.diagnosticsJson)
56
276
  }))
57
277
  );
58
278
  });
@@ -60,11 +280,368 @@ export function createObservabilityRouter(ctx: AppContext) {
60
280
  return router;
61
281
  }
62
282
 
63
- function parsePayload(payloadJson: string) {
283
+ function parsePayload(payloadJson: string): Record<string, unknown> {
64
284
  try {
65
285
  const parsed = JSON.parse(payloadJson) as unknown;
66
- return typeof parsed === "object" && parsed !== null ? parsed : {};
286
+ return toRecord(parsed) ?? {};
67
287
  } catch {
68
288
  return {};
69
289
  }
70
290
  }
291
+
292
+ function toRecord(value: unknown) {
293
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
294
+ }
295
+
296
+ function serializeRunRow(
297
+ run: {
298
+ id: string;
299
+ companyId: string;
300
+ agentId: string;
301
+ status: string;
302
+ startedAt: Date;
303
+ finishedAt: Date | null;
304
+ message: string | null;
305
+ },
306
+ outcome: unknown
307
+ ) {
308
+ const runType = resolveRunType(run, outcome);
309
+ return {
310
+ id: run.id,
311
+ companyId: run.companyId,
312
+ agentId: run.agentId,
313
+ status: run.status,
314
+ startedAt: run.startedAt.toISOString(),
315
+ finishedAt: run.finishedAt?.toISOString() ?? null,
316
+ message: run.message ?? null,
317
+ runType
318
+ };
319
+ }
320
+
321
+ function resolveRunType(
322
+ run: {
323
+ status: string;
324
+ message: string | null;
325
+ },
326
+ outcome: unknown
327
+ ): "work" | "no_assigned_work" | "budget_skip" | "overlap_skip" | "other_skip" | "failed" | "running" {
328
+ if (run.status === "started") {
329
+ return "running";
330
+ }
331
+ if (run.status === "failed") {
332
+ return "failed";
333
+ }
334
+ const normalizedMessage = (run.message ?? "").toLowerCase();
335
+ if (normalizedMessage.includes("already in progress")) {
336
+ return "overlap_skip";
337
+ }
338
+ if (normalizedMessage.includes("budget hard-stop")) {
339
+ return "budget_skip";
340
+ }
341
+ if (isNoAssignedWorkMessage(run.message)) {
342
+ return "no_assigned_work";
343
+ }
344
+ if (isNoAssignedWorkOutcome(outcome)) {
345
+ return "no_assigned_work";
346
+ }
347
+ if (run.status === "skipped") {
348
+ return "other_skip";
349
+ }
350
+ return "work";
351
+ }
352
+
353
+ function isNoAssignedWorkMessage(message: string | null) {
354
+ return /\bno assigned work found\b/i.test(message ?? "");
355
+ }
356
+
357
+ function isNoAssignedWorkOutcome(outcome: unknown) {
358
+ const record = toRecord(outcome);
359
+ if (!record) {
360
+ return false;
361
+ }
362
+ if (record.kind !== "skipped") {
363
+ return false;
364
+ }
365
+ const issueIdsTouched = Array.isArray(record.issueIdsTouched)
366
+ ? record.issueIdsTouched.filter((value) => typeof value === "string")
367
+ : [];
368
+ if (issueIdsTouched.length === 0) {
369
+ return true;
370
+ }
371
+ const actions = Array.isArray(record.actions)
372
+ ? record.actions.filter((value) => typeof value === "object" && value !== null)
373
+ : [];
374
+ return actions.some((action) => {
375
+ const parsed = action as Record<string, unknown>;
376
+ return parsed.type === "heartbeat.skip";
377
+ });
378
+ }
379
+
380
+ function buildRunDetailsMap(
381
+ auditRows: Array<{
382
+ entityType: string;
383
+ eventType: string;
384
+ entityId: string;
385
+ payloadJson: string;
386
+ createdAt: Date;
387
+ }>
388
+ ) {
389
+ const detailsByRunId = new Map<string, Record<string, unknown>>();
390
+ const relevantRows = auditRows
391
+ .filter(
392
+ (row) =>
393
+ row.entityType === "heartbeat_run" &&
394
+ (row.eventType === "heartbeat.completed" || row.eventType === "heartbeat.failed")
395
+ )
396
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
397
+ for (const row of relevantRows) {
398
+ if (detailsByRunId.has(row.entityId)) {
399
+ continue;
400
+ }
401
+ detailsByRunId.set(row.entityId, parsePayload(row.payloadJson));
402
+ }
403
+ return detailsByRunId;
404
+ }
405
+
406
+ function deriveRelevantMessagesFromRawTranscript(
407
+ items: Array<{
408
+ id: string;
409
+ companyId: string;
410
+ runId: string;
411
+ sequence: number;
412
+ kind: string;
413
+ label: string | null;
414
+ text: string | null;
415
+ payloadJson: string | null;
416
+ signalLevel: string | null;
417
+ groupKey: string | null;
418
+ source: string | null;
419
+ createdAt: Date;
420
+ }>,
421
+ run: {
422
+ id: string;
423
+ companyId: string;
424
+ status: string;
425
+ message: string | null;
426
+ finishedAt: Date | null;
427
+ },
428
+ kindFilter: string[],
429
+ signalOnly: boolean
430
+ ) {
431
+ const derived: Array<{
432
+ id: string;
433
+ companyId: string;
434
+ runId: string;
435
+ sequence: number;
436
+ kind: "assistant" | "tool_call" | "tool_result" | "result";
437
+ label: string | null;
438
+ text: string | null;
439
+ payloadJson: string | null;
440
+ signalLevel: "high" | "medium";
441
+ groupKey: string | null;
442
+ source: "stderr";
443
+ createdAt: Date;
444
+ }> = [];
445
+
446
+ let inPromptBlock = true;
447
+ let assistantAfterCodex = false;
448
+ let pendingTool:
449
+ | {
450
+ sequence: number;
451
+ createdAt: Date;
452
+ command: string;
453
+ }
454
+ | undefined;
455
+ let pendingResult:
456
+ | {
457
+ sequence: number;
458
+ createdAt: Date;
459
+ command: string;
460
+ statusLine: string;
461
+ output: string[];
462
+ }
463
+ | undefined;
464
+
465
+ const flushPendingResult = () => {
466
+ if (!pendingResult) {
467
+ return;
468
+ }
469
+ const body = [pendingResult.statusLine, ...(pendingResult.output.length > 0 ? ["", ...pendingResult.output] : [])]
470
+ .join("\n")
471
+ .trim();
472
+ derived.push({
473
+ id: `derived-${run.id}-${pendingResult.sequence}-result`,
474
+ companyId: run.companyId,
475
+ runId: run.id,
476
+ sequence: pendingResult.sequence * 10 + 1,
477
+ kind: "tool_result",
478
+ label: pendingResult.command,
479
+ text: body || pendingResult.command,
480
+ payloadJson: null,
481
+ signalLevel: "high",
482
+ groupKey: `tool:${pendingResult.command}`,
483
+ source: "stderr",
484
+ createdAt: pendingResult.createdAt
485
+ });
486
+ pendingResult = undefined;
487
+ };
488
+
489
+ for (const item of items) {
490
+ const text = (item.text ?? "").trim();
491
+ if (!text) {
492
+ continue;
493
+ }
494
+ if (inPromptBlock) {
495
+ if (text === "mcp startup: no servers" || text === "codex") {
496
+ inPromptBlock = false;
497
+ } else {
498
+ continue;
499
+ }
500
+ }
501
+
502
+ if (text === "codex") {
503
+ flushPendingResult();
504
+ assistantAfterCodex = true;
505
+ continue;
506
+ }
507
+ if (text === "exec") {
508
+ flushPendingResult();
509
+ assistantAfterCodex = false;
510
+ continue;
511
+ }
512
+
513
+ const inlineCommandMatch = /^(\/bin\/.+?) in .+? (succeeded|failed|exited .+?):$/i.exec(text);
514
+ if (inlineCommandMatch) {
515
+ const command = inlineCommandMatch[1]!.trim();
516
+ derived.push({
517
+ id: `derived-${run.id}-${item.sequence}-call`,
518
+ companyId: run.companyId,
519
+ runId: run.id,
520
+ sequence: item.sequence * 10,
521
+ kind: "tool_call",
522
+ label: "command_execution",
523
+ text: command,
524
+ payloadJson: JSON.stringify({ command }),
525
+ signalLevel: "high",
526
+ groupKey: `tool:${command}`,
527
+ source: "stderr",
528
+ createdAt: item.createdAt
529
+ });
530
+ pendingResult = {
531
+ sequence: item.sequence,
532
+ createdAt: item.createdAt,
533
+ command,
534
+ statusLine: text.slice(command.length).trim(),
535
+ output: []
536
+ };
537
+ assistantAfterCodex = false;
538
+ continue;
539
+ }
540
+
541
+ if (text.startsWith("/bin/")) {
542
+ flushPendingResult();
543
+ pendingTool = {
544
+ sequence: item.sequence,
545
+ createdAt: item.createdAt,
546
+ command: text
547
+ };
548
+ derived.push({
549
+ id: `derived-${run.id}-${item.sequence}-call`,
550
+ companyId: run.companyId,
551
+ runId: run.id,
552
+ sequence: item.sequence * 10,
553
+ kind: "tool_call",
554
+ label: "command_execution",
555
+ text,
556
+ payloadJson: JSON.stringify({ command: text }),
557
+ signalLevel: "high",
558
+ groupKey: `tool:${text}`,
559
+ source: "stderr",
560
+ createdAt: item.createdAt
561
+ });
562
+ assistantAfterCodex = false;
563
+ continue;
564
+ }
565
+
566
+ if (/^(succeeded in \d+ms:|failed in \d+ms:|exited \d+ in \d+ms:)$/i.test(text) && pendingTool) {
567
+ pendingResult = {
568
+ sequence: pendingTool.sequence,
569
+ createdAt: pendingTool.createdAt,
570
+ command: pendingTool.command,
571
+ statusLine: text,
572
+ output: []
573
+ };
574
+ pendingTool = undefined;
575
+ continue;
576
+ }
577
+
578
+ if (pendingResult) {
579
+ if (text === "---" || text === "--------") {
580
+ continue;
581
+ }
582
+ pendingResult.output.push(text);
583
+ continue;
584
+ }
585
+
586
+ if (assistantAfterCodex && looksLikeUsefulAssistantText(text)) {
587
+ derived.push({
588
+ id: `derived-${run.id}-${item.sequence}-assistant`,
589
+ companyId: run.companyId,
590
+ runId: run.id,
591
+ sequence: item.sequence * 10,
592
+ kind: "assistant",
593
+ label: null,
594
+ text,
595
+ payloadJson: null,
596
+ signalLevel: "medium",
597
+ groupKey: "assistant",
598
+ source: "stderr",
599
+ createdAt: item.createdAt
600
+ });
601
+ assistantAfterCodex = false;
602
+ }
603
+ }
604
+
605
+ flushPendingResult();
606
+
607
+ const runMessage = run.message?.trim();
608
+ const shouldAppendRunSummary = Boolean(runMessage) && run.status !== "started";
609
+ if (!derived.some((item) => item.kind === "result") && shouldAppendRunSummary && runMessage) {
610
+ derived.push({
611
+ id: `derived-${run.id}-final-result`,
612
+ companyId: run.companyId,
613
+ runId: run.id,
614
+ sequence: (items[items.length - 1]?.sequence ?? 0) * 10 + 9,
615
+ kind: "result",
616
+ label: null,
617
+ text: runMessage,
618
+ payloadJson: null,
619
+ signalLevel: "high",
620
+ groupKey: "result",
621
+ source: "stderr",
622
+ createdAt: run.finishedAt ?? items[items.length - 1]?.createdAt ?? new Date()
623
+ });
624
+ }
625
+
626
+ return derived
627
+ .filter((item) => (kindFilter.length > 0 ? kindFilter.includes(item.kind) : true))
628
+ .filter(() => (signalOnly ? true : true));
629
+ }
630
+
631
+ function looksLikeUsefulAssistantText(text: string) {
632
+ const normalized = text.toLowerCase();
633
+ if (normalized.length > 320) {
634
+ return false;
635
+ }
636
+ return (
637
+ normalized.includes("i’m ") ||
638
+ normalized.includes("i'm ") ||
639
+ normalized.includes("next i") ||
640
+ normalized.includes("using `") ||
641
+ normalized.includes("switching to") ||
642
+ normalized.includes("the workspace already") ||
643
+ normalized.includes("i have the") ||
644
+ normalized.includes("i still need") ||
645
+ normalized.includes("restoring")
646
+ );
647
+ }