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.
- package/package.json +4 -4
- package/src/app.ts +11 -1
- package/src/http.ts +39 -0
- package/src/lib/comment-recipients.ts +105 -0
- package/src/lib/hiring-delegate.ts +7 -6
- package/src/lib/instance-paths.ts +11 -0
- package/src/realtime/attention.ts +47 -0
- package/src/realtime/governance.ts +11 -3
- package/src/realtime/heartbeat-runs.ts +33 -11
- package/src/realtime/hub.ts +34 -2
- package/src/realtime/office-space.ts +17 -1
- package/src/routes/agents.ts +81 -12
- package/src/routes/attention.ts +112 -0
- package/src/routes/companies.ts +13 -5
- package/src/routes/goals.ts +10 -2
- package/src/routes/governance.ts +85 -2
- package/src/routes/heartbeats.ts +81 -43
- package/src/routes/issues.ts +293 -62
- package/src/routes/observability.ts +219 -10
- package/src/routes/projects.ts +7 -2
- package/src/scripts/onboard-seed.ts +8 -7
- package/src/server.ts +3 -1
- package/src/services/attention-service.ts +412 -0
- package/src/services/budget-service.ts +99 -2
- package/src/services/comment-recipient-dispatch-service.ts +158 -0
- package/src/services/governance-service.ts +237 -14
- package/src/services/heartbeat-queue-service.ts +318 -0
- package/src/services/heartbeat-service.ts +2341 -278
- package/src/services/memory-file-service.ts +510 -35
- package/src/services/plugin-runtime.ts +33 -1
- package/src/services/template-apply-service.ts +37 -2
- package/src/worker/scheduler.ts +46 -8
|
@@ -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:
|
|
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
|
|
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
|
|
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(
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
const
|
|
172
|
-
|
|
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") {
|