@vellumai/assistant 0.5.2 → 0.5.3
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/ARCHITECTURE.md +109 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +699 -0
- package/src/__tests__/memory-reducer.test.ts +698 -0
- package/src/__tests__/memory-regressions.test.ts +6 -4
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/loader.ts +1 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-runtime-assembly.ts +2 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +9 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +161 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +245 -101
- package/src/memory/db-init.ts +12 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +6 -0
- package/src/memory/jobs-worker.ts +12 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/index.ts +3 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +99 -0
- package/src/memory/reducer.ts +453 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/routes/conversation-management-routes.ts +6 -0
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +2 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +21 -0
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open-loop brief compiler.
|
|
3
|
+
*
|
|
4
|
+
* Merges reducer-created open_loops rows with live task queue (work items)
|
|
5
|
+
* and follow-up state into a ranked, deduplicated list of bullets for the
|
|
6
|
+
* memory brief.
|
|
7
|
+
*
|
|
8
|
+
* Ranking tiers (highest first):
|
|
9
|
+
* 1. Overdue — dueAt in the past
|
|
10
|
+
* 2. Due ≤ 24 h — dueAt within the next 24 hours
|
|
11
|
+
* 3. Due ≤ 7 d — dueAt within the next 7 days
|
|
12
|
+
* 4. High-priority / blocked — work items at priority tier 0 or
|
|
13
|
+
* follow-ups with status "nudged"
|
|
14
|
+
* 5. Recently touched — updatedAt within the last 48 hours
|
|
15
|
+
*
|
|
16
|
+
* After ranked items, at most ONE low-salience loop is resurfaced via
|
|
17
|
+
* deterministic pseudo-random sampling seeded by `scopeId + userMessageId`.
|
|
18
|
+
* The resurfaced loop's `surfacedAt` is updated so it won't repeat
|
|
19
|
+
* immediately.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { and, eq } from "drizzle-orm";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
type BriefFollowUp,
|
|
26
|
+
getPendingAndOverdueFollowUps,
|
|
27
|
+
} from "../followups/followup-store.js";
|
|
28
|
+
import {
|
|
29
|
+
type ActionableWorkItem,
|
|
30
|
+
getActionableWorkItems,
|
|
31
|
+
} from "../tasks/task-store.js";
|
|
32
|
+
import { getDb } from "./db.js";
|
|
33
|
+
import { updateLastSurfacedAt } from "./reducer-store.js";
|
|
34
|
+
import { openLoops } from "./schema.js";
|
|
35
|
+
|
|
36
|
+
// ── Constants ─────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const MS_24H = 24 * 60 * 60 * 1000;
|
|
39
|
+
const MS_7D = 7 * 24 * 60 * 60 * 1000;
|
|
40
|
+
const MS_48H = 48 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
// ── Types ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface OpenLoopBullet {
|
|
45
|
+
/** Dedupe key — one of `loop:<id>`, `work:<id>`, or `followup:<id>`. */
|
|
46
|
+
key: string;
|
|
47
|
+
summary: string;
|
|
48
|
+
tier: number; // 1–5, lower = higher priority
|
|
49
|
+
source: "loop" | "work_item" | "followup";
|
|
50
|
+
sourceId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface OpenLoopBriefResult {
|
|
54
|
+
/** Bullets ordered by tier then recency. */
|
|
55
|
+
bullets: OpenLoopBullet[];
|
|
56
|
+
/** ID of the resurfaced low-salience loop, if any. */
|
|
57
|
+
resurfacedLoopId: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface OpenLoopRow {
|
|
61
|
+
id: string;
|
|
62
|
+
scopeId: string;
|
|
63
|
+
summary: string;
|
|
64
|
+
status: string;
|
|
65
|
+
source: string;
|
|
66
|
+
dueAt: number | null;
|
|
67
|
+
surfacedAt: number | null;
|
|
68
|
+
createdAt: number;
|
|
69
|
+
updatedAt: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Deterministic hash ────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Simple 32-bit FNV-1a hash for deterministic pseudo-random selection.
|
|
76
|
+
* Not cryptographic — only needs to be well-distributed for sampling.
|
|
77
|
+
*/
|
|
78
|
+
function fnv1a(input: string): number {
|
|
79
|
+
let h = 0x811c9dc5;
|
|
80
|
+
for (let i = 0; i < input.length; i++) {
|
|
81
|
+
h ^= input.charCodeAt(i);
|
|
82
|
+
h = Math.imul(h, 0x01000193);
|
|
83
|
+
}
|
|
84
|
+
return h >>> 0; // unsigned 32-bit
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Tier assignment ───────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function assignLoopTier(loop: OpenLoopRow, now: number): number {
|
|
90
|
+
if (loop.dueAt != null) {
|
|
91
|
+
if (loop.dueAt <= now) return 1; // overdue
|
|
92
|
+
if (loop.dueAt <= now + MS_24H) return 2; // due within 24h
|
|
93
|
+
if (loop.dueAt <= now + MS_7D) return 3; // due within 7d
|
|
94
|
+
}
|
|
95
|
+
if (now - loop.updatedAt <= MS_48H) return 5; // recently touched
|
|
96
|
+
return 6; // low salience — candidate for resurfacing
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function assignWorkItemTier(item: ActionableWorkItem, now: number): number {
|
|
100
|
+
if (item.priorityTier === 0) return 4; // high priority
|
|
101
|
+
if (item.status === "awaiting_review") return 4;
|
|
102
|
+
if (now - item.updatedAt <= MS_48H) return 5;
|
|
103
|
+
return 6;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function assignFollowUpTier(fu: BriefFollowUp, now: number): number {
|
|
107
|
+
if (fu.expectedResponseBy != null && fu.expectedResponseBy <= now) return 1;
|
|
108
|
+
if (fu.expectedResponseBy != null && fu.expectedResponseBy <= now + MS_24H)
|
|
109
|
+
return 2;
|
|
110
|
+
if (fu.expectedResponseBy != null && fu.expectedResponseBy <= now + MS_7D)
|
|
111
|
+
return 3;
|
|
112
|
+
if (fu.status === "nudged") return 4;
|
|
113
|
+
if (now - fu.updatedAt <= MS_48H) return 5;
|
|
114
|
+
return 6;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Compiler ──────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compile the open-loop section of the memory brief.
|
|
121
|
+
*
|
|
122
|
+
* @param scopeId Memory scope (e.g. assistant instance ID)
|
|
123
|
+
* @param userMessageId Current user message ID — used as part of the
|
|
124
|
+
* resurfacing seed so the selection is deterministic
|
|
125
|
+
* per turn but varies across turns.
|
|
126
|
+
* @param now Current epoch-ms timestamp (injectable for testing).
|
|
127
|
+
*/
|
|
128
|
+
export function compileOpenLoopBrief(
|
|
129
|
+
scopeId: string,
|
|
130
|
+
userMessageId: string,
|
|
131
|
+
now: number = Date.now(),
|
|
132
|
+
): OpenLoopBriefResult {
|
|
133
|
+
// 1. Gather data from all three sources
|
|
134
|
+
const loops = getOpenLoopsForScope(scopeId);
|
|
135
|
+
const workItems = getActionableWorkItems();
|
|
136
|
+
const followUps = getPendingAndOverdueFollowUps();
|
|
137
|
+
|
|
138
|
+
// 2. Convert to bullets with tier assignment
|
|
139
|
+
const bullets: OpenLoopBullet[] = [];
|
|
140
|
+
const seenKeys = new Set<string>();
|
|
141
|
+
|
|
142
|
+
// Loops first (they are the authoritative open-loop source)
|
|
143
|
+
for (const loop of loops) {
|
|
144
|
+
const key = `loop:${loop.id}`;
|
|
145
|
+
if (seenKeys.has(key)) continue;
|
|
146
|
+
seenKeys.add(key);
|
|
147
|
+
bullets.push({
|
|
148
|
+
key,
|
|
149
|
+
summary: loop.summary,
|
|
150
|
+
tier: assignLoopTier(loop, now),
|
|
151
|
+
source: "loop",
|
|
152
|
+
sourceId: loop.id,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Work items — skip if already represented by a loop with matching summary
|
|
157
|
+
const loopSummaries = new Set(loops.map((l) => l.summary.toLowerCase()));
|
|
158
|
+
for (const item of workItems) {
|
|
159
|
+
const key = `work:${item.id}`;
|
|
160
|
+
if (seenKeys.has(key)) continue;
|
|
161
|
+
// Deduplicate against loop summaries
|
|
162
|
+
if (loopSummaries.has(item.title.toLowerCase())) continue;
|
|
163
|
+
seenKeys.add(key);
|
|
164
|
+
bullets.push({
|
|
165
|
+
key,
|
|
166
|
+
summary: item.title,
|
|
167
|
+
tier: assignWorkItemTier(item, now),
|
|
168
|
+
source: "work_item",
|
|
169
|
+
sourceId: item.id,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Follow-ups — skip if already represented by a loop
|
|
174
|
+
for (const fu of followUps) {
|
|
175
|
+
const key = `followup:${fu.id}`;
|
|
176
|
+
if (seenKeys.has(key)) continue;
|
|
177
|
+
const fuSummary =
|
|
178
|
+
`Awaiting reply on ${fu.channel} (${fu.conversationId})`.toLowerCase();
|
|
179
|
+
if (loopSummaries.has(fuSummary)) continue;
|
|
180
|
+
seenKeys.add(key);
|
|
181
|
+
bullets.push({
|
|
182
|
+
key,
|
|
183
|
+
summary: `Awaiting reply on ${fu.channel} (${fu.conversationId})`,
|
|
184
|
+
tier: assignFollowUpTier(fu, now),
|
|
185
|
+
source: "followup",
|
|
186
|
+
sourceId: fu.id,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 3. Split into ranked (tiers 1–5) and low-salience (tier 6)
|
|
191
|
+
const ranked: OpenLoopBullet[] = [];
|
|
192
|
+
const lowSalience: OpenLoopBullet[] = [];
|
|
193
|
+
|
|
194
|
+
for (const b of bullets) {
|
|
195
|
+
if (b.tier <= 5) {
|
|
196
|
+
ranked.push(b);
|
|
197
|
+
} else {
|
|
198
|
+
lowSalience.push(b);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Sort ranked: by tier ascending, then by source priority (loop > work > followup)
|
|
203
|
+
ranked.sort((a, b) => {
|
|
204
|
+
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
205
|
+
return sourcePriority(a.source) - sourcePriority(b.source);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 4. Deterministic resurfacing of ONE low-salience loop
|
|
209
|
+
let resurfacedLoopId: string | null = null;
|
|
210
|
+
|
|
211
|
+
if (lowSalience.length > 0) {
|
|
212
|
+
// Only consider loops from the open_loops table for resurfacing
|
|
213
|
+
// (work items and follow-ups have their own lifecycle)
|
|
214
|
+
const resurfaceCandidates = lowSalience.filter((b) => b.source === "loop");
|
|
215
|
+
|
|
216
|
+
if (resurfaceCandidates.length > 0) {
|
|
217
|
+
// Sort candidates deterministically by key for stable ordering
|
|
218
|
+
resurfaceCandidates.sort((a, b) => a.key.localeCompare(b.key));
|
|
219
|
+
|
|
220
|
+
const seed = `${scopeId}:${userMessageId}`;
|
|
221
|
+
const hash = fnv1a(seed);
|
|
222
|
+
const idx = hash % resurfaceCandidates.length;
|
|
223
|
+
const picked = resurfaceCandidates[idx];
|
|
224
|
+
|
|
225
|
+
// Promote picked to tier 5 and add to ranked output
|
|
226
|
+
ranked.push({ ...picked, tier: 5 });
|
|
227
|
+
resurfacedLoopId = picked.sourceId;
|
|
228
|
+
|
|
229
|
+
// Update surfacedAt so it is deprioritised on subsequent turns
|
|
230
|
+
updateLastSurfacedAt(picked.sourceId, now);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Re-sort after potential resurfaced bullet insertion
|
|
235
|
+
ranked.sort((a, b) => {
|
|
236
|
+
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
237
|
+
return sourcePriority(a.source) - sourcePriority(b.source);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
bullets: ranked,
|
|
242
|
+
resurfacedLoopId,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Internals ─────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
function sourcePriority(source: "loop" | "work_item" | "followup"): number {
|
|
249
|
+
switch (source) {
|
|
250
|
+
case "loop":
|
|
251
|
+
return 0;
|
|
252
|
+
case "work_item":
|
|
253
|
+
return 1;
|
|
254
|
+
case "followup":
|
|
255
|
+
return 2;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getOpenLoopsForScope(scopeId: string): OpenLoopRow[] {
|
|
260
|
+
const db = getDb();
|
|
261
|
+
return db
|
|
262
|
+
.select()
|
|
263
|
+
.from(openLoops)
|
|
264
|
+
.where(and(eq(openLoops.scopeId, scopeId), eq(openLoops.status, "open")))
|
|
265
|
+
.all() as OpenLoopRow[];
|
|
266
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic compiler for the "Time-Relevant Context" section of the
|
|
3
|
+
* memory brief. Reads active `time_contexts` rows plus due-soon live
|
|
4
|
+
* schedule jobs, sorts them by urgency bucket, and caps the output.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { and, gte, lte } from "drizzle-orm";
|
|
8
|
+
|
|
9
|
+
import { getDueSoonSchedules } from "../schedule/schedule-store.js";
|
|
10
|
+
import type { BriefEntry } from "./brief-formatting.js";
|
|
11
|
+
import { renderBriefSection } from "./brief-formatting.js";
|
|
12
|
+
import type { DrizzleDb } from "./db-connection.js";
|
|
13
|
+
import { timeContexts } from "./schema/memory-brief.js";
|
|
14
|
+
|
|
15
|
+
const MAX_ENTRIES = 3;
|
|
16
|
+
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
/** Urgency buckets — lower number = higher priority. */
|
|
20
|
+
const enum Bucket {
|
|
21
|
+
HappeningNow = 0,
|
|
22
|
+
Overdue = 1,
|
|
23
|
+
Within24h = 2,
|
|
24
|
+
Within7d = 3,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Candidate {
|
|
28
|
+
bucket: Bucket;
|
|
29
|
+
/** Epoch ms timestamp used for secondary sort within a bucket. */
|
|
30
|
+
sortKey: number;
|
|
31
|
+
entry: BriefEntry;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Public API
|
|
36
|
+
// ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Compile the time-relevant brief section.
|
|
40
|
+
*
|
|
41
|
+
* @param db Drizzle database instance
|
|
42
|
+
* @param now Current epoch-ms timestamp (injectable for deterministic tests)
|
|
43
|
+
* @returns Markdown string for the section, or `null` if nothing qualifies
|
|
44
|
+
*/
|
|
45
|
+
export function compileTimeBrief(
|
|
46
|
+
db: DrizzleDb,
|
|
47
|
+
scopeId: string,
|
|
48
|
+
now: number,
|
|
49
|
+
): string | null {
|
|
50
|
+
const candidates: Candidate[] = [];
|
|
51
|
+
|
|
52
|
+
collectTimeContexts(db, scopeId, now, candidates);
|
|
53
|
+
collectDueSoonSchedules(now, candidates);
|
|
54
|
+
|
|
55
|
+
// Sort: primary = bucket ascending, secondary = sortKey ascending (sooner first)
|
|
56
|
+
candidates.sort((a, b) => a.bucket - b.bucket || a.sortKey - b.sortKey);
|
|
57
|
+
|
|
58
|
+
const entries = candidates.slice(0, MAX_ENTRIES).map((c) => c.entry);
|
|
59
|
+
return renderBriefSection("Time-Relevant Context", entries, MAX_ENTRIES);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ────────────────────────────────────────────────────────────────────
|
|
63
|
+
// Internal collectors
|
|
64
|
+
// ────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function collectTimeContexts(
|
|
67
|
+
db: DrizzleDb,
|
|
68
|
+
scopeId: string,
|
|
69
|
+
now: number,
|
|
70
|
+
out: Candidate[],
|
|
71
|
+
): void {
|
|
72
|
+
// Active time contexts: activeFrom <= now AND activeUntil >= now
|
|
73
|
+
const rows = db
|
|
74
|
+
.select()
|
|
75
|
+
.from(timeContexts)
|
|
76
|
+
.where(
|
|
77
|
+
and(
|
|
78
|
+
lte(timeContexts.activeFrom, now),
|
|
79
|
+
gte(timeContexts.activeUntil, now),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
.all()
|
|
83
|
+
.filter((r) => r.scopeId === scopeId);
|
|
84
|
+
|
|
85
|
+
for (const row of rows) {
|
|
86
|
+
const remaining = row.activeUntil - now;
|
|
87
|
+
let bucket: Bucket;
|
|
88
|
+
|
|
89
|
+
if (row.activeFrom <= now && row.activeUntil >= now) {
|
|
90
|
+
// Currently active — classify by how much time remains
|
|
91
|
+
if (remaining <= ONE_DAY_MS) {
|
|
92
|
+
bucket = Bucket.HappeningNow;
|
|
93
|
+
} else if (remaining <= SEVEN_DAYS_MS) {
|
|
94
|
+
bucket = Bucket.Within24h;
|
|
95
|
+
} else {
|
|
96
|
+
bucket = Bucket.Within7d;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
bucket = Bucket.Within7d;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
out.push({
|
|
103
|
+
bucket,
|
|
104
|
+
sortKey: row.activeUntil,
|
|
105
|
+
entry: { text: row.summary },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function collectDueSoonSchedules(now: number, out: Candidate[]): void {
|
|
111
|
+
const jobs = getDueSoonSchedules(now, SEVEN_DAYS_MS);
|
|
112
|
+
|
|
113
|
+
for (const job of jobs) {
|
|
114
|
+
const delta = job.nextRunAt - now;
|
|
115
|
+
let bucket: Bucket;
|
|
116
|
+
|
|
117
|
+
if (delta <= 0) {
|
|
118
|
+
bucket = Bucket.Overdue;
|
|
119
|
+
} else if (delta <= ONE_DAY_MS) {
|
|
120
|
+
bucket = Bucket.Within24h;
|
|
121
|
+
} else {
|
|
122
|
+
bucket = Bucket.Within7d;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const label = formatScheduleLabel(job.name, job.nextRunAt, now);
|
|
126
|
+
out.push({
|
|
127
|
+
bucket,
|
|
128
|
+
sortKey: job.nextRunAt,
|
|
129
|
+
entry: { text: label },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ────────────────────────────────────────────────────────────────────
|
|
135
|
+
// Formatting
|
|
136
|
+
// ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function formatScheduleLabel(
|
|
139
|
+
name: string,
|
|
140
|
+
nextRunAt: number,
|
|
141
|
+
now: number,
|
|
142
|
+
): string {
|
|
143
|
+
const delta = nextRunAt - now;
|
|
144
|
+
|
|
145
|
+
if (delta <= 0) {
|
|
146
|
+
return `Scheduled: "${name}" — overdue`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const minutes = Math.round(delta / 60_000);
|
|
150
|
+
if (minutes < 60) {
|
|
151
|
+
return `Scheduled: "${name}" — in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const hours = Math.round(delta / 3_600_000);
|
|
155
|
+
if (hours < 24) {
|
|
156
|
+
return `Scheduled: "${name}" — in ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const days = Math.round(delta / 86_400_000);
|
|
160
|
+
return `Scheduled: "${name}" — in ${days} day${days === 1 ? "" : "s"}`;
|
|
161
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Top-level memory brief composer.
|
|
3
|
+
*
|
|
4
|
+
* Composes the "Time-Relevant Context" and "Open Loops" sections into a
|
|
5
|
+
* single `<memory_brief>` XML-wrapped block. Omits empty sections and
|
|
6
|
+
* returns an empty string when neither section has content.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { renderBriefSection } from "./brief-formatting.js";
|
|
10
|
+
import type { OpenLoopBriefResult } from "./brief-open-loops.js";
|
|
11
|
+
import { compileOpenLoopBrief } from "./brief-open-loops.js";
|
|
12
|
+
import { compileTimeBrief } from "./brief-time.js";
|
|
13
|
+
import type { DrizzleDb } from "./db-connection.js";
|
|
14
|
+
|
|
15
|
+
/** Maximum number of open-loop bullets to include in the brief. */
|
|
16
|
+
const MAX_OPEN_LOOP_ENTRIES = 5;
|
|
17
|
+
|
|
18
|
+
export interface MemoryBriefResult {
|
|
19
|
+
/** Rendered `<memory_brief>` block, or empty string if nothing to show. */
|
|
20
|
+
text: string;
|
|
21
|
+
/** Forwarded from `compileOpenLoopBrief` for downstream tracking. */
|
|
22
|
+
resurfacedLoopId: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compile the full memory brief block.
|
|
27
|
+
*
|
|
28
|
+
* @param db Drizzle database instance
|
|
29
|
+
* @param scopeId Memory scope (e.g. assistant instance ID)
|
|
30
|
+
* @param userMessageId Current user message ID — used for deterministic
|
|
31
|
+
* open-loop resurfacing
|
|
32
|
+
* @param now Current epoch-ms timestamp (injectable for tests)
|
|
33
|
+
* @returns `{ text, resurfacedLoopId }` — `text` is the
|
|
34
|
+
* rendered `<memory_brief>` block or empty string
|
|
35
|
+
*/
|
|
36
|
+
export function compileMemoryBrief(
|
|
37
|
+
db: DrizzleDb,
|
|
38
|
+
scopeId: string,
|
|
39
|
+
userMessageId: string,
|
|
40
|
+
now: number = Date.now(),
|
|
41
|
+
): MemoryBriefResult {
|
|
42
|
+
// Compile individual sections
|
|
43
|
+
const timeSection = compileTimeBrief(db, scopeId, now);
|
|
44
|
+
|
|
45
|
+
const openLoopResult: OpenLoopBriefResult = compileOpenLoopBrief(
|
|
46
|
+
scopeId,
|
|
47
|
+
userMessageId,
|
|
48
|
+
now,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Convert open-loop bullets to a rendered section via the shared helper
|
|
52
|
+
const openLoopEntries = openLoopResult.bullets.map((b) => ({
|
|
53
|
+
text: b.summary,
|
|
54
|
+
}));
|
|
55
|
+
const openLoopSection = renderBriefSection(
|
|
56
|
+
"Open Loops",
|
|
57
|
+
openLoopEntries,
|
|
58
|
+
MAX_OPEN_LOOP_ENTRIES,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Collect non-empty sections
|
|
62
|
+
const sections: string[] = [];
|
|
63
|
+
if (timeSection) sections.push(timeSection);
|
|
64
|
+
if (openLoopSection) sections.push(openLoopSection);
|
|
65
|
+
|
|
66
|
+
// If no sections have content, return empty
|
|
67
|
+
if (sections.length === 0) {
|
|
68
|
+
return { text: "", resurfacedLoopId: openLoopResult.resurfacedLoopId };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const body = sections.join("\n\n");
|
|
72
|
+
const text = `<memory_brief>\n${body}\n</memory_brief>`;
|
|
73
|
+
|
|
74
|
+
return { text, resurfacedLoopId: openLoopResult.resurfacedLoopId };
|
|
75
|
+
}
|