@desplega.ai/agent-swarm 1.92.0 → 1.92.2
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/README.md +1 -1
- package/openapi.json +276 -3
- package/package.json +6 -6
- package/plugin/skills/pages/SKILL.md +5 -2
- package/src/be/db.ts +416 -20
- package/src/be/memory/boot-reembed.ts +85 -0
- package/src/be/memory/constants.ts +44 -2
- package/src/be/memory/providers/openai-embedding.ts +15 -5
- package/src/be/memory/providers/sqlite-store.ts +325 -76
- package/src/be/memory/reranker.ts +35 -17
- package/src/be/memory/types.ts +43 -0
- package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
- package/src/be/migrations/085_script_runs_kind.sql +9 -0
- package/src/be/migrations/086_pages_default_authed.sql +64 -0
- package/src/be/migrations/087_skill_files.sql +19 -0
- package/src/be/modelsdev-cache.json +5622 -2543
- package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.ts +465 -0
- package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
- package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
- package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
- package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
- package/src/be/seed-scripts/index.ts +32 -4
- package/src/be/seed-skills/index.ts +0 -7
- package/src/be/skill-sync.ts +91 -7
- package/src/commands/runner.ts +6 -2
- package/src/heartbeat/templates.ts +20 -16
- package/src/http/index.ts +50 -7
- package/src/http/mcp-user.ts +23 -0
- package/src/http/mcp.ts +58 -0
- package/src/http/memory.ts +62 -0
- package/src/http/pages.ts +1 -1
- package/src/http/script-runs.ts +2 -0
- package/src/http/scripts.ts +39 -2
- package/src/http/skills.ts +225 -0
- package/src/providers/claude-adapter.ts +56 -24
- package/src/script-workflows/workflow-ctx.ts +7 -3
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +13 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +12 -0
- package/src/tests/claude-adapter-binary.test.ts +135 -81
- package/src/tests/create-page-tool.test.ts +19 -2
- package/src/tests/heartbeat-checklist.test.ts +36 -0
- package/src/tests/mcp-transport-gc.test.ts +58 -0
- package/src/tests/memory-e2e.test.ts +6 -6
- package/src/tests/memory-health-endpoint.test.ts +78 -0
- package/src/tests/memory-rater-e2e.test.ts +4 -5
- package/src/tests/memory-reranker.test.ts +135 -124
- package/src/tests/memory-store.test.ts +221 -1
- package/src/tests/memory.test.ts +13 -12
- package/src/tests/pages-http.test.ts +20 -2
- package/src/tests/pages-storage.test.ts +26 -0
- package/src/tests/scripts-mcp-e2e.test.ts +53 -0
- package/src/tests/seed-scripts.test.ts +328 -3
- package/src/tests/skill-files-http.test.ts +171 -0
- package/src/tests/skill-files.test.ts +162 -0
- package/src/tests/skill-get-file-tool.test.ts +110 -0
- package/src/tests/skill-sync.test.ts +125 -6
- package/src/tests/task-cascade-fail.test.ts +304 -0
- package/src/tools/create-page.ts +2 -2
- package/src/tools/skills/index.ts +1 -0
- package/src/tools/skills/skill-get-file.ts +80 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +20 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/templates/schedules/daily-blocker-digest/content.md +68 -54
- package/templates/schedules/daily-compounding-reflection/content.md +4 -4
- package/templates/schedules/daily-hn-briefing/content.md +5 -5
- package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
- package/templates/schedules/gtm-weekly-review/content.md +9 -9
- package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
- package/templates/skills/agentmail-sending/content.md +6 -7
- package/templates/skills/desloppify/content.md +8 -9
- package/templates/skills/jira-interaction/content.md +25 -33
- package/templates/skills/kapso-whatsapp/content.md +29 -30
- package/templates/skills/linear-interaction/content.md +8 -9
- package/templates/skills/profile-corruption-escalation/content.md +44 -85
- package/templates/skills/sprite-cli/content.md +4 -5
- package/templates/skills/turso-interaction/content.md +14 -17
- package/templates/skills/workflow-iterate/content.md +38 -391
- package/templates/skills/x-api-interactions/content.md +4 -6
- package/templates/workflows/llm-safe-release-context/config.json +13 -0
- package/templates/workflows/llm-safe-release-context/content.md +69 -0
- package/templates/skills/scheduled-task-resilience/config.json +0 -14
- package/templates/skills/scheduled-task-resilience/content.md +0 -95
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const argsSchema = z.object({
|
|
4
|
+
days: z
|
|
5
|
+
.number()
|
|
6
|
+
.int()
|
|
7
|
+
.positive()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe("Look back this many days for retrieval/rating data (default 30)"),
|
|
10
|
+
freshDays: z
|
|
11
|
+
.number()
|
|
12
|
+
.int()
|
|
13
|
+
.positive()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe("Memories younger than this are 'fresh' (default 14)"),
|
|
16
|
+
usefulnessThreshold: z
|
|
17
|
+
.number()
|
|
18
|
+
.min(0)
|
|
19
|
+
.max(1)
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("α/(α+β) cutoff for 'useful' (default 0.6)"),
|
|
22
|
+
backfillDays: z
|
|
23
|
+
.number()
|
|
24
|
+
.int()
|
|
25
|
+
.min(0)
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Backfill this many past daily points on first run (default 14; 0 to skip)"),
|
|
28
|
+
publishPage: z.boolean().optional().describe("Publish an authed HTML page (default true)"),
|
|
29
|
+
writeAgentFs: z.boolean().optional().describe("Write markdown report to agent-fs (default true)"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const KV_NS = "memory-eval";
|
|
33
|
+
const KV_KEY = "history";
|
|
34
|
+
|
|
35
|
+
type DailyPoint = {
|
|
36
|
+
date: string;
|
|
37
|
+
axis1Score: number;
|
|
38
|
+
axis1Total: number;
|
|
39
|
+
axis1Hits: number;
|
|
40
|
+
axis2AvgUsefulness: number | null;
|
|
41
|
+
axis2Retrievals: number | null;
|
|
42
|
+
axis2PrefCount: number | null;
|
|
43
|
+
axis3FreshScore: number;
|
|
44
|
+
axis3TotalRetrieved: number;
|
|
45
|
+
axis3ExpiredPct: number;
|
|
46
|
+
totalMemories: number;
|
|
47
|
+
searchableMemories: number;
|
|
48
|
+
expiredMemories: number;
|
|
49
|
+
backfilled: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function rowsToObjects(res: any): any[] {
|
|
53
|
+
const p = res?.data ?? res;
|
|
54
|
+
const cols: string[] = p?.columns ?? [];
|
|
55
|
+
return (p?.rows ?? []).map((r: any) =>
|
|
56
|
+
Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pct(part: number, total: number): number {
|
|
61
|
+
return total > 0 ? Math.round((part / total) * 1000) / 10 : 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function round2(n: number): number {
|
|
65
|
+
return Math.round(n * 100) / 100;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function esc(value: unknown): string {
|
|
69
|
+
const s = typeof value === "string" ? value : value == null ? "" : JSON.stringify(value);
|
|
70
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function computeBackfillPoint(
|
|
74
|
+
ctx: any,
|
|
75
|
+
refDate: string,
|
|
76
|
+
days: number,
|
|
77
|
+
freshDays: number,
|
|
78
|
+
usefulnessThreshold: number,
|
|
79
|
+
): Promise<DailyPoint> {
|
|
80
|
+
const w = `datetime('${refDate}','-${days} days')`;
|
|
81
|
+
const ref = `'${refDate}'`;
|
|
82
|
+
const freshW = `datetime('${refDate}','-${freshDays} days')`;
|
|
83
|
+
|
|
84
|
+
const axis1TotalRows = rowsToObjects(
|
|
85
|
+
await ctx.swarm.db_query({
|
|
86
|
+
sql: `SELECT count(*) as cnt FROM agent_tasks t
|
|
87
|
+
WHERE t.contextKey IS NOT NULL AND t.status = 'completed'
|
|
88
|
+
AND t.createdAt > ${w} AND t.createdAt <= ${ref}
|
|
89
|
+
AND EXISTS (
|
|
90
|
+
SELECT 1 FROM agent_tasks p
|
|
91
|
+
WHERE p.contextKey = t.contextKey AND p.id != t.id AND p.createdAt < t.createdAt
|
|
92
|
+
)`,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
const a1Total = Number(axis1TotalRows[0]?.cnt ?? 0);
|
|
96
|
+
|
|
97
|
+
let a1Hits = 0;
|
|
98
|
+
if (a1Total > 0) {
|
|
99
|
+
const axis1HitRows = rowsToObjects(
|
|
100
|
+
await ctx.swarm.db_query({
|
|
101
|
+
sql: `SELECT count(DISTINCT mr.taskId) as cnt FROM memory_retrieval mr
|
|
102
|
+
JOIN agent_memory m ON mr.memoryId = m.id
|
|
103
|
+
JOIN agent_tasks ct ON mr.taskId = ct.id
|
|
104
|
+
WHERE ct.contextKey IS NOT NULL AND ct.status = 'completed'
|
|
105
|
+
AND ct.createdAt > ${w} AND ct.createdAt <= ${ref}
|
|
106
|
+
AND m.sourceTaskId IS NOT NULL
|
|
107
|
+
AND m.alpha / nullif(m.alpha + m.beta, 0) > ${usefulnessThreshold}
|
|
108
|
+
AND EXISTS (
|
|
109
|
+
SELECT 1 FROM agent_tasks p
|
|
110
|
+
WHERE p.contextKey = ct.contextKey AND p.id = m.sourceTaskId AND p.createdAt < ct.createdAt
|
|
111
|
+
)
|
|
112
|
+
AND EXISTS (
|
|
113
|
+
SELECT 1 FROM agent_tasks e
|
|
114
|
+
WHERE e.contextKey = ct.contextKey AND e.id != ct.id AND e.createdAt < ct.createdAt
|
|
115
|
+
)`,
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
a1Hits = Number(axis1HitRows[0]?.cnt ?? 0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const axis3Rows = rowsToObjects(
|
|
122
|
+
await ctx.swarm.db_query({
|
|
123
|
+
sql: `SELECT
|
|
124
|
+
CASE
|
|
125
|
+
WHEN m.createdAt > ${freshW} THEN 'fresh'
|
|
126
|
+
WHEN m.expiresAt IS NOT NULL AND m.expiresAt < ${ref} THEN 'expired'
|
|
127
|
+
ELSE 'other'
|
|
128
|
+
END as bucket,
|
|
129
|
+
count(DISTINCT mr.memoryId) as uniqueMemories
|
|
130
|
+
FROM memory_retrieval mr
|
|
131
|
+
JOIN agent_memory m ON mr.memoryId = m.id
|
|
132
|
+
WHERE mr.retrievedAt > ${w} AND mr.retrievedAt <= ${ref}
|
|
133
|
+
GROUP BY bucket`,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
let totalRetrieved = 0;
|
|
138
|
+
let freshMem = 0;
|
|
139
|
+
let expiredMem = 0;
|
|
140
|
+
for (const r of axis3Rows) {
|
|
141
|
+
const n = Number(r.uniqueMemories ?? 0);
|
|
142
|
+
totalRetrieved += n;
|
|
143
|
+
if (r.bucket === "fresh") freshMem = n;
|
|
144
|
+
if (r.bucket === "expired") expiredMem = n;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const storeRows = rowsToObjects(
|
|
148
|
+
await ctx.swarm.db_query({
|
|
149
|
+
sql: `SELECT
|
|
150
|
+
count(*) as total,
|
|
151
|
+
sum(CASE WHEN expiresAt IS NULL OR expiresAt > ${ref} THEN 1 ELSE 0 END) as searchable,
|
|
152
|
+
sum(CASE WHEN expiresAt IS NOT NULL AND expiresAt <= ${ref} THEN 1 ELSE 0 END) as expired
|
|
153
|
+
FROM agent_memory WHERE createdAt <= ${ref}`,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
const store = storeRows[0] ?? {};
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
date: refDate,
|
|
160
|
+
axis1Score: pct(a1Hits, a1Total),
|
|
161
|
+
axis1Total: a1Total,
|
|
162
|
+
axis1Hits: a1Hits,
|
|
163
|
+
axis2AvgUsefulness: null,
|
|
164
|
+
axis2Retrievals: null,
|
|
165
|
+
axis2PrefCount: null,
|
|
166
|
+
axis3FreshScore: pct(freshMem, totalRetrieved),
|
|
167
|
+
axis3TotalRetrieved: totalRetrieved,
|
|
168
|
+
axis3ExpiredPct: pct(expiredMem, totalRetrieved),
|
|
169
|
+
totalMemories: Number(store.total ?? 0),
|
|
170
|
+
searchableMemories: Number(store.searchable ?? 0),
|
|
171
|
+
expiredMemories: Number(store.expired ?? 0),
|
|
172
|
+
backfilled: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function reportToDailyPoint(date: string, report: any): DailyPoint {
|
|
177
|
+
return {
|
|
178
|
+
date,
|
|
179
|
+
axis1Score: report.axis1.score,
|
|
180
|
+
axis1Total: report.axis1.followUpTasks,
|
|
181
|
+
axis1Hits: report.axis1.tasksWithUsefulCarryForward,
|
|
182
|
+
axis2AvgUsefulness: report.axis2.avgUsefulness,
|
|
183
|
+
axis2Retrievals: report.axis2.totalRetrievals,
|
|
184
|
+
axis2PrefCount: report.axis2.totalPreferenceMemories,
|
|
185
|
+
axis3FreshScore: report.axis3.freshScore,
|
|
186
|
+
axis3TotalRetrieved: report.axis3.totalUniqueMemoriesRetrieved,
|
|
187
|
+
axis3ExpiredPct: report.axis3.buckets.expired.pctOfRetrieved,
|
|
188
|
+
totalMemories: report.storeSnapshot.totalMemories,
|
|
189
|
+
searchableMemories: report.storeSnapshot.totalMemories - report.storeSnapshot.expiredMemories,
|
|
190
|
+
expiredMemories: report.storeSnapshot.expiredMemories,
|
|
191
|
+
backfilled: false,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 3-axis memory quality evaluation with over-time trend tracking and KV-backed historical backfill. */
|
|
196
|
+
export default async function memoryEval(args: any, ctx: any) {
|
|
197
|
+
const parsed = argsSchema.safeParse(args || {});
|
|
198
|
+
if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
|
|
199
|
+
|
|
200
|
+
const days = parsed.data.days || 30;
|
|
201
|
+
const freshDays = parsed.data.freshDays || 14;
|
|
202
|
+
const usefulnessThreshold = parsed.data.usefulnessThreshold ?? 0.6;
|
|
203
|
+
const backfillDays = parsed.data.backfillDays ?? 14;
|
|
204
|
+
const publishPage = parsed.data.publishPage !== false;
|
|
205
|
+
const writeAgentFs = parsed.data.writeAgentFs !== false;
|
|
206
|
+
|
|
207
|
+
const now = new Date().toISOString();
|
|
208
|
+
const today = now.slice(0, 10);
|
|
209
|
+
const report: any = { generatedAt: now, days, freshDays, usefulnessThreshold };
|
|
210
|
+
|
|
211
|
+
// ─── Axis 1: Carry-forward context ───────────────────────────────────
|
|
212
|
+
const w = `datetime('now','-${days} days')`;
|
|
213
|
+
|
|
214
|
+
const axis1FollowUps = rowsToObjects(
|
|
215
|
+
await ctx.swarm.db_query({
|
|
216
|
+
sql: `SELECT t.id as taskId, t.contextKey
|
|
217
|
+
FROM agent_tasks t
|
|
218
|
+
WHERE t.contextKey IS NOT NULL
|
|
219
|
+
AND t.status = 'completed'
|
|
220
|
+
AND t.createdAt > ${w}
|
|
221
|
+
AND EXISTS (
|
|
222
|
+
SELECT 1 FROM agent_tasks prior
|
|
223
|
+
WHERE prior.contextKey = t.contextKey
|
|
224
|
+
AND prior.id != t.id
|
|
225
|
+
AND prior.createdAt < t.createdAt
|
|
226
|
+
)`,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
let axis1HitCount = 0;
|
|
231
|
+
const axis1Total = axis1FollowUps.length;
|
|
232
|
+
|
|
233
|
+
if (axis1Total > 0) {
|
|
234
|
+
const axis1Hits = rowsToObjects(
|
|
235
|
+
await ctx.swarm.db_query({
|
|
236
|
+
sql: `SELECT DISTINCT mr.taskId
|
|
237
|
+
FROM memory_retrieval mr
|
|
238
|
+
JOIN agent_memory m ON mr.memoryId = m.id
|
|
239
|
+
JOIN agent_tasks current_task ON mr.taskId = current_task.id
|
|
240
|
+
WHERE current_task.contextKey IS NOT NULL
|
|
241
|
+
AND current_task.status = 'completed'
|
|
242
|
+
AND current_task.createdAt > ${w}
|
|
243
|
+
AND m.sourceTaskId IS NOT NULL
|
|
244
|
+
AND m.alpha / nullif(m.alpha + m.beta, 0) > ${usefulnessThreshold}
|
|
245
|
+
AND EXISTS (
|
|
246
|
+
SELECT 1 FROM agent_tasks prior
|
|
247
|
+
WHERE prior.contextKey = current_task.contextKey
|
|
248
|
+
AND prior.id = m.sourceTaskId
|
|
249
|
+
AND prior.createdAt < current_task.createdAt
|
|
250
|
+
)
|
|
251
|
+
AND EXISTS (
|
|
252
|
+
SELECT 1 FROM agent_tasks earlier
|
|
253
|
+
WHERE earlier.contextKey = current_task.contextKey
|
|
254
|
+
AND earlier.id != current_task.id
|
|
255
|
+
AND earlier.createdAt < current_task.createdAt
|
|
256
|
+
)`,
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
axis1HitCount = axis1Hits.length;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
report.axis1 = {
|
|
263
|
+
name: "Carry-forward context",
|
|
264
|
+
description:
|
|
265
|
+
"Fraction of contextKey-chained follow-up tasks that retrieve ≥1 useful memory from a prior task in the same chain.",
|
|
266
|
+
followUpTasks: axis1Total,
|
|
267
|
+
tasksWithUsefulCarryForward: axis1HitCount,
|
|
268
|
+
score: pct(axis1HitCount, axis1Total),
|
|
269
|
+
softTarget: 60,
|
|
270
|
+
unit: "%",
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// ─── Axis 2: Follow preferences & constraints ───────────────────────
|
|
274
|
+
|
|
275
|
+
const prefMemories = rowsToObjects(
|
|
276
|
+
await ctx.swarm.db_query({
|
|
277
|
+
sql: `SELECT id, name, sourcePath, alpha, beta, accessCount,
|
|
278
|
+
CASE WHEN (alpha + beta) > 0 THEN round(alpha / (alpha + beta), 4) ELSE 0.5 END as usefulness
|
|
279
|
+
FROM agent_memory
|
|
280
|
+
WHERE source = 'file_index'
|
|
281
|
+
AND (sourcePath LIKE '%CLAUDE.md'
|
|
282
|
+
OR sourcePath LIKE '%IDENTITY.md'
|
|
283
|
+
OR sourcePath LIKE '%SOUL.md'
|
|
284
|
+
OR sourcePath LIKE '%TOOLS.md')`,
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const prefTotal = prefMemories.length;
|
|
289
|
+
const prefWithAccess = prefMemories.filter((m: any) => (m.accessCount ?? 0) > 0).length;
|
|
290
|
+
const prefUsefulnessValues = prefMemories.map((m: any) => Number(m.usefulness ?? 0.5));
|
|
291
|
+
const prefAvgUsefulness =
|
|
292
|
+
prefUsefulnessValues.length > 0
|
|
293
|
+
? round2(prefUsefulnessValues.reduce((s: number, v: number) => s + v, 0) / prefUsefulnessValues.length)
|
|
294
|
+
: 0;
|
|
295
|
+
const prefTotalAccess = prefMemories.reduce((s: number, m: any) => s + (m.accessCount ?? 0), 0);
|
|
296
|
+
|
|
297
|
+
const prefRatings = rowsToObjects(
|
|
298
|
+
await ctx.swarm.db_query({
|
|
299
|
+
sql: `SELECT mr.source as raterSource, count(*) as cnt,
|
|
300
|
+
round(avg(mr.signal), 4) as avgSignal
|
|
301
|
+
FROM memory_rating mr
|
|
302
|
+
JOIN agent_memory m ON mr.memoryId = m.id
|
|
303
|
+
WHERE m.source = 'file_index'
|
|
304
|
+
AND (m.sourcePath LIKE '%CLAUDE.md'
|
|
305
|
+
OR m.sourcePath LIKE '%IDENTITY.md'
|
|
306
|
+
OR m.sourcePath LIKE '%SOUL.md'
|
|
307
|
+
OR m.sourcePath LIKE '%TOOLS.md')
|
|
308
|
+
GROUP BY mr.source`,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const prefByPath = rowsToObjects(
|
|
313
|
+
await ctx.swarm.db_query({
|
|
314
|
+
sql: `SELECT
|
|
315
|
+
CASE
|
|
316
|
+
WHEN sourcePath LIKE '%CLAUDE.md' THEN 'CLAUDE.md'
|
|
317
|
+
WHEN sourcePath LIKE '%IDENTITY.md' THEN 'IDENTITY.md'
|
|
318
|
+
WHEN sourcePath LIKE '%SOUL.md' THEN 'SOUL.md'
|
|
319
|
+
WHEN sourcePath LIKE '%TOOLS.md' THEN 'TOOLS.md'
|
|
320
|
+
ELSE 'other'
|
|
321
|
+
END as fileType,
|
|
322
|
+
count(*) as cnt,
|
|
323
|
+
sum(accessCount) as totalAccess,
|
|
324
|
+
round(avg(CASE WHEN (alpha + beta) > 0 THEN alpha / (alpha + beta) ELSE 0.5 END), 4) as avgUsefulness
|
|
325
|
+
FROM agent_memory
|
|
326
|
+
WHERE source = 'file_index'
|
|
327
|
+
AND (sourcePath LIKE '%CLAUDE.md'
|
|
328
|
+
OR sourcePath LIKE '%IDENTITY.md'
|
|
329
|
+
OR sourcePath LIKE '%SOUL.md'
|
|
330
|
+
OR sourcePath LIKE '%TOOLS.md')
|
|
331
|
+
GROUP BY fileType`,
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
report.axis2 = {
|
|
336
|
+
name: "Follow preferences & constraints",
|
|
337
|
+
description:
|
|
338
|
+
"Retrieval rate and LlmRater scores for file_index memories from CLAUDE.md/IDENTITY.md/SOUL.md/TOOLS.md (the 'preference memories').",
|
|
339
|
+
totalPreferenceMemories: prefTotal,
|
|
340
|
+
memoriesEverRetrieved: prefWithAccess,
|
|
341
|
+
retrievalRate: pct(prefWithAccess, prefTotal),
|
|
342
|
+
totalRetrievals: prefTotalAccess,
|
|
343
|
+
avgUsefulness: prefAvgUsefulness,
|
|
344
|
+
raterBreakdown: prefRatings.map((r: any) => ({
|
|
345
|
+
rater: r.raterSource,
|
|
346
|
+
ratings: r.cnt,
|
|
347
|
+
avgSignal: Number(r.avgSignal ?? 0),
|
|
348
|
+
})),
|
|
349
|
+
byFile: prefByPath.map((r: any) => ({
|
|
350
|
+
file: r.fileType,
|
|
351
|
+
memories: r.cnt,
|
|
352
|
+
totalAccess: r.totalAccess ?? 0,
|
|
353
|
+
avgUsefulness: Number(r.avgUsefulness ?? 0),
|
|
354
|
+
})),
|
|
355
|
+
softTarget: "High retrieval rate + usefulness > 0.6 on preference memories",
|
|
356
|
+
unit: "composite",
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// ─── Axis 3: Stay current over time ──────────────────────────────────
|
|
360
|
+
|
|
361
|
+
const freshW = `datetime('now','-${freshDays} days')`;
|
|
362
|
+
|
|
363
|
+
const axis3Buckets = rowsToObjects(
|
|
364
|
+
await ctx.swarm.db_query({
|
|
365
|
+
sql: `SELECT
|
|
366
|
+
CASE
|
|
367
|
+
WHEN m.createdAt > ${freshW} THEN 'fresh'
|
|
368
|
+
WHEN m.expiresAt IS NOT NULL AND m.expiresAt < datetime('now') THEN 'expired'
|
|
369
|
+
WHEN m.accessedAt < datetime('now','-${freshDays} days') AND m.accessCount > 0 THEN 'aging'
|
|
370
|
+
WHEN m.accessCount = 0 AND m.createdAt < ${freshW} THEN 'stranded'
|
|
371
|
+
ELSE 'aging'
|
|
372
|
+
END as bucket,
|
|
373
|
+
count(DISTINCT mr.memoryId) as uniqueMemories,
|
|
374
|
+
count(*) as retrievals
|
|
375
|
+
FROM memory_retrieval mr
|
|
376
|
+
JOIN agent_memory m ON mr.memoryId = m.id
|
|
377
|
+
WHERE mr.retrievedAt > ${w}
|
|
378
|
+
GROUP BY bucket`,
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const bucketMap: Record<string, { uniqueMemories: number; retrievals: number }> = {};
|
|
383
|
+
let totalRetrievedMemories = 0;
|
|
384
|
+
let totalRetrievals = 0;
|
|
385
|
+
for (const b of axis3Buckets) {
|
|
386
|
+
const mem = Number(b.uniqueMemories ?? 0);
|
|
387
|
+
const ret = Number(b.retrievals ?? 0);
|
|
388
|
+
bucketMap[b.bucket] = { uniqueMemories: mem, retrievals: ret };
|
|
389
|
+
totalRetrievedMemories += mem;
|
|
390
|
+
totalRetrievals += ret;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const freshMem = bucketMap.fresh?.uniqueMemories ?? 0;
|
|
394
|
+
const freshScore = pct(freshMem, totalRetrievedMemories);
|
|
395
|
+
|
|
396
|
+
const staleRefCount = rowsToObjects(
|
|
397
|
+
await ctx.swarm.db_query({
|
|
398
|
+
sql: `SELECT count(DISTINCT mr.memoryId) as cnt
|
|
399
|
+
FROM memory_retrieval mr
|
|
400
|
+
JOIN agent_memory m ON mr.memoryId = m.id
|
|
401
|
+
WHERE mr.retrievedAt > ${w}
|
|
402
|
+
AND m.expiresAt IS NOT NULL
|
|
403
|
+
AND m.expiresAt < datetime('now')`,
|
|
404
|
+
}),
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
report.axis3 = {
|
|
408
|
+
name: "Stay current over time",
|
|
409
|
+
description:
|
|
410
|
+
"Distribution of retrieved memories across freshness buckets. Target: >80% of retrieved memories are fresh.",
|
|
411
|
+
window: `${days} days`,
|
|
412
|
+
freshDays,
|
|
413
|
+
totalUniqueMemoriesRetrieved: totalRetrievedMemories,
|
|
414
|
+
totalRetrievals,
|
|
415
|
+
buckets: {
|
|
416
|
+
fresh: {
|
|
417
|
+
label: `Created within last ${freshDays} days`,
|
|
418
|
+
uniqueMemories: freshMem,
|
|
419
|
+
retrievals: bucketMap.fresh?.retrievals ?? 0,
|
|
420
|
+
pctOfRetrieved: freshScore,
|
|
421
|
+
},
|
|
422
|
+
aging: {
|
|
423
|
+
label: `Older than ${freshDays} days, still active`,
|
|
424
|
+
uniqueMemories: bucketMap.aging?.uniqueMemories ?? 0,
|
|
425
|
+
retrievals: bucketMap.aging?.retrievals ?? 0,
|
|
426
|
+
pctOfRetrieved: pct(bucketMap.aging?.uniqueMemories ?? 0, totalRetrievedMemories),
|
|
427
|
+
},
|
|
428
|
+
stranded: {
|
|
429
|
+
label: "Old, never accessed",
|
|
430
|
+
uniqueMemories: bucketMap.stranded?.uniqueMemories ?? 0,
|
|
431
|
+
retrievals: bucketMap.stranded?.retrievals ?? 0,
|
|
432
|
+
pctOfRetrieved: pct(bucketMap.stranded?.uniqueMemories ?? 0, totalRetrievedMemories),
|
|
433
|
+
},
|
|
434
|
+
expired: {
|
|
435
|
+
label: "Past TTL / expired",
|
|
436
|
+
uniqueMemories: bucketMap.expired?.uniqueMemories ?? 0,
|
|
437
|
+
retrievals: bucketMap.expired?.retrievals ?? 0,
|
|
438
|
+
pctOfRetrieved: pct(bucketMap.expired?.uniqueMemories ?? 0, totalRetrievedMemories),
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
staleReferentCount: Number(staleRefCount[0]?.cnt ?? 0),
|
|
442
|
+
freshScore,
|
|
443
|
+
softTarget: 80,
|
|
444
|
+
unit: "%",
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// ─── Store totals ────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
const totalMemoryRows = rowsToObjects(
|
|
450
|
+
await ctx.swarm.db_query({
|
|
451
|
+
sql: `SELECT count(*) as total,
|
|
452
|
+
sum(CASE WHEN accessCount > 0 THEN 1 ELSE 0 END) as accessed,
|
|
453
|
+
sum(CASE WHEN expiresAt IS NOT NULL AND expiresAt < datetime('now') THEN 1 ELSE 0 END) as expired
|
|
454
|
+
FROM agent_memory`,
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const storeTotals = totalMemoryRows[0] ?? {};
|
|
459
|
+
|
|
460
|
+
report.storeSnapshot = {
|
|
461
|
+
totalMemories: Number(storeTotals.total ?? 0),
|
|
462
|
+
memoriesEverAccessed: Number(storeTotals.accessed ?? 0),
|
|
463
|
+
expiredMemories: Number(storeTotals.expired ?? 0),
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// ─── History persistence (KV) ───────────────────────────────────────
|
|
467
|
+
|
|
468
|
+
let history: DailyPoint[] = [];
|
|
469
|
+
try {
|
|
470
|
+
const kvResult = await ctx.swarm.kv_get({ key: KV_KEY, namespace: KV_NS });
|
|
471
|
+
const payload = kvResult?.data ?? kvResult;
|
|
472
|
+
if (Array.isArray(payload?.value)) history = payload.value;
|
|
473
|
+
} catch (_e) {
|
|
474
|
+
// first run — no history yet
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const todayPoint = reportToDailyPoint(today, report);
|
|
478
|
+
|
|
479
|
+
if (backfillDays > 0) {
|
|
480
|
+
const existingDates = new Set(history.map((p) => p.date));
|
|
481
|
+
const toBackfill: string[] = [];
|
|
482
|
+
for (let i = 1; i <= backfillDays; i++) {
|
|
483
|
+
const d = new Date();
|
|
484
|
+
d.setDate(d.getDate() - i);
|
|
485
|
+
const dateStr = d.toISOString().slice(0, 10);
|
|
486
|
+
if (!existingDates.has(dateStr)) toBackfill.push(dateStr);
|
|
487
|
+
}
|
|
488
|
+
for (const dateStr of toBackfill) {
|
|
489
|
+
try {
|
|
490
|
+
const pt = await computeBackfillPoint(ctx, dateStr, days, freshDays, usefulnessThreshold);
|
|
491
|
+
history.push(pt);
|
|
492
|
+
} catch (_e) {
|
|
493
|
+
// skip dates that fail
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const idx = history.findIndex((p) => p.date === today);
|
|
499
|
+
if (idx >= 0) history[idx] = todayPoint;
|
|
500
|
+
else history.push(todayPoint);
|
|
501
|
+
|
|
502
|
+
history.sort((a, b) => a.date.localeCompare(b.date));
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
await ctx.swarm.kv_set({ key: KV_KEY, namespace: KV_NS, value: history });
|
|
506
|
+
} catch (_e) {
|
|
507
|
+
report.kvError = "kv_set failed — history not persisted this run";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
report.historyPoints = history.length;
|
|
511
|
+
|
|
512
|
+
// ─── Markdown report ─────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
const md = buildMarkdownReport(report);
|
|
515
|
+
|
|
516
|
+
if (writeAgentFs) {
|
|
517
|
+
try {
|
|
518
|
+
await ctx.swarm.agent_fs_write({
|
|
519
|
+
path: `docs/memory-eval-${today}.md`,
|
|
520
|
+
content: md,
|
|
521
|
+
message: `Memory eval report ${today}`,
|
|
522
|
+
org: "648a5f3c-35c8-4f11-8673-b89de52cd6bd",
|
|
523
|
+
});
|
|
524
|
+
} catch (_e) {
|
|
525
|
+
report.agentFsError = "agent_fs_write failed — report still available in page + return value";
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ─── Publish dashboard page ──────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
if (publishPage) {
|
|
532
|
+
const html = buildDashboardHtml(report, history);
|
|
533
|
+
try {
|
|
534
|
+
const response = await ctx.swarm.page_create({
|
|
535
|
+
title: "Memory Eval — 3-Axis Dashboard",
|
|
536
|
+
slug: "memory-eval",
|
|
537
|
+
description: `3-axis memory quality dashboard: carry-forward ${report.axis1.score}%, preferences usefulness ${report.axis2.avgUsefulness}, freshness ${report.axis3.freshScore}%. ${history.length} data points.`,
|
|
538
|
+
contentType: "text/html",
|
|
539
|
+
authMode: "authed",
|
|
540
|
+
body: html,
|
|
541
|
+
});
|
|
542
|
+
const payload = response?.data ?? response;
|
|
543
|
+
report.page = {
|
|
544
|
+
id: payload?.id ?? payload?.page?.id ?? null,
|
|
545
|
+
appUrl: payload?.appUrl ?? payload?.app_url ?? null,
|
|
546
|
+
apiUrl: payload?.apiUrl ?? payload?.api_url ?? null,
|
|
547
|
+
version: payload?.version ?? payload?.page?.version ?? null,
|
|
548
|
+
};
|
|
549
|
+
} catch (_e) {
|
|
550
|
+
report.pageError = "page_create failed";
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return report;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function buildMarkdownReport(r: any): string {
|
|
558
|
+
const lines: string[] = [];
|
|
559
|
+
const ln = (s = "") => lines.push(s);
|
|
560
|
+
|
|
561
|
+
ln(`# Memory Eval — 3-Axis Report`);
|
|
562
|
+
ln(`> Generated: ${r.generatedAt} · Window: ${r.days} days · History: ${r.historyPoints ?? 1} points`);
|
|
563
|
+
ln();
|
|
564
|
+
ln(`## Summary`);
|
|
565
|
+
ln();
|
|
566
|
+
ln(`| Axis | Score | Soft Target |`);
|
|
567
|
+
ln(`|------|-------|-------------|`);
|
|
568
|
+
ln(`| 1 — Carry-forward context | **${r.axis1.score}%** | ${r.axis1.softTarget}% |`);
|
|
569
|
+
ln(`| 2 — Preferences avg usefulness | **${r.axis2.avgUsefulness}** | >0.6 |`);
|
|
570
|
+
ln(`| 3 — Freshness (% fresh retrieved) | **${r.axis3.freshScore}%** | ${r.axis3.softTarget}% |`);
|
|
571
|
+
ln();
|
|
572
|
+
ln(
|
|
573
|
+
`**Store:** ${r.storeSnapshot.totalMemories} total memories, ${r.storeSnapshot.memoriesEverAccessed} ever accessed, ${r.storeSnapshot.expiredMemories} expired.`,
|
|
574
|
+
);
|
|
575
|
+
ln();
|
|
576
|
+
|
|
577
|
+
ln(`---`);
|
|
578
|
+
ln(`## Axis 1 — Carry-forward Context`);
|
|
579
|
+
ln();
|
|
580
|
+
ln(`*${r.axis1.description}*`);
|
|
581
|
+
ln();
|
|
582
|
+
ln(`- Follow-up tasks in window: **${r.axis1.followUpTasks}**`);
|
|
583
|
+
ln(`- Tasks with useful carry-forward: **${r.axis1.tasksWithUsefulCarryForward}**`);
|
|
584
|
+
ln(`- Score: **${r.axis1.score}%** (soft target: ${r.axis1.softTarget}%)`);
|
|
585
|
+
ln();
|
|
586
|
+
|
|
587
|
+
ln(`---`);
|
|
588
|
+
ln(`## Axis 2 — Follow Preferences & Constraints`);
|
|
589
|
+
ln();
|
|
590
|
+
ln(`*${r.axis2.description}*`);
|
|
591
|
+
ln();
|
|
592
|
+
ln(`- Preference memories in store: **${r.axis2.totalPreferenceMemories}**`);
|
|
593
|
+
ln(`- Ever retrieved: **${r.axis2.memoriesEverRetrieved}** (${r.axis2.retrievalRate}%)`);
|
|
594
|
+
ln(`- Total retrievals: **${r.axis2.totalRetrievals}**`);
|
|
595
|
+
ln(`- Avg usefulness: **${r.axis2.avgUsefulness}**`);
|
|
596
|
+
ln();
|
|
597
|
+
ln(`### By File`);
|
|
598
|
+
ln();
|
|
599
|
+
ln(`| File | Memories | Total Access | Avg Usefulness |`);
|
|
600
|
+
ln(`|------|----------|--------------|----------------|`);
|
|
601
|
+
for (const f of r.axis2.byFile) {
|
|
602
|
+
ln(`| ${f.file} | ${f.memories} | ${f.totalAccess} | ${round2(f.avgUsefulness)} |`);
|
|
603
|
+
}
|
|
604
|
+
ln();
|
|
605
|
+
if (r.axis2.raterBreakdown.length > 0) {
|
|
606
|
+
ln(`### Rater Breakdown`);
|
|
607
|
+
ln();
|
|
608
|
+
ln(`| Rater | Ratings | Avg Signal |`);
|
|
609
|
+
ln(`|-------|---------|------------|`);
|
|
610
|
+
for (const rb of r.axis2.raterBreakdown) {
|
|
611
|
+
ln(`| ${rb.rater} | ${rb.ratings} | ${round2(rb.avgSignal)} |`);
|
|
612
|
+
}
|
|
613
|
+
ln();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
ln(`---`);
|
|
617
|
+
ln(`## Axis 3 — Stay Current Over Time`);
|
|
618
|
+
ln();
|
|
619
|
+
ln(`*${r.axis3.description}*`);
|
|
620
|
+
ln();
|
|
621
|
+
ln(`- Window: **${r.axis3.window}** · Fresh threshold: **${r.axis3.freshDays} days**`);
|
|
622
|
+
ln(`- Unique memories retrieved: **${r.axis3.totalUniqueMemoriesRetrieved}**`);
|
|
623
|
+
ln(`- Fresh score: **${r.axis3.freshScore}%** (soft target: ${r.axis3.softTarget}%)`);
|
|
624
|
+
ln(`- Stale referents (expired but still retrieved): **${r.axis3.staleReferentCount}**`);
|
|
625
|
+
ln();
|
|
626
|
+
ln(`### Freshness Buckets`);
|
|
627
|
+
ln();
|
|
628
|
+
ln(`| Bucket | Unique Memories | Retrievals | % of Retrieved |`);
|
|
629
|
+
ln(`|--------|-----------------|------------|----------------|`);
|
|
630
|
+
for (const [k, v] of Object.entries(r.axis3.buckets) as [string, any][]) {
|
|
631
|
+
ln(`| ${k} (${v.label}) | ${v.uniqueMemories} | ${v.retrievals} | ${v.pctOfRetrieved}% |`);
|
|
632
|
+
}
|
|
633
|
+
ln();
|
|
634
|
+
|
|
635
|
+
return lines.join("\n");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function buildDashboardHtml(report: any, history: DailyPoint[]): string {
|
|
639
|
+
const fmtNum = (v: unknown) =>
|
|
640
|
+
typeof v === "number" ? new Intl.NumberFormat("en-US").format(v) : String(v ?? "");
|
|
641
|
+
const dataJson = JSON.stringify(history);
|
|
642
|
+
|
|
643
|
+
const metricCards = [
|
|
644
|
+
["Carry-forward", `${report.axis1.score}%`],
|
|
645
|
+
["Pref Usefulness", report.axis2.avgUsefulness],
|
|
646
|
+
["Freshness", `${report.axis3.freshScore}%`],
|
|
647
|
+
["Total Memories", report.storeSnapshot.totalMemories],
|
|
648
|
+
]
|
|
649
|
+
.map(
|
|
650
|
+
([label, value]) =>
|
|
651
|
+
`<div class="metric"><strong>${esc(fmtNum(value))}</strong><span>${esc(label)}</span></div>`,
|
|
652
|
+
)
|
|
653
|
+
.join("");
|
|
654
|
+
|
|
655
|
+
const findingSections = buildFindingsHtml(report);
|
|
656
|
+
|
|
657
|
+
return `<!doctype html>
|
|
658
|
+
<html lang="en">
|
|
659
|
+
<head>
|
|
660
|
+
<meta charset="utf-8">
|
|
661
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
662
|
+
<meta name="theme-color" content="#f5f2ea">
|
|
663
|
+
<title>Memory Eval — 3-Axis Dashboard</title>
|
|
664
|
+
<style>
|
|
665
|
+
:root {
|
|
666
|
+
color-scheme: light;
|
|
667
|
+
--bg: #f5f2ea; --panel: #ffffff; --ink: #18181b; --muted: #5f6368;
|
|
668
|
+
--line: #ded8cb; --accent: #255c99;
|
|
669
|
+
--danger: #b42318; --danger-bg: #fff1f0;
|
|
670
|
+
--warn: #b54708; --warn-bg: #fff7ed;
|
|
671
|
+
--note: #175cd3; --note-bg: #eff6ff;
|
|
672
|
+
--low: #067647; --low-bg: #ecfdf3;
|
|
673
|
+
--radius: 8px;
|
|
674
|
+
--shadow: 0 1px 2px rgba(24,24,27,.06), 0 14px 36px rgba(24,24,27,.07);
|
|
675
|
+
}
|
|
676
|
+
* { box-sizing: border-box; }
|
|
677
|
+
body {
|
|
678
|
+
margin: 0; background: var(--bg); color: var(--ink);
|
|
679
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
680
|
+
font-size: 16px; line-height: 1.55;
|
|
681
|
+
}
|
|
682
|
+
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; }
|
|
683
|
+
header { margin-bottom: 28px; }
|
|
684
|
+
.eyebrow {
|
|
685
|
+
margin: 0 0 8px; color: var(--muted); font-size: 13px; font-weight: 750;
|
|
686
|
+
letter-spacing: .08em; text-transform: uppercase;
|
|
687
|
+
}
|
|
688
|
+
h1 { margin: 0; max-width: 860px; font-size: clamp(2rem,4vw,3rem); line-height: 1.05; }
|
|
689
|
+
.lede { max-width: 780px; margin: 16px 0 0; color: var(--muted); font-size: 18px; }
|
|
690
|
+
.metrics {
|
|
691
|
+
display: grid; grid-template-columns: repeat(4, minmax(0,1fr));
|
|
692
|
+
gap: 12px; margin: 32px 0;
|
|
693
|
+
}
|
|
694
|
+
.metric, .section, .chart-card, details {
|
|
695
|
+
background: var(--panel); border: 1px solid var(--line);
|
|
696
|
+
border-radius: var(--radius); box-shadow: var(--shadow);
|
|
697
|
+
}
|
|
698
|
+
.metric { padding: 18px; }
|
|
699
|
+
.metric strong { display: block; font-size: 32px; line-height: 1; font-variant-numeric: tabular-nums; }
|
|
700
|
+
.metric span { display: block; margin-top: 8px; color: var(--muted); font-size: 13px; font-weight: 650; }
|
|
701
|
+
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
|
|
702
|
+
.chart-card { padding: 20px; }
|
|
703
|
+
.chart-card h2 { margin: 0 0 4px; font-size: 17px; line-height: 1.3; }
|
|
704
|
+
.chart-note { margin: 8px 0 0; color: var(--muted); font-size: 12px; font-style: italic; }
|
|
705
|
+
.chart-wrap { position: relative; height: 240px; margin-top: 12px; }
|
|
706
|
+
.section { margin-top: 18px; padding: 24px; }
|
|
707
|
+
.section-grid { display: grid; grid-template-columns: 260px minmax(0,1fr); gap: 28px; }
|
|
708
|
+
.section-kicker {
|
|
709
|
+
margin: 0 0 12px; color: var(--accent); font-size: 13px; font-weight: 800;
|
|
710
|
+
letter-spacing: .08em; text-transform: uppercase;
|
|
711
|
+
}
|
|
712
|
+
.check-list { display: grid; gap: 8px; }
|
|
713
|
+
.check {
|
|
714
|
+
display: flex; align-items: baseline; justify-content: space-between;
|
|
715
|
+
gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line);
|
|
716
|
+
}
|
|
717
|
+
.check span { color: var(--muted); font-size: 13px; }
|
|
718
|
+
.check strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
|
719
|
+
.section-head {
|
|
720
|
+
display: flex; align-items: start; justify-content: space-between;
|
|
721
|
+
gap: 16px; margin-bottom: 16px;
|
|
722
|
+
}
|
|
723
|
+
.section-head h2 { max-width: 680px; margin: 0; font-size: 24px; line-height: 1.2; }
|
|
724
|
+
.section-head > span { flex: 0 0 auto; color: var(--muted); font-size: 13px; font-weight: 700; white-space: nowrap; }
|
|
725
|
+
.findings { display: grid; gap: 12px; }
|
|
726
|
+
.finding {
|
|
727
|
+
border: 1px solid var(--line); border-left: 4px solid var(--note);
|
|
728
|
+
border-radius: var(--radius); padding: 16px; background: #fffdf8;
|
|
729
|
+
}
|
|
730
|
+
.finding.danger { border-left-color: var(--danger); }
|
|
731
|
+
.finding.warn { border-left-color: var(--warn); }
|
|
732
|
+
.finding.low { border-left-color: var(--low); }
|
|
733
|
+
.finding-head { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
|
|
734
|
+
.finding-id { margin: 0 0 4px; color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; }
|
|
735
|
+
h3 { margin: 0; font-size: 17px; line-height: 1.3; }
|
|
736
|
+
.pill {
|
|
737
|
+
display: inline-flex; align-items: center; min-height: 26px; padding: 4px 9px;
|
|
738
|
+
border-radius: 999px; font-size: 12px; font-weight: 800; text-transform: uppercase; white-space: nowrap;
|
|
739
|
+
}
|
|
740
|
+
.pill.danger { background: var(--danger-bg); color: var(--danger); }
|
|
741
|
+
.pill.warn { background: var(--warn-bg); color: var(--warn); }
|
|
742
|
+
.pill.note { background: var(--note-bg); color: var(--note); }
|
|
743
|
+
.pill.low { background: var(--low-bg); color: var(--low); }
|
|
744
|
+
.action { margin: 10px 0 0; color: var(--muted); }
|
|
745
|
+
.sample-table { margin-top: 14px; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--panel); }
|
|
746
|
+
table { width: 100%; min-width: 480px; border-collapse: collapse; }
|
|
747
|
+
th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
748
|
+
th { color: var(--muted); font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
|
|
749
|
+
td { max-width: 360px; color: #27272a; font-size: 13px; overflow-wrap: anywhere; }
|
|
750
|
+
tr:last-child td { border-bottom: 0; }
|
|
751
|
+
details { margin-top: 24px; padding: 18px; }
|
|
752
|
+
summary { cursor: pointer; font-weight: 800; }
|
|
753
|
+
pre {
|
|
754
|
+
margin: 16px 0 0; max-height: 560px; overflow: auto; padding: 16px;
|
|
755
|
+
border-radius: var(--radius); background: #111827; color: #f9fafb;
|
|
756
|
+
font-size: 12px; line-height: 1.45;
|
|
757
|
+
}
|
|
758
|
+
.backfill-legend {
|
|
759
|
+
margin: 0 0 8px; padding: 8px 12px; background: var(--note-bg);
|
|
760
|
+
border-radius: var(--radius); font-size: 13px; color: var(--note);
|
|
761
|
+
}
|
|
762
|
+
@media (max-width: 860px) {
|
|
763
|
+
main { width: min(100% - 24px, 1120px); padding-top: 32px; }
|
|
764
|
+
.metrics { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
|
765
|
+
.chart-grid { grid-template-columns: 1fr; }
|
|
766
|
+
.section-grid { grid-template-columns: 1fr; gap: 18px; }
|
|
767
|
+
}
|
|
768
|
+
@media (max-width: 520px) { .metrics { grid-template-columns: 1fr; } }
|
|
769
|
+
</style>
|
|
770
|
+
</head>
|
|
771
|
+
<body>
|
|
772
|
+
<main>
|
|
773
|
+
<header>
|
|
774
|
+
<p class="eyebrow">Generated ${esc(report.generatedAt)}</p>
|
|
775
|
+
<h1>Memory Eval — 3-Axis Dashboard</h1>
|
|
776
|
+
<p class="lede">${esc(`${report.days}-day window · Axis 1 (carry-forward): ${report.axis1.score}% · Axis 2 (preferences): ${report.axis2.avgUsefulness} · Axis 3 (freshness): ${report.axis3.freshScore}% · ${history.length} data points`)}</p>
|
|
777
|
+
</header>
|
|
778
|
+
|
|
779
|
+
<section class="metrics" aria-label="Current scores">${metricCards}</section>
|
|
780
|
+
|
|
781
|
+
<p class="backfill-legend">
|
|
782
|
+
<strong>Chart legend:</strong> Solid lines = measured data. Dashed gray = soft target.
|
|
783
|
+
Axes 1 & 3 are backfilled from audit tables. Axis 2 (preferences) is forward-only — it queries current memory state and cannot be reconstructed historically.
|
|
784
|
+
</p>
|
|
785
|
+
|
|
786
|
+
<section class="chart-grid">
|
|
787
|
+
<div class="chart-card">
|
|
788
|
+
<h2>Axis 1 — Carry-forward Context (%)</h2>
|
|
789
|
+
<div class="chart-wrap"><canvas id="c1"></canvas></div>
|
|
790
|
+
</div>
|
|
791
|
+
<div class="chart-card">
|
|
792
|
+
<h2>Axis 2 — Preferences Usefulness</h2>
|
|
793
|
+
<div class="chart-wrap"><canvas id="c2"></canvas></div>
|
|
794
|
+
<p class="chart-note">Forward-only: cannot be reconstructed from historical data. Starts from first live run.</p>
|
|
795
|
+
</div>
|
|
796
|
+
<div class="chart-card">
|
|
797
|
+
<h2>Axis 3 — Freshness Score (%)</h2>
|
|
798
|
+
<div class="chart-wrap"><canvas id="c3"></canvas></div>
|
|
799
|
+
</div>
|
|
800
|
+
<div class="chart-card">
|
|
801
|
+
<h2>Memory Corpus</h2>
|
|
802
|
+
<div class="chart-wrap"><canvas id="c4"></canvas></div>
|
|
803
|
+
</div>
|
|
804
|
+
</section>
|
|
805
|
+
|
|
806
|
+
${findingSections}
|
|
807
|
+
|
|
808
|
+
<details>
|
|
809
|
+
<summary>Compressed JSON appendix</summary>
|
|
810
|
+
<pre>${esc(JSON.stringify(report, null, 2))}</pre>
|
|
811
|
+
</details>
|
|
812
|
+
</main>
|
|
813
|
+
|
|
814
|
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
815
|
+
<script>
|
|
816
|
+
(function() {
|
|
817
|
+
var H = ${dataJson};
|
|
818
|
+
var labels = H.map(function(p){ return p.date.slice(5); });
|
|
819
|
+
var fullLabels = H.map(function(p){ return p.date; });
|
|
820
|
+
var baseOpts = {
|
|
821
|
+
responsive: true, maintainAspectRatio: false,
|
|
822
|
+
interaction: { mode: 'index', intersect: false },
|
|
823
|
+
plugins: {
|
|
824
|
+
legend: { position: 'bottom', labels: { boxWidth: 12, padding: 10, font: { size: 11 } } },
|
|
825
|
+
tooltip: {
|
|
826
|
+
callbacks: {
|
|
827
|
+
title: function(items) { return fullLabels[items[0].dataIndex] || ''; }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
scales: {
|
|
832
|
+
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45, autoSkip: true, maxTicksLimit: 15 } },
|
|
833
|
+
y: { beginAtZero: true, grid: { color: '#ded8cb40' }, ticks: { font: { size: 11 } } }
|
|
834
|
+
},
|
|
835
|
+
elements: { point: { radius: 3, hoverRadius: 5 }, line: { tension: 0.3, borderWidth: 2 } }
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
function targetDs(label, val, len) {
|
|
839
|
+
return {
|
|
840
|
+
label: label, data: Array(len).fill(val),
|
|
841
|
+
borderColor: '#94a3b8', borderDash: [6,4], borderWidth: 1.5,
|
|
842
|
+
pointRadius: 0, pointHoverRadius: 0, fill: false
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
new Chart(document.getElementById('c1'), {
|
|
847
|
+
type: 'line',
|
|
848
|
+
data: {
|
|
849
|
+
labels: labels,
|
|
850
|
+
datasets: [
|
|
851
|
+
{ label: 'Carry-forward %', data: H.map(function(p){return p.axis1Score;}), borderColor: '#2563eb', backgroundColor: '#2563eb20', fill: true },
|
|
852
|
+
targetDs('Target 60%', 60, H.length)
|
|
853
|
+
]
|
|
854
|
+
},
|
|
855
|
+
options: Object.assign({}, baseOpts, { scales: Object.assign({}, baseOpts.scales, { y: Object.assign({}, baseOpts.scales.y, { max: 100 }) }) })
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
new Chart(document.getElementById('c2'), {
|
|
859
|
+
type: 'line',
|
|
860
|
+
data: {
|
|
861
|
+
labels: labels,
|
|
862
|
+
datasets: [
|
|
863
|
+
{ label: 'Avg Usefulness', data: H.map(function(p){return p.axis2AvgUsefulness;}), borderColor: '#7c3aed', backgroundColor: '#7c3aed20', fill: true, spanGaps: false },
|
|
864
|
+
targetDs('Target 0.6', 0.6, H.length)
|
|
865
|
+
]
|
|
866
|
+
},
|
|
867
|
+
options: Object.assign({}, baseOpts, { scales: Object.assign({}, baseOpts.scales, { y: Object.assign({}, baseOpts.scales.y, { max: 1, ticks: Object.assign({}, baseOpts.scales.y.ticks, { stepSize: 0.2 }) }) }) })
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
new Chart(document.getElementById('c3'), {
|
|
871
|
+
type: 'line',
|
|
872
|
+
data: {
|
|
873
|
+
labels: labels,
|
|
874
|
+
datasets: [
|
|
875
|
+
{ label: 'Freshness %', data: H.map(function(p){return p.axis3FreshScore;}), borderColor: '#059669', backgroundColor: '#05966920', fill: true },
|
|
876
|
+
targetDs('Target 80%', 80, H.length)
|
|
877
|
+
]
|
|
878
|
+
},
|
|
879
|
+
options: Object.assign({}, baseOpts, { scales: Object.assign({}, baseOpts.scales, { y: Object.assign({}, baseOpts.scales.y, { max: 100 }) }) })
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
new Chart(document.getElementById('c4'), {
|
|
883
|
+
type: 'line',
|
|
884
|
+
data: {
|
|
885
|
+
labels: labels,
|
|
886
|
+
datasets: [
|
|
887
|
+
{ label: 'Total', data: H.map(function(p){return p.totalMemories;}), borderColor: '#2563eb', fill: false },
|
|
888
|
+
{ label: 'Searchable', data: H.map(function(p){return p.searchableMemories;}), borderColor: '#059669', fill: false },
|
|
889
|
+
{ label: 'Expired', data: H.map(function(p){return p.expiredMemories;}), borderColor: '#dc2626', fill: false }
|
|
890
|
+
]
|
|
891
|
+
},
|
|
892
|
+
options: baseOpts
|
|
893
|
+
});
|
|
894
|
+
})();
|
|
895
|
+
</script>
|
|
896
|
+
</body>
|
|
897
|
+
</html>`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function buildFindingsHtml(report: any): string {
|
|
901
|
+
const severityTone = (s?: string) =>
|
|
902
|
+
s === "critical" ? "danger" : s === "high" ? "warn" : s === "medium" ? "note" : "low";
|
|
903
|
+
const fmtMetric = (v: unknown) =>
|
|
904
|
+
typeof v === "number" ? new Intl.NumberFormat("en-US").format(v) : String(v ?? "");
|
|
905
|
+
const humanLabel = (s: string) =>
|
|
906
|
+
s
|
|
907
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
908
|
+
.replace(/[-_.]+/g, " ")
|
|
909
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
910
|
+
|
|
911
|
+
type Finding = { id: string; severity?: string; summary: string; action?: string; samples?: any[] };
|
|
912
|
+
type Section = {
|
|
913
|
+
key: string;
|
|
914
|
+
label?: string;
|
|
915
|
+
goal: string;
|
|
916
|
+
findingCount?: number;
|
|
917
|
+
checks?: Record<string, unknown>;
|
|
918
|
+
findings?: Finding[];
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const sections: Section[] = [
|
|
922
|
+
{
|
|
923
|
+
key: "axis1-carry-forward",
|
|
924
|
+
label: "Axis 1",
|
|
925
|
+
goal: "Carry-forward context: do follow-up tasks retrieve useful memories from prior tasks in the same thread?",
|
|
926
|
+
findingCount: report.axis1.followUpTasks > 0 ? 1 : 0,
|
|
927
|
+
checks: {
|
|
928
|
+
followUpTasks: report.axis1.followUpTasks,
|
|
929
|
+
tasksWithCarryForward: report.axis1.tasksWithUsefulCarryForward,
|
|
930
|
+
score: `${report.axis1.score}%`,
|
|
931
|
+
softTarget: `${report.axis1.softTarget}%`,
|
|
932
|
+
},
|
|
933
|
+
findings:
|
|
934
|
+
report.axis1.followUpTasks > 0
|
|
935
|
+
? [
|
|
936
|
+
{
|
|
937
|
+
id: "axis1.carry-forward-rate",
|
|
938
|
+
severity: report.axis1.score >= 60 ? "low" : report.axis1.score >= 30 ? "medium" : "high",
|
|
939
|
+
summary: `${report.axis1.score}% of ${report.axis1.followUpTasks} follow-up tasks retrieved a useful carry-forward memory (soft target: ${report.axis1.softTarget}%).`,
|
|
940
|
+
action:
|
|
941
|
+
report.axis1.score < 60
|
|
942
|
+
? "Investigate whether memories from prior tasks in a thread are being written and embedded correctly."
|
|
943
|
+
: "Score meets soft target. Monitor for regression after architecture changes.",
|
|
944
|
+
},
|
|
945
|
+
]
|
|
946
|
+
: [],
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
key: "axis2-preferences",
|
|
950
|
+
label: "Axis 2",
|
|
951
|
+
goal: "Follow preferences & constraints: are identity/config memories retrieved and rated useful?",
|
|
952
|
+
findingCount: report.axis2.byFile.length,
|
|
953
|
+
checks: {
|
|
954
|
+
preferenceMemories: report.axis2.totalPreferenceMemories,
|
|
955
|
+
everRetrieved: `${report.axis2.retrievalRate}%`,
|
|
956
|
+
avgUsefulness: report.axis2.avgUsefulness,
|
|
957
|
+
totalRetrievals: report.axis2.totalRetrievals,
|
|
958
|
+
},
|
|
959
|
+
findings: report.axis2.byFile.map((f: any) => ({
|
|
960
|
+
id: `axis2.file.${f.file}`,
|
|
961
|
+
severity: f.avgUsefulness < 0.5 ? "medium" : "low",
|
|
962
|
+
summary: `${f.file}: ${f.memories} memories, ${f.totalAccess} retrievals, avg usefulness ${round2(f.avgUsefulness)}.`,
|
|
963
|
+
action:
|
|
964
|
+
f.avgUsefulness < 0.5
|
|
965
|
+
? "Low usefulness suggests these memories are being retrieved but not helpful — check chunking and embedding quality."
|
|
966
|
+
: "Healthy retrieval pattern.",
|
|
967
|
+
})),
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
key: "axis3-freshness",
|
|
971
|
+
label: "Axis 3",
|
|
972
|
+
goal: "Stay current over time: are retrieved memories fresh, or are stale/expired memories polluting results?",
|
|
973
|
+
findingCount: 1,
|
|
974
|
+
checks: {
|
|
975
|
+
freshScore: `${report.axis3.freshScore}%`,
|
|
976
|
+
softTarget: `${report.axis3.softTarget}%`,
|
|
977
|
+
totalRetrieved: report.axis3.totalUniqueMemoriesRetrieved,
|
|
978
|
+
staleReferents: report.axis3.staleReferentCount,
|
|
979
|
+
},
|
|
980
|
+
findings: [
|
|
981
|
+
{
|
|
982
|
+
id: "axis3.freshness-distribution",
|
|
983
|
+
severity:
|
|
984
|
+
report.axis3.freshScore >= 80 ? "low" : report.axis3.freshScore >= 50 ? "medium" : "high",
|
|
985
|
+
summary: `${report.axis3.freshScore}% of retrieved memories are fresh (<${report.axis3.freshDays}d). Aging: ${report.axis3.buckets.aging.pctOfRetrieved}%, Stranded: ${report.axis3.buckets.stranded.pctOfRetrieved}%, Expired: ${report.axis3.buckets.expired.pctOfRetrieved}%.`,
|
|
986
|
+
action:
|
|
987
|
+
report.axis3.freshScore < 80
|
|
988
|
+
? "Stale memories are diluting retrieval quality. Prioritize TTL enforcement and the Memory Curator job."
|
|
989
|
+
: "Freshness meets target. Continue monitoring.",
|
|
990
|
+
samples: Object.entries(report.axis3.buckets).map(([k, v]: [string, any]) => ({
|
|
991
|
+
bucket: k,
|
|
992
|
+
uniqueMemories: v.uniqueMemories,
|
|
993
|
+
retrievals: v.retrievals,
|
|
994
|
+
pctOfRetrieved: `${v.pctOfRetrieved}%`,
|
|
995
|
+
})),
|
|
996
|
+
},
|
|
997
|
+
],
|
|
998
|
+
},
|
|
999
|
+
];
|
|
1000
|
+
|
|
1001
|
+
return sections
|
|
1002
|
+
.map((section) => {
|
|
1003
|
+
const findings = (section.findings || [])
|
|
1004
|
+
.map(
|
|
1005
|
+
(f) => `<article class="finding ${esc(severityTone(f.severity))}">
|
|
1006
|
+
<div class="finding-head">
|
|
1007
|
+
<div>
|
|
1008
|
+
<p class="finding-id">${esc(f.id)}</p>
|
|
1009
|
+
<h3>${esc(f.summary)}</h3>
|
|
1010
|
+
</div>
|
|
1011
|
+
<span class="pill ${esc(severityTone(f.severity))}">${esc(f.severity || "low")}</span>
|
|
1012
|
+
</div>
|
|
1013
|
+
${f.action ? `<p class="action">${esc(f.action)}</p>` : ""}
|
|
1014
|
+
${renderSamples(f.samples)}
|
|
1015
|
+
</article>`,
|
|
1016
|
+
)
|
|
1017
|
+
.join("");
|
|
1018
|
+
const checks = Object.entries(section.checks || {})
|
|
1019
|
+
.map(
|
|
1020
|
+
([label, value]) =>
|
|
1021
|
+
`<div class="check"><span>${esc(humanLabel(label))}</span><strong>${esc(fmtMetric(value))}</strong></div>`,
|
|
1022
|
+
)
|
|
1023
|
+
.join("");
|
|
1024
|
+
return `<section class="section">
|
|
1025
|
+
<div class="section-grid">
|
|
1026
|
+
<aside class="checks">
|
|
1027
|
+
<p class="section-kicker">${esc(section.label || humanLabel(section.key))}</p>
|
|
1028
|
+
<div class="check-list">${checks}</div>
|
|
1029
|
+
</aside>
|
|
1030
|
+
<div>
|
|
1031
|
+
<div class="section-head">
|
|
1032
|
+
<h2>${esc(section.goal)}</h2>
|
|
1033
|
+
<span>${esc(fmtMetric(section.findingCount ?? section.findings?.length ?? 0))} finding(s)</span>
|
|
1034
|
+
</div>
|
|
1035
|
+
<div class="findings">${findings || '<p class="empty">No actionable findings.</p>'}</div>
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
</section>`;
|
|
1039
|
+
})
|
|
1040
|
+
.join("\n");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function renderSamples(samples?: unknown[]): string {
|
|
1044
|
+
if (!Array.isArray(samples) || samples.length === 0) return "";
|
|
1045
|
+
const normalized = samples.map((row) =>
|
|
1046
|
+
row && typeof row === "object" && !Array.isArray(row) ? (row as Record<string, unknown>) : { value: row },
|
|
1047
|
+
);
|
|
1048
|
+
const columns = Array.from(new Set(normalized.flatMap((row) => Object.keys(row).slice(0, 6)))).slice(0, 6);
|
|
1049
|
+
if (columns.length === 0) return "";
|
|
1050
|
+
const renderVal = (v: unknown) =>
|
|
1051
|
+
Array.isArray(v) ? v.map((i) => (typeof i === "object" ? JSON.stringify(i) : String(i ?? ""))).join(", ") : typeof v === "object" && v ? JSON.stringify(v) : String(v ?? "");
|
|
1052
|
+
const rows = normalized
|
|
1053
|
+
.map((row) => `<tr>${columns.map((c) => `<td>${esc(renderVal(row[c]))}</td>`).join("")}</tr>`)
|
|
1054
|
+
.join("");
|
|
1055
|
+
return `<div class="sample-table"><table>
|
|
1056
|
+
<thead><tr>${columns.map((c) => `<th>${esc(c.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_.]+/g, " ").replace(/\b\w/g, (ch) => ch.toUpperCase()))}</th>`).join("")}</tr></thead>
|
|
1057
|
+
<tbody>${rows}</tbody>
|
|
1058
|
+
</table></div>`;
|
|
1059
|
+
}
|