bopodev-api 0.1.24 → 0.1.26

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.
@@ -3,9 +3,11 @@ import { join, relative, resolve } from "node:path";
3
3
  import type { AgentMemoryContext } from "bopodev-agent-sdk";
4
4
  import {
5
5
  isInsidePath,
6
+ resolveCompanyMemoryRootPath,
6
7
  resolveAgentDailyMemoryPath,
7
8
  resolveAgentDurableMemoryPath,
8
- resolveAgentMemoryRootPath
9
+ resolveAgentMemoryRootPath,
10
+ resolveProjectMemoryRootPath
9
11
  } from "../lib/instance-paths";
10
12
 
11
13
  const MAX_DAILY_LINES = 12;
@@ -13,25 +15,78 @@ const MAX_DURABLE_FACTS = 12;
13
15
  const MAX_TACIT_NOTES_CHARS = 1_500;
14
16
  const MAX_OBSERVABILITY_FILES = 200;
15
17
  const MAX_OBSERVABILITY_FILE_BYTES = 512 * 1024;
18
+ const MAX_CANDIDATE_FACTS = 3;
16
19
 
17
20
  export type PersistedHeartbeatMemory = {
18
21
  memoryRoot: string;
19
22
  dailyNotePath: string;
20
23
  dailyEntry: string;
21
- candidateFacts: string[];
24
+ candidateFacts: MemoryCandidateFact[];
25
+ };
26
+
27
+ export type MemoryScope = "company" | "project" | "agent";
28
+
29
+ export type MemoryCandidateFact = {
30
+ fact: string;
31
+ confidence: number;
32
+ impactTags: string[];
33
+ scope: MemoryScope;
34
+ };
35
+
36
+ type DurableFactRecord = {
37
+ fact: string;
38
+ sourceRunId: string | null;
39
+ scope: MemoryScope;
40
+ confidence: number | null;
41
+ createdAt: string | null;
42
+ supersedes: string | null;
43
+ status: "active" | "superseded";
44
+ impactTags: string[];
45
+ };
46
+
47
+ type ScopedMemorySource = {
48
+ scope: MemoryScope;
49
+ root: string;
50
+ label: string;
22
51
  };
23
52
 
24
53
  export async function loadAgentMemoryContext(input: {
25
54
  companyId: string;
26
55
  agentId: string;
56
+ projectIds?: string[];
57
+ queryText?: string;
27
58
  }): Promise<AgentMemoryContext> {
59
+ const projectIds = Array.from(new Set((input.projectIds ?? []).map((entry) => entry.trim()).filter(Boolean)));
60
+ const scopedRoots: ScopedMemorySource[] = [
61
+ {
62
+ scope: "company",
63
+ root: resolveCompanyMemoryRootPath(input.companyId),
64
+ label: "company"
65
+ },
66
+ ...projectIds.map((projectId) => ({
67
+ scope: "project" as const,
68
+ root: resolveProjectMemoryRootPath(input.companyId, projectId),
69
+ label: `project:${projectId}`
70
+ })),
71
+ {
72
+ scope: "agent",
73
+ root: resolveAgentMemoryRootPath(input.companyId, input.agentId),
74
+ label: "agent"
75
+ }
76
+ ];
77
+ const tacitBlocks = await Promise.all(
78
+ scopedRoots.map(async (source) => {
79
+ const tacit = await readTacitNotes(source.root);
80
+ if (!tacit) {
81
+ return null;
82
+ }
83
+ return `### ${source.label}\n${tacit}`;
84
+ })
85
+ );
86
+ const tacitNotes = tacitBlocks.filter(Boolean).join("\n\n").trim() || undefined;
87
+ const durableFacts = await readScopedDurableFacts(scopedRoots, MAX_DURABLE_FACTS, input.queryText);
88
+ const dailyNotes = await readScopedDailyNotes(scopedRoots, MAX_DAILY_LINES, input.queryText);
28
89
  const memoryRoot = resolveAgentMemoryRootPath(input.companyId, input.agentId);
29
- const durableRoot = resolveAgentDurableMemoryPath(input.companyId, input.agentId);
30
- const dailyRoot = resolveAgentDailyMemoryPath(input.companyId, input.agentId);
31
- await ensureMemoryDirs(memoryRoot, durableRoot, dailyRoot);
32
- const tacitNotes = await readTacitNotes(memoryRoot);
33
- const durableFacts = await readDurableFacts(durableRoot, MAX_DURABLE_FACTS);
34
- const dailyNotes = await readRecentDailyNotes(dailyRoot, MAX_DAILY_LINES);
35
90
  return {
36
91
  memoryRoot,
37
92
  tacitNotes,
@@ -47,6 +102,11 @@ export async function persistHeartbeatMemory(input: {
47
102
  status: string;
48
103
  summary: string;
49
104
  outcomeKind?: string | null;
105
+ mission?: string | null;
106
+ goalContext?: {
107
+ companyGoals?: string[];
108
+ projectGoals?: string[];
109
+ };
50
110
  }): Promise<PersistedHeartbeatMemory> {
51
111
  const memoryRoot = resolveAgentMemoryRootPath(input.companyId, input.agentId);
52
112
  const durableRoot = resolveAgentDurableMemoryPath(input.companyId, input.agentId);
@@ -61,11 +121,15 @@ export async function persistHeartbeatMemory(input: {
61
121
  `- run: ${input.runId}`,
62
122
  `- status: ${input.status}`,
63
123
  `- outcome: ${input.outcomeKind ?? "unknown"}`,
124
+ `- missionAlignment: ${computeMissionAlignmentScore(input.summary, input.mission ?? null, input.goalContext).toFixed(2)}`,
64
125
  `- summary: ${summary || "No summary provided."}`,
65
126
  ""
66
127
  ].join("\n");
67
128
  await writeFile(dailyNotePath, dailyEntry, { encoding: "utf8", flag: "a" });
68
- const candidateFacts = deriveCandidateFacts(summary);
129
+ const candidateFacts = deriveCandidateFacts(summary, {
130
+ mission: input.mission ?? null,
131
+ goalContext: input.goalContext
132
+ });
69
133
  return {
70
134
  memoryRoot,
71
135
  dailyNotePath,
@@ -77,17 +141,44 @@ export async function persistHeartbeatMemory(input: {
77
141
  export async function appendDurableFact(input: {
78
142
  companyId: string;
79
143
  agentId: string;
80
- fact: string;
144
+ fact: string | MemoryCandidateFact;
81
145
  sourceRunId?: string | null;
146
+ scope?: MemoryScope;
147
+ confidence?: number | null;
148
+ impactTags?: string[];
149
+ supersedes?: string | null;
150
+ status?: "active" | "superseded";
82
151
  }) {
83
152
  const durableRoot = resolveAgentDurableMemoryPath(input.companyId, input.agentId);
84
153
  await mkdir(durableRoot, { recursive: true });
85
154
  const targetFile = join(durableRoot, "items.yaml");
86
- const normalizedFact = collapseWhitespace(input.fact);
155
+ const typedFact = typeof input.fact === "string" ? null : input.fact;
156
+ const rawFact = typeof input.fact === "string" ? input.fact : input.fact.fact;
157
+ const normalizedFact = collapseWhitespace(rawFact);
87
158
  if (!normalizedFact) {
88
159
  return null;
89
160
  }
90
- const row = `- fact: "${escapeYamlString(normalizedFact)}"\n sourceRunId: "${escapeYamlString(input.sourceRunId ?? "")}"\n`;
161
+ const existingRecords = await readDurableFactRecords(durableRoot);
162
+ const duplicate = existingRecords.some((record) => areFactsEquivalent(record.fact, normalizedFact));
163
+ if (duplicate) {
164
+ return null;
165
+ }
166
+ const confidence = clampConfidence(typedFact?.confidence ?? input.confidence ?? null);
167
+ const impactTags = dedupeStrings(typedFact?.impactTags ?? input.impactTags ?? []);
168
+ const scope = typedFact?.scope ?? input.scope ?? "agent";
169
+ const createdAt = new Date().toISOString();
170
+ const status = input.status ?? "active";
171
+ const row = [
172
+ `- fact: "${escapeYamlString(normalizedFact)}"`,
173
+ ` sourceRunId: "${escapeYamlString(input.sourceRunId ?? "")}"`,
174
+ ` scope: "${escapeYamlString(scope)}"`,
175
+ ` confidence: "${confidence !== null ? confidence.toFixed(2) : ""}"`,
176
+ ` createdAt: "${escapeYamlString(createdAt)}"`,
177
+ ` supersedes: "${escapeYamlString(input.supersedes ?? "")}"`,
178
+ ` status: "${escapeYamlString(status)}"`,
179
+ ` impactTags: "${escapeYamlString(impactTags.join(","))}"`,
180
+ ""
181
+ ].join("\n");
91
182
  await writeFile(targetFile, row, { encoding: "utf8", flag: "a" });
92
183
  return targetFile;
93
184
  }
@@ -139,11 +230,67 @@ function collapseWhitespace(value: string) {
139
230
  return value.replace(/\s+/g, " ").trim();
140
231
  }
141
232
 
142
- function deriveCandidateFacts(summary: string) {
143
- if (!summary || summary.length < 18) {
233
+ function deriveCandidateFacts(
234
+ summary: string,
235
+ context?: {
236
+ mission?: string | null;
237
+ goalContext?: {
238
+ companyGoals?: string[];
239
+ projectGoals?: string[];
240
+ };
241
+ }
242
+ ): MemoryCandidateFact[] {
243
+ const normalized = collapseWhitespace(summary);
244
+ if (!normalized || normalized.length < 18) {
144
245
  return [];
145
246
  }
146
- return [summary.slice(0, 400)];
247
+ const segments = normalized
248
+ .split(/(?<=[.!?])\s+/)
249
+ .map((entry) => entry.trim())
250
+ .filter(Boolean);
251
+ const selected: MemoryCandidateFact[] = [];
252
+ for (const segment of segments) {
253
+ if (selected.length >= MAX_CANDIDATE_FACTS) {
254
+ break;
255
+ }
256
+ if (segment.length < 40 || segment.length > 320) {
257
+ continue;
258
+ }
259
+ const lowered = segment.toLowerCase();
260
+ if (
261
+ lowered.includes("no summary provided") ||
262
+ lowered.includes("heartbeat failed") ||
263
+ lowered.includes("unknown") ||
264
+ lowered.startsWith("status:")
265
+ ) {
266
+ continue;
267
+ }
268
+ const cleaned = segment.replace(/^(-\s*)?summary:\s*/i, "").trim();
269
+ const missionAlignment = computeMissionAlignmentScore(cleaned, context?.mission ?? null, context?.goalContext);
270
+ const confidence = Math.min(0.95, Math.max(0.5, 0.55 + missionAlignment * 0.4));
271
+ const impactTags = deriveImpactTags(cleaned, context?.mission ?? null, context?.goalContext);
272
+ const duplicate = selected.some((entry) => areFactsEquivalent(entry.fact, cleaned));
273
+ if (!duplicate) {
274
+ selected.push({
275
+ fact: cleaned.slice(0, 400),
276
+ confidence,
277
+ impactTags,
278
+ scope: "agent"
279
+ });
280
+ }
281
+ }
282
+ if (selected.length > 0) {
283
+ return selected;
284
+ }
285
+ const fallback = normalized.slice(0, 280);
286
+ return [
287
+ {
288
+ fact: fallback,
289
+ confidence: 0.55,
290
+ impactTags: deriveImpactTags(fallback, context?.mission ?? null, context?.goalContext),
291
+ scope: "agent"
292
+ }
293
+ ];
147
294
  }
148
295
 
149
296
  async function ensureMemoryDirs(memoryRoot: string, durableRoot: string, dailyRoot: string) {
@@ -167,26 +314,9 @@ async function readTacitNotes(memoryRoot: string) {
167
314
  }
168
315
 
169
316
  async function readDurableFacts(durableRoot: string, limit: number) {
170
- const candidates = [join(durableRoot, "summary.md"), join(durableRoot, "items.yaml")];
171
- const facts: string[] = [];
172
- for (const candidate of candidates) {
173
- try {
174
- const content = await readFile(candidate, "utf8");
175
- const lines = content
176
- .split(/\r?\n/)
177
- .map((line) => line.trim())
178
- .filter((line) => line.length > 0 && !line.startsWith("#"));
179
- for (const line of lines) {
180
- if (facts.length >= limit) {
181
- return facts;
182
- }
183
- facts.push(line.slice(0, 300));
184
- }
185
- } catch {
186
- // best effort
187
- }
188
- }
189
- return facts;
317
+ const records = await readDurableFactRecords(durableRoot);
318
+ const activeRecords = filterSupersededFacts(records);
319
+ return activeRecords.slice(0, limit).map((record) => record.fact.slice(0, 300));
190
320
  }
191
321
 
192
322
  async function readRecentDailyNotes(dailyRoot: string, limit: number) {
@@ -218,6 +348,351 @@ async function readRecentDailyNotes(dailyRoot: string, limit: number) {
218
348
  }
219
349
  }
220
350
 
351
+ async function readScopedDurableFacts(scopedRoots: ScopedMemorySource[], limit: number, queryText?: string) {
352
+ const queryTokens = tokenize(queryText ?? "");
353
+ const records: Array<DurableFactRecord & { scopeLabel: string }> = [];
354
+ for (const source of scopedRoots) {
355
+ const durableRoot = join(source.root, "life");
356
+ const scopedRecords = await readDurableFactRecords(durableRoot);
357
+ for (const record of scopedRecords) {
358
+ records.push({
359
+ ...record,
360
+ scope: source.scope,
361
+ scopeLabel: source.label
362
+ });
363
+ }
364
+ }
365
+ const activeRecords = filterSupersededFacts(records);
366
+ const scored = activeRecords
367
+ .map((record) => ({
368
+ record,
369
+ score: scoreFact(record, queryTokens)
370
+ }))
371
+ .sort((left, right) => right.score - left.score)
372
+ .slice(0, limit);
373
+ return scored.map(({ record }) => {
374
+ const tags = record.impactTags.length > 0 ? ` [${record.impactTags.join(", ")}]` : "";
375
+ return `[${record.scopeLabel}] ${record.fact}${tags}`.slice(0, 300);
376
+ });
377
+ }
378
+
379
+ async function readScopedDailyNotes(scopedRoots: ScopedMemorySource[], limit: number, queryText?: string) {
380
+ const queryTokens = tokenize(queryText ?? "");
381
+ const notes: Array<{ line: string; scopeLabel: string; score: number }> = [];
382
+ for (const source of scopedRoots) {
383
+ const dailyRoot = join(source.root, "memory");
384
+ const scopedNotes = await readRecentDailyNotes(dailyRoot, limit);
385
+ for (const line of scopedNotes) {
386
+ const score = scoreTextMatch(line, queryTokens);
387
+ notes.push({
388
+ line,
389
+ scopeLabel: source.label,
390
+ score: score + (source.scope === "agent" ? 0.15 : source.scope === "project" ? 0.1 : 0.05)
391
+ });
392
+ }
393
+ }
394
+ return notes
395
+ .sort((left, right) => right.score - left.score)
396
+ .slice(0, limit)
397
+ .map((entry) => `[${entry.scopeLabel}] ${entry.line}`.slice(0, 300));
398
+ }
399
+
400
+ async function readDurableFactRecords(durableRoot: string): Promise<DurableFactRecord[]> {
401
+ const records: DurableFactRecord[] = [];
402
+ const summaryPath = join(durableRoot, "summary.md");
403
+ const itemsPath = join(durableRoot, "items.yaml");
404
+ try {
405
+ const summary = await readFile(summaryPath, "utf8");
406
+ const summaryLines = summary
407
+ .split(/\r?\n/)
408
+ .map((line) => collapseWhitespace(line))
409
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
410
+ for (const line of summaryLines) {
411
+ records.push({
412
+ fact: line.slice(0, 400),
413
+ sourceRunId: null,
414
+ scope: "agent",
415
+ confidence: null,
416
+ createdAt: null,
417
+ supersedes: null,
418
+ status: "active",
419
+ impactTags: []
420
+ });
421
+ }
422
+ } catch {
423
+ // best effort
424
+ }
425
+ try {
426
+ const yaml = await readFile(itemsPath, "utf8");
427
+ const parsed = parseItemsYamlRecords(yaml);
428
+ for (const record of parsed) {
429
+ records.push(record);
430
+ }
431
+ } catch {
432
+ // best effort
433
+ }
434
+ return dedupeDurableRecords(records);
435
+ }
436
+
437
+ function parseItemsYamlRecords(content: string): DurableFactRecord[] {
438
+ const lines = content.split(/\r?\n/);
439
+ const rows: Array<Record<string, string>> = [];
440
+ let current: Record<string, string> | null = null;
441
+ for (const line of lines) {
442
+ const trimmed = line.trim();
443
+ if (!trimmed) {
444
+ continue;
445
+ }
446
+ if (trimmed.startsWith("- ")) {
447
+ if (current) {
448
+ rows.push(current);
449
+ }
450
+ current = {};
451
+ const [key, rawValue] = splitKeyValue(trimmed.slice(2));
452
+ if (key) {
453
+ current[key] = rawValue;
454
+ }
455
+ continue;
456
+ }
457
+ if (!current) {
458
+ continue;
459
+ }
460
+ const [key, rawValue] = splitKeyValue(trimmed);
461
+ if (key) {
462
+ current[key] = rawValue;
463
+ }
464
+ }
465
+ if (current) {
466
+ rows.push(current);
467
+ }
468
+ const mapped: DurableFactRecord[] = [];
469
+ for (const row of rows) {
470
+ const fact = collapseWhitespace(unquoteYamlString(row.fact ?? ""));
471
+ if (!fact) {
472
+ continue;
473
+ }
474
+ const sourceRunId = normalizeNullableString(unquoteYamlString(row.sourceRunId ?? ""));
475
+ const scope = parseScope(unquoteYamlString(row.scope ?? ""));
476
+ const confidence = parseConfidence(unquoteYamlString(row.confidence ?? ""));
477
+ const createdAt = normalizeNullableString(unquoteYamlString(row.createdAt ?? ""));
478
+ const supersedes = normalizeNullableString(unquoteYamlString(row.supersedes ?? ""));
479
+ const status = parseStatus(unquoteYamlString(row.status ?? ""));
480
+ const impactTags = splitCsv(unquoteYamlString(row.impactTags ?? ""));
481
+ mapped.push({
482
+ fact,
483
+ sourceRunId,
484
+ scope,
485
+ confidence,
486
+ createdAt,
487
+ supersedes,
488
+ status,
489
+ impactTags
490
+ });
491
+ }
492
+ return mapped;
493
+ }
494
+
495
+ function splitKeyValue(line: string): [string, string] {
496
+ const idx = line.indexOf(":");
497
+ if (idx < 0) {
498
+ return ["", ""];
499
+ }
500
+ const key = line.slice(0, idx).trim();
501
+ const value = line.slice(idx + 1).trim();
502
+ return [key, value];
503
+ }
504
+
505
+ function unquoteYamlString(value: string) {
506
+ const trimmed = value.trim();
507
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
508
+ const inner = trimmed.slice(1, -1);
509
+ return inner.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
510
+ }
511
+ return trimmed;
512
+ }
513
+
514
+ function parseScope(value: string): MemoryScope {
515
+ if (value === "company" || value === "project" || value === "agent") {
516
+ return value;
517
+ }
518
+ return "agent";
519
+ }
520
+
521
+ function parseStatus(value: string): "active" | "superseded" {
522
+ return value === "superseded" ? "superseded" : "active";
523
+ }
524
+
525
+ function parseConfidence(value: string) {
526
+ if (!value) {
527
+ return null;
528
+ }
529
+ const parsed = Number(value);
530
+ if (!Number.isFinite(parsed)) {
531
+ return null;
532
+ }
533
+ return clampConfidence(parsed);
534
+ }
535
+
536
+ function normalizeNullableString(value: string) {
537
+ const normalized = value.trim();
538
+ return normalized.length > 0 ? normalized : null;
539
+ }
540
+
541
+ function splitCsv(value: string) {
542
+ return dedupeStrings(
543
+ value
544
+ .split(",")
545
+ .map((entry) => entry.trim())
546
+ .filter(Boolean)
547
+ );
548
+ }
549
+
550
+ function dedupeDurableRecords(records: DurableFactRecord[]) {
551
+ const result: DurableFactRecord[] = [];
552
+ for (const record of records) {
553
+ if (result.some((entry) => areFactsEquivalent(entry.fact, record.fact))) {
554
+ continue;
555
+ }
556
+ result.push(record);
557
+ }
558
+ return result;
559
+ }
560
+
561
+ function filterSupersededFacts<T extends DurableFactRecord>(records: T[]) {
562
+ const supersededFacts = new Set(
563
+ records
564
+ .filter((record) => record.supersedes && record.supersedes.trim().length > 0)
565
+ .map((record) => canonicalizeFact(record.supersedes!))
566
+ );
567
+ return records.filter(
568
+ (record) => record.status !== "superseded" && !supersededFacts.has(canonicalizeFact(record.fact))
569
+ );
570
+ }
571
+
572
+ function deriveImpactTags(
573
+ fact: string,
574
+ mission?: string | null,
575
+ goalContext?: {
576
+ companyGoals?: string[];
577
+ projectGoals?: string[];
578
+ }
579
+ ) {
580
+ const tags = new Set<string>();
581
+ const lowered = fact.toLowerCase();
582
+ if (/\b(test|qa|validation|verify)\b/.test(lowered)) {
583
+ tags.add("quality");
584
+ }
585
+ if (/\b(budget|cost|token|latency|performance)\b/.test(lowered)) {
586
+ tags.add("efficiency");
587
+ }
588
+ if (/\b(fix|bug|error|incident|failure)\b/.test(lowered)) {
589
+ tags.add("reliability");
590
+ }
591
+ if (/\b(customer|user|ux|onboarding)\b/.test(lowered)) {
592
+ tags.add("customer");
593
+ }
594
+ const missionTokens = tokenize(mission ?? "");
595
+ if (scoreTextMatch(fact, missionTokens) > 0) {
596
+ tags.add("mission");
597
+ }
598
+ const goalTokens = tokenize([...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? [])].join(" "));
599
+ if (scoreTextMatch(fact, goalTokens) > 0) {
600
+ tags.add("goal");
601
+ }
602
+ return Array.from(tags);
603
+ }
604
+
605
+ function computeMissionAlignmentScore(
606
+ summary: string,
607
+ mission?: string | null,
608
+ goalContext?: {
609
+ companyGoals?: string[];
610
+ projectGoals?: string[];
611
+ }
612
+ ) {
613
+ const missionTokens = tokenize(mission ?? "");
614
+ const goalTokens = tokenize([...(goalContext?.companyGoals ?? []), ...(goalContext?.projectGoals ?? [])].join(" "));
615
+ const missionScore = scoreTextMatch(summary, missionTokens);
616
+ const goalScore = scoreTextMatch(summary, goalTokens);
617
+ return Math.min(1, missionScore * 0.55 + goalScore * 0.45);
618
+ }
619
+
620
+ function scoreFact(record: DurableFactRecord, queryTokens: string[]) {
621
+ const textMatch = scoreTextMatch(record.fact, queryTokens);
622
+ const scopeBoost = record.scope === "agent" ? 0.2 : record.scope === "project" ? 0.14 : 0.08;
623
+ const confidenceBoost = (record.confidence ?? 0.5) * 0.2;
624
+ const recencyBoost = scoreRecency(record.createdAt) * 0.2;
625
+ return textMatch * 0.4 + scopeBoost + confidenceBoost + recencyBoost;
626
+ }
627
+
628
+ function scoreRecency(iso: string | null) {
629
+ if (!iso) {
630
+ return 0.25;
631
+ }
632
+ const ts = Date.parse(iso);
633
+ if (!Number.isFinite(ts)) {
634
+ return 0.25;
635
+ }
636
+ const ageDays = Math.max(0, (Date.now() - ts) / (1000 * 60 * 60 * 24));
637
+ if (ageDays <= 3) {
638
+ return 1;
639
+ }
640
+ if (ageDays <= 14) {
641
+ return 0.8;
642
+ }
643
+ if (ageDays <= 60) {
644
+ return 0.55;
645
+ }
646
+ return 0.3;
647
+ }
648
+
649
+ function scoreTextMatch(text: string, queryTokens: string[]) {
650
+ if (queryTokens.length === 0) {
651
+ return 0;
652
+ }
653
+ const textTokens = new Set(tokenize(text));
654
+ let overlap = 0;
655
+ for (const token of queryTokens) {
656
+ if (textTokens.has(token)) {
657
+ overlap += 1;
658
+ }
659
+ }
660
+ return overlap / Math.max(queryTokens.length, 1);
661
+ }
662
+
663
+ function tokenize(value: string) {
664
+ return dedupeStrings(
665
+ value
666
+ .toLowerCase()
667
+ .replace(/[^a-z0-9\s]/g, " ")
668
+ .split(/\s+/)
669
+ .map((entry) => entry.trim())
670
+ .filter((entry) => entry.length >= 3)
671
+ );
672
+ }
673
+
674
+ function dedupeStrings(values: string[]) {
675
+ return Array.from(new Set(values));
676
+ }
677
+
678
+ function canonicalizeFact(value: string) {
679
+ return collapseWhitespace(value)
680
+ .toLowerCase()
681
+ .replace(/[^a-z0-9\s]/g, "")
682
+ .trim();
683
+ }
684
+
685
+ function areFactsEquivalent(left: string, right: string) {
686
+ return canonicalizeFact(left) === canonicalizeFact(right);
687
+ }
688
+
689
+ function clampConfidence(value: number | null) {
690
+ if (value === null) {
691
+ return null;
692
+ }
693
+ return Math.min(1, Math.max(0, value));
694
+ }
695
+
221
696
  async function walkFiles(root: string, maxFiles: number) {
222
697
  const collected: string[] = [];
223
698
  const queue = [root];
@@ -133,7 +133,12 @@ const builtinExecutors: Record<string, BuiltinPluginExecutor> = {
133
133
  blockers: [],
134
134
  diagnostics: {
135
135
  runId: context.runId,
136
- summaryPresent: typeof context.summary === "string" && context.summary.trim().length > 0
136
+ summaryPresent: typeof context.summary === "string" && context.summary.trim().length > 0,
137
+ usefulnessScore: scoreMemorySummaryUsefulness(context.summary ?? ""),
138
+ outcomeKind:
139
+ context.outcome && typeof context.outcome === "object" && "kind" in context.outcome
140
+ ? String((context.outcome as Record<string, unknown>).kind ?? "")
141
+ : ""
137
142
  }
138
143
  }),
139
144
  "queue-publisher": async (context) => ({
@@ -158,6 +163,33 @@ const builtinExecutors: Record<string, BuiltinPluginExecutor> = {
158
163
  })
159
164
  };
160
165
 
166
+ function scoreMemorySummaryUsefulness(summary: string) {
167
+ const normalized = summary.trim();
168
+ if (!normalized) {
169
+ return 0;
170
+ }
171
+ const tokenCount = normalized
172
+ .toLowerCase()
173
+ .replace(/[^a-z0-9\s]/g, " ")
174
+ .split(/\s+/)
175
+ .filter((entry) => entry.length >= 3).length;
176
+ const evidenceTerms = /\b(test|validated|verified|implemented|deployed|fixed|refactor|migration|metric)\b/i.test(normalized);
177
+ const blockersTerms = /\b(blocked|failed|unknown|maybe)\b/i.test(normalized);
178
+ let score = 0.3;
179
+ if (tokenCount >= 20) {
180
+ score += 0.3;
181
+ } else if (tokenCount >= 8) {
182
+ score += 0.15;
183
+ }
184
+ if (evidenceTerms) {
185
+ score += 0.25;
186
+ }
187
+ if (!blockersTerms) {
188
+ score += 0.15;
189
+ }
190
+ return Number(Math.min(1, Math.max(0, score)).toFixed(3));
191
+ }
192
+
161
193
  export function pluginSystemEnabled() {
162
194
  const disabled = process.env.BOPO_PLUGIN_SYSTEM_DISABLED;
163
195
  if (disabled === "1" || disabled === "true") {