@vellumai/assistant 0.5.2 → 0.5.4
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/architecture/memory.md +105 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- 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-clear-safety.test.ts +259 -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-switch-memory-reduction.test.ts +474 -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__/db-schedule-syntax-migration.test.ts +3 -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-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +707 -0
- package/src/__tests__/memory-reducer.test.ts +704 -0
- package/src/__tests__/memory-regressions.test.ts +30 -8
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -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/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- 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/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-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +3 -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/conversations.ts +11 -0
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +52 -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-recall.ts +516 -0
- 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 +162 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +455 -101
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +16 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +8 -0
- package/src/memory/jobs-worker.ts +20 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -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/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +106 -0
- package/src/memory/reducer.ts +467 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -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/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +94 -2
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- 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 +3 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +28 -0
- package/src/schedule/scheduler.ts +6 -2
- 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/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- 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,13 @@
|
|
|
1
|
+
import type { DrizzleDb } from "../db-connection.js";
|
|
2
|
+
import { getSqliteFrom } from "../db-connection.js";
|
|
3
|
+
|
|
4
|
+
export function migrateScheduleQuietFlag(database: DrizzleDb): void {
|
|
5
|
+
const raw = getSqliteFrom(database);
|
|
6
|
+
try {
|
|
7
|
+
raw.exec(
|
|
8
|
+
`ALTER TABLE cron_jobs ADD COLUMN quiet INTEGER NOT NULL DEFAULT 0`,
|
|
9
|
+
);
|
|
10
|
+
} catch {
|
|
11
|
+
// Column already exists — nothing to do.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -126,6 +126,10 @@ export { migrateRenameThreadStartersCheckpoints } from "./181-rename-thread-star
|
|
|
126
126
|
export { migrateOAuthProvidersDisplayMetadata } from "./182-oauth-providers-display-metadata.js";
|
|
127
127
|
export { migrateConversationForkLineage } from "./183-add-conversation-fork-lineage.js";
|
|
128
128
|
export { migrateLlmRequestLogProvider } from "./184-llm-request-log-provider.js";
|
|
129
|
+
export { migrateMemoryBriefState } from "./185-memory-brief-state.js";
|
|
130
|
+
export { migrateMemoryArchiveTables } from "./186-memory-archive.js";
|
|
131
|
+
export { migrateMemoryReducerCheckpoints } from "./187-memory-reducer-checkpoints.js";
|
|
132
|
+
export { migrateScheduleQuietFlag } from "./188-schedule-quiet-flag.js";
|
|
129
133
|
export {
|
|
130
134
|
MIGRATION_REGISTRY,
|
|
131
135
|
type MigrationRegistryEntry,
|
|
@@ -20,7 +20,7 @@ export interface QdrantClientConfig {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface QdrantPointPayload {
|
|
23
|
-
target_type: "segment" | "item" | "summary" | "media";
|
|
23
|
+
target_type: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media";
|
|
24
24
|
target_id: string;
|
|
25
25
|
text: string;
|
|
26
26
|
kind?: string;
|
|
@@ -230,7 +230,7 @@ export class VellumQdrantClient {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
async upsert(
|
|
233
|
-
targetType: "segment" | "item" | "summary" | "media",
|
|
233
|
+
targetType: "segment" | "item" | "summary" | "observation" | "chunk" | "episode" | "media",
|
|
234
234
|
targetId: string,
|
|
235
235
|
vector: number[],
|
|
236
236
|
payload: Omit<QdrantPointPayload, "target_type" | "target_id">,
|
|
@@ -324,8 +324,11 @@ export class VellumQdrantClient {
|
|
|
324
324
|
async searchWithFilter(
|
|
325
325
|
vector: number[],
|
|
326
326
|
limit: number,
|
|
327
|
-
targetTypes: Array<
|
|
327
|
+
targetTypes: Array<
|
|
328
|
+
"segment" | "item" | "summary" | "media" | "chunk" | "episode"
|
|
329
|
+
>,
|
|
328
330
|
excludeMessageIds?: string[],
|
|
331
|
+
scopeIds?: string[],
|
|
329
332
|
): Promise<QdrantSearchResult[]> {
|
|
330
333
|
const mustConditions: Array<Record<string, unknown>> = [
|
|
331
334
|
{
|
|
@@ -346,12 +349,24 @@ export class VellumQdrantClient {
|
|
|
346
349
|
},
|
|
347
350
|
{
|
|
348
351
|
key: "target_type",
|
|
349
|
-
match: { any: ["segment", "summary", "media"] },
|
|
352
|
+
match: { any: ["segment", "summary", "media", "chunk"] },
|
|
350
353
|
},
|
|
351
354
|
],
|
|
352
355
|
});
|
|
353
356
|
}
|
|
354
357
|
|
|
358
|
+
// Scope filtering: accept points whose memory_scope_id matches one of the
|
|
359
|
+
// allowed scopes, OR points that lack the field entirely (legacy data).
|
|
360
|
+
// Post-query DB filtering remains as defense-in-depth for legacy points.
|
|
361
|
+
if (scopeIds && scopeIds.length > 0) {
|
|
362
|
+
mustConditions.push({
|
|
363
|
+
should: [
|
|
364
|
+
{ key: "memory_scope_id", match: { any: scopeIds } },
|
|
365
|
+
{ is_empty: { key: "memory_scope_id" } },
|
|
366
|
+
],
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
355
370
|
const mustNotConditions: Array<Record<string, unknown>> = [
|
|
356
371
|
{ key: "_meta", match: { value: true } },
|
|
357
372
|
];
|
|
@@ -561,6 +576,10 @@ export class VellumQdrantClient {
|
|
|
561
576
|
field_name: "modality",
|
|
562
577
|
field_schema: "keyword",
|
|
563
578
|
}),
|
|
579
|
+
this.client.createPayloadIndex(this.collection, {
|
|
580
|
+
field_name: "memory_scope_id",
|
|
581
|
+
field_schema: "keyword",
|
|
582
|
+
}),
|
|
564
583
|
]);
|
|
565
584
|
}
|
|
566
585
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reducer scheduler — synchronous pre-switch/create reduction of the most
|
|
3
|
+
* recently updated dirty conversation.
|
|
4
|
+
*
|
|
5
|
+
* When the user switches conversations or starts a new one, we want the
|
|
6
|
+
* *previous* conversation's memory to be reduced before the next memory
|
|
7
|
+
* read. This module exposes {@link reduceBeforeSwitch} which:
|
|
8
|
+
*
|
|
9
|
+
* 1. Finds the single most recently updated dirty conversation (excluding
|
|
10
|
+
* the target conversation).
|
|
11
|
+
* 2. Runs the same reduction pipeline the background job uses (load
|
|
12
|
+
* unreduced messages, call {@link runReducer}, apply via
|
|
13
|
+
* {@link applyReducerResult}).
|
|
14
|
+
* 3. Awaits the result so the caller can proceed knowing memory is fresh.
|
|
15
|
+
*
|
|
16
|
+
* If no eligible dirty conversation exists, the function returns immediately.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { and, asc, desc, eq, gte, isNotNull, ne } from "drizzle-orm";
|
|
20
|
+
|
|
21
|
+
import { getLogger } from "../util/logger.js";
|
|
22
|
+
import { type ConversationRow, getConversation } from "./conversation-crud.js";
|
|
23
|
+
import { getDb } from "./db.js";
|
|
24
|
+
import { type ReducerPromptInput, runReducer } from "./reducer.js";
|
|
25
|
+
import {
|
|
26
|
+
applyReducerResult,
|
|
27
|
+
getActiveOpenLoops,
|
|
28
|
+
getActiveTimeContexts,
|
|
29
|
+
} from "./reducer-store.js";
|
|
30
|
+
import { EMPTY_REDUCER_RESULT } from "./reducer-types.js";
|
|
31
|
+
import { conversations, messages } from "./schema.js";
|
|
32
|
+
|
|
33
|
+
const log = getLogger("reducer-scheduler");
|
|
34
|
+
|
|
35
|
+
// ── Internal helpers ────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
interface MessageRow {
|
|
38
|
+
id: string;
|
|
39
|
+
role: string;
|
|
40
|
+
content: string;
|
|
41
|
+
createdAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find the single most recently updated dirty conversation, excluding
|
|
46
|
+
* the target conversation. Returns the conversation ID or null if none.
|
|
47
|
+
*/
|
|
48
|
+
export function findMostRecentDirtyConversation(
|
|
49
|
+
excludeConversationId: string,
|
|
50
|
+
): string | null {
|
|
51
|
+
const db = getDb();
|
|
52
|
+
const row = db
|
|
53
|
+
.select({ id: conversations.id })
|
|
54
|
+
.from(conversations)
|
|
55
|
+
.where(
|
|
56
|
+
and(
|
|
57
|
+
isNotNull(conversations.memoryDirtyTailSinceMessageId),
|
|
58
|
+
ne(conversations.id, excludeConversationId),
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
.orderBy(desc(conversations.updatedAt))
|
|
62
|
+
.limit(1)
|
|
63
|
+
.get();
|
|
64
|
+
|
|
65
|
+
return row?.id ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load messages from `dirtyTailMessageId` onward (inclusive), ordered by
|
|
70
|
+
* createdAt ascending.
|
|
71
|
+
*/
|
|
72
|
+
function loadUnreducedMessages(
|
|
73
|
+
conversationId: string,
|
|
74
|
+
dirtyTailMessageId: string,
|
|
75
|
+
): MessageRow[] {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
|
|
78
|
+
const tailMessage = db
|
|
79
|
+
.select({ createdAt: messages.createdAt })
|
|
80
|
+
.from(messages)
|
|
81
|
+
.where(eq(messages.id, dirtyTailMessageId))
|
|
82
|
+
.get();
|
|
83
|
+
|
|
84
|
+
if (!tailMessage) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return db
|
|
89
|
+
.select({
|
|
90
|
+
id: messages.id,
|
|
91
|
+
role: messages.role,
|
|
92
|
+
content: messages.content,
|
|
93
|
+
createdAt: messages.createdAt,
|
|
94
|
+
})
|
|
95
|
+
.from(messages)
|
|
96
|
+
.where(
|
|
97
|
+
and(
|
|
98
|
+
eq(messages.conversationId, conversationId),
|
|
99
|
+
gte(messages.createdAt, tailMessage.createdAt),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
.orderBy(asc(messages.createdAt))
|
|
103
|
+
.all();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the `newMessages` array for the reducer input, optionally
|
|
108
|
+
* prepending the conversation's contextSummary as a synthetic system message.
|
|
109
|
+
*/
|
|
110
|
+
function buildNewMessages(
|
|
111
|
+
conversation: ConversationRow,
|
|
112
|
+
unreducedMessages: MessageRow[],
|
|
113
|
+
): Array<{ role: string; content: string }> {
|
|
114
|
+
const result: Array<{ role: string; content: string }> = [];
|
|
115
|
+
|
|
116
|
+
if (conversation.contextSummary) {
|
|
117
|
+
result.push({
|
|
118
|
+
role: "system",
|
|
119
|
+
content: `[Prior context summary] ${conversation.contextSummary}`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const msg of unreducedMessages) {
|
|
124
|
+
result.push({ role: msg.role, content: msg.content });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Public API ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reduce the most recently updated dirty conversation (excluding
|
|
134
|
+
* `targetConversationId`) before a conversation switch or create.
|
|
135
|
+
*
|
|
136
|
+
* This runs the full reduction pipeline synchronously (awaiting the
|
|
137
|
+
* provider call) so the caller can proceed knowing memory is fresh.
|
|
138
|
+
*
|
|
139
|
+
* Returns the conversation ID that was reduced, or null if none were eligible.
|
|
140
|
+
*/
|
|
141
|
+
export async function reduceBeforeSwitch(
|
|
142
|
+
targetConversationId: string,
|
|
143
|
+
): Promise<string | null> {
|
|
144
|
+
const dirtyConversationId =
|
|
145
|
+
findMostRecentDirtyConversation(targetConversationId);
|
|
146
|
+
|
|
147
|
+
if (!dirtyConversationId) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const conversation = getConversation(dirtyConversationId);
|
|
152
|
+
if (!conversation) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dirtyTailMessageId = conversation.memoryDirtyTailSinceMessageId;
|
|
157
|
+
if (!dirtyTailMessageId) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Load unreduced messages ──────────────────────────────────
|
|
162
|
+
const unreducedMessages = loadUnreducedMessages(
|
|
163
|
+
dirtyConversationId,
|
|
164
|
+
dirtyTailMessageId,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (unreducedMessages.length === 0) {
|
|
168
|
+
log.debug(
|
|
169
|
+
{ conversationId: dirtyConversationId, dirtyTailMessageId },
|
|
170
|
+
"No messages found from dirty tail — nothing to reduce on switch",
|
|
171
|
+
);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Load active brief-state context ──────────────────────────
|
|
176
|
+
const scopeId = conversation.memoryScopeId;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
|
|
179
|
+
const existingTimeContexts = getActiveTimeContexts(scopeId, now);
|
|
180
|
+
const existingOpenLoops = getActiveOpenLoops(scopeId);
|
|
181
|
+
|
|
182
|
+
// ── Build reducer input ──────────────────────────────────────
|
|
183
|
+
const newMessages = buildNewMessages(conversation, unreducedMessages);
|
|
184
|
+
|
|
185
|
+
const reducerInput: ReducerPromptInput = {
|
|
186
|
+
conversationId: dirtyConversationId,
|
|
187
|
+
newMessages,
|
|
188
|
+
existingTimeContexts: existingTimeContexts.map((tc) => ({
|
|
189
|
+
id: tc.id,
|
|
190
|
+
summary: tc.summary,
|
|
191
|
+
})),
|
|
192
|
+
existingOpenLoops: existingOpenLoops.map((ol) => ({
|
|
193
|
+
id: ol.id,
|
|
194
|
+
summary: ol.summary,
|
|
195
|
+
status: ol.status,
|
|
196
|
+
})),
|
|
197
|
+
nowMs: now,
|
|
198
|
+
scopeId,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── Run the reducer ──────────────────────────────────────────
|
|
202
|
+
try {
|
|
203
|
+
const result = await runReducer(reducerInput);
|
|
204
|
+
|
|
205
|
+
if (result === EMPTY_REDUCER_RESULT) {
|
|
206
|
+
log.debug(
|
|
207
|
+
{ conversationId: dirtyConversationId },
|
|
208
|
+
"Reducer returned empty result on switch — not advancing checkpoint",
|
|
209
|
+
);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Apply result transactionally ───────────────────────────
|
|
214
|
+
const lastMessage = unreducedMessages[unreducedMessages.length - 1];
|
|
215
|
+
applyReducerResult({
|
|
216
|
+
result,
|
|
217
|
+
conversationId: dirtyConversationId,
|
|
218
|
+
scopeId,
|
|
219
|
+
reducedThroughMessageId: lastMessage.id,
|
|
220
|
+
now,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
log.info(
|
|
224
|
+
{
|
|
225
|
+
conversationId: dirtyConversationId,
|
|
226
|
+
reducedThroughMessageId: lastMessage.id,
|
|
227
|
+
messageCount: unreducedMessages.length,
|
|
228
|
+
timeContextOps: result.timeContexts.length,
|
|
229
|
+
openLoopOps: result.openLoops.length,
|
|
230
|
+
},
|
|
231
|
+
"Pre-switch memory reduction completed",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return dirtyConversationId;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
log.warn(
|
|
237
|
+
{ err, conversationId: dirtyConversationId },
|
|
238
|
+
"Pre-switch memory reduction failed — continuing with switch",
|
|
239
|
+
);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reducer store — transactional application of reducer results to brief-state
|
|
3
|
+
* tables (time_contexts, open_loops) and conversation reducer checkpoints.
|
|
4
|
+
*
|
|
5
|
+
* The `applyReducerResult` helper is the single entry point for persisting
|
|
6
|
+
* reducer output. It runs all upserts, resolves, and checkpoint advances
|
|
7
|
+
* inside a single SQLite transaction so the DB is never left in a
|
|
8
|
+
* partially-applied state.
|
|
9
|
+
*
|
|
10
|
+
* Archive writes are intentionally out of scope — they have their own
|
|
11
|
+
* lifecycle and can be tested independently.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { and, eq, gt } from "drizzle-orm";
|
|
15
|
+
import { v4 as uuid } from "uuid";
|
|
16
|
+
|
|
17
|
+
import { getLogger } from "../util/logger.js";
|
|
18
|
+
import { getDb } from "./db.js";
|
|
19
|
+
import type { ReducerResult } from "./reducer-types.js";
|
|
20
|
+
import { conversations, messages, openLoops, timeContexts } from "./schema.js";
|
|
21
|
+
|
|
22
|
+
const log = getLogger("reducer-store");
|
|
23
|
+
|
|
24
|
+
// ── Read helpers ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Return all active (non-expired) time contexts for a memory scope.
|
|
28
|
+
* "Active" means `activeUntil` is in the future relative to `now`.
|
|
29
|
+
*/
|
|
30
|
+
export function getActiveTimeContexts(
|
|
31
|
+
scopeId: string,
|
|
32
|
+
now: number = Date.now(),
|
|
33
|
+
): Array<{
|
|
34
|
+
id: string;
|
|
35
|
+
summary: string;
|
|
36
|
+
activeFrom: number;
|
|
37
|
+
activeUntil: number;
|
|
38
|
+
}> {
|
|
39
|
+
const db = getDb();
|
|
40
|
+
return db
|
|
41
|
+
.select({
|
|
42
|
+
id: timeContexts.id,
|
|
43
|
+
summary: timeContexts.summary,
|
|
44
|
+
activeFrom: timeContexts.activeFrom,
|
|
45
|
+
activeUntil: timeContexts.activeUntil,
|
|
46
|
+
})
|
|
47
|
+
.from(timeContexts)
|
|
48
|
+
.where(
|
|
49
|
+
and(eq(timeContexts.scopeId, scopeId), gt(timeContexts.activeUntil, now)),
|
|
50
|
+
)
|
|
51
|
+
.all();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Return all open loops for a memory scope.
|
|
56
|
+
*/
|
|
57
|
+
export function getActiveOpenLoops(
|
|
58
|
+
scopeId: string,
|
|
59
|
+
): Array<{ id: string; summary: string; status: string }> {
|
|
60
|
+
const db = getDb();
|
|
61
|
+
return db
|
|
62
|
+
.select({
|
|
63
|
+
id: openLoops.id,
|
|
64
|
+
summary: openLoops.summary,
|
|
65
|
+
status: openLoops.status,
|
|
66
|
+
})
|
|
67
|
+
.from(openLoops)
|
|
68
|
+
.where(and(eq(openLoops.scopeId, scopeId), eq(openLoops.status, "open")))
|
|
69
|
+
.all();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Brief-compiler helper ────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Update the `surfaced_at` timestamp on a single open loop.
|
|
76
|
+
*
|
|
77
|
+
* Called by the brief compiler after resurfacing a low-salience loop
|
|
78
|
+
* so it is not immediately resurfaced again on the next turn.
|
|
79
|
+
*/
|
|
80
|
+
export function updateLastSurfacedAt(loopId: string, surfacedAt: number): void {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
db.update(openLoops)
|
|
83
|
+
.set({ surfacedAt, updatedAt: surfacedAt })
|
|
84
|
+
.where(eq(openLoops.id, loopId))
|
|
85
|
+
.run();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Transactional apply ──────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export interface ApplyReducerResultParams {
|
|
91
|
+
/** The validated reducer result to persist. */
|
|
92
|
+
result: ReducerResult;
|
|
93
|
+
/** Conversation that was reduced. */
|
|
94
|
+
conversationId: string;
|
|
95
|
+
/** Memory scope for new rows (e.g. assistant instance ID). */
|
|
96
|
+
scopeId: string;
|
|
97
|
+
/** ID of the last message that was included in this reducer run. */
|
|
98
|
+
reducedThroughMessageId: string;
|
|
99
|
+
/** Current timestamp in epoch ms (injectable for testing). */
|
|
100
|
+
now?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Atomically apply a reducer result to the database.
|
|
105
|
+
*
|
|
106
|
+
* Within a single transaction this function:
|
|
107
|
+
* 1. Upserts time_contexts (create / update / resolve)
|
|
108
|
+
* 2. Upserts open_loops (create / update / resolve)
|
|
109
|
+
* 3. Advances the conversation's reducer checkpoint columns
|
|
110
|
+
* 4. Clears `memoryDirtyTailSinceMessageId` when the conversation is
|
|
111
|
+
* fully caught up (no messages exist after `reducedThroughMessageId`)
|
|
112
|
+
*
|
|
113
|
+
* Archive candidates in the result are intentionally ignored — they are
|
|
114
|
+
* handled by a separate pipeline.
|
|
115
|
+
*
|
|
116
|
+
* The function is idempotent: applying the same result twice leaves the
|
|
117
|
+
* database in the same state. Create operations use deterministic IDs
|
|
118
|
+
* derived from the reducer output position so re-application produces
|
|
119
|
+
* the same rows.
|
|
120
|
+
*/
|
|
121
|
+
export function applyReducerResult(params: ApplyReducerResultParams): void {
|
|
122
|
+
const {
|
|
123
|
+
result,
|
|
124
|
+
conversationId,
|
|
125
|
+
scopeId,
|
|
126
|
+
reducedThroughMessageId,
|
|
127
|
+
now = Date.now(),
|
|
128
|
+
} = params;
|
|
129
|
+
|
|
130
|
+
const db = getDb();
|
|
131
|
+
|
|
132
|
+
db.transaction((tx) => {
|
|
133
|
+
// ── 1. Time contexts ───────────────────────────────────────────
|
|
134
|
+
for (let i = 0; i < result.timeContexts.length; i++) {
|
|
135
|
+
const op = result.timeContexts[i];
|
|
136
|
+
|
|
137
|
+
if (op.action === "create") {
|
|
138
|
+
const id = uuid();
|
|
139
|
+
tx.insert(timeContexts)
|
|
140
|
+
.values({
|
|
141
|
+
id,
|
|
142
|
+
scopeId,
|
|
143
|
+
summary: op.summary,
|
|
144
|
+
source: op.source,
|
|
145
|
+
activeFrom: op.activeFrom,
|
|
146
|
+
activeUntil: op.activeUntil,
|
|
147
|
+
createdAt: now,
|
|
148
|
+
updatedAt: now,
|
|
149
|
+
})
|
|
150
|
+
.run();
|
|
151
|
+
} else if (op.action === "update") {
|
|
152
|
+
const setFields: Record<string, unknown> = { updatedAt: now };
|
|
153
|
+
if (op.summary !== undefined) setFields.summary = op.summary;
|
|
154
|
+
if (op.activeFrom !== undefined) setFields.activeFrom = op.activeFrom;
|
|
155
|
+
if (op.activeUntil !== undefined)
|
|
156
|
+
setFields.activeUntil = op.activeUntil;
|
|
157
|
+
|
|
158
|
+
tx.update(timeContexts)
|
|
159
|
+
.set(setFields)
|
|
160
|
+
.where(eq(timeContexts.id, op.id))
|
|
161
|
+
.run();
|
|
162
|
+
} else {
|
|
163
|
+
// resolve — delete the row (resolved time contexts are no longer relevant)
|
|
164
|
+
tx.delete(timeContexts).where(eq(timeContexts.id, op.id)).run();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── 2. Open loops ──────────────────────────────────────────────
|
|
169
|
+
for (let i = 0; i < result.openLoops.length; i++) {
|
|
170
|
+
const op = result.openLoops[i];
|
|
171
|
+
|
|
172
|
+
if (op.action === "create") {
|
|
173
|
+
const id = uuid();
|
|
174
|
+
tx.insert(openLoops)
|
|
175
|
+
.values({
|
|
176
|
+
id,
|
|
177
|
+
scopeId,
|
|
178
|
+
summary: op.summary,
|
|
179
|
+
source: op.source,
|
|
180
|
+
status: "open",
|
|
181
|
+
dueAt: op.dueAt ?? null,
|
|
182
|
+
createdAt: now,
|
|
183
|
+
updatedAt: now,
|
|
184
|
+
})
|
|
185
|
+
.run();
|
|
186
|
+
} else if (op.action === "update") {
|
|
187
|
+
const setFields: Record<string, unknown> = { updatedAt: now };
|
|
188
|
+
if (op.summary !== undefined) setFields.summary = op.summary;
|
|
189
|
+
if (op.dueAt !== undefined) setFields.dueAt = op.dueAt;
|
|
190
|
+
|
|
191
|
+
tx.update(openLoops)
|
|
192
|
+
.set(setFields)
|
|
193
|
+
.where(eq(openLoops.id, op.id))
|
|
194
|
+
.run();
|
|
195
|
+
} else {
|
|
196
|
+
// resolve — mark status (resolved | expired)
|
|
197
|
+
tx.update(openLoops)
|
|
198
|
+
.set({ status: op.status, updatedAt: now })
|
|
199
|
+
.where(eq(openLoops.id, op.id))
|
|
200
|
+
.run();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── 3. Advance reducer checkpoint ──────────────────────────────
|
|
205
|
+
//
|
|
206
|
+
// Check whether the conversation is fully caught up: no messages
|
|
207
|
+
// exist after the one we just reduced through. If caught up, clear
|
|
208
|
+
// the dirty tail marker so the reducer knows there's nothing left
|
|
209
|
+
// to process.
|
|
210
|
+
const laterMessage = tx
|
|
211
|
+
.select({ id: messages.id })
|
|
212
|
+
.from(messages)
|
|
213
|
+
.where(
|
|
214
|
+
and(
|
|
215
|
+
eq(messages.conversationId, conversationId),
|
|
216
|
+
gt(
|
|
217
|
+
messages.createdAt,
|
|
218
|
+
getMessageCreatedAt(tx, reducedThroughMessageId),
|
|
219
|
+
),
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
.limit(1)
|
|
223
|
+
.get();
|
|
224
|
+
|
|
225
|
+
const isCaughtUp = !laterMessage;
|
|
226
|
+
|
|
227
|
+
const checkpointUpdate: Record<string, unknown> = {
|
|
228
|
+
memoryReducedThroughMessageId: reducedThroughMessageId,
|
|
229
|
+
memoryLastReducedAt: now,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (isCaughtUp) {
|
|
233
|
+
checkpointUpdate.memoryDirtyTailSinceMessageId = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
tx.update(conversations)
|
|
237
|
+
.set(checkpointUpdate)
|
|
238
|
+
.where(eq(conversations.id, conversationId))
|
|
239
|
+
.run();
|
|
240
|
+
|
|
241
|
+
log.debug(
|
|
242
|
+
{
|
|
243
|
+
conversationId,
|
|
244
|
+
reducedThroughMessageId,
|
|
245
|
+
timeContextOps: result.timeContexts.length,
|
|
246
|
+
openLoopOps: result.openLoops.length,
|
|
247
|
+
isCaughtUp,
|
|
248
|
+
},
|
|
249
|
+
"Applied reducer result",
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Internal helpers ─────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get the createdAt timestamp for a message by ID.
|
|
258
|
+
* Returns 0 if the message doesn't exist (which means the gt() comparison
|
|
259
|
+
* will match all messages — safe fallback that prevents clearing dirty tail).
|
|
260
|
+
*/
|
|
261
|
+
function getMessageCreatedAt(
|
|
262
|
+
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
|
|
263
|
+
messageId: string,
|
|
264
|
+
): number {
|
|
265
|
+
const row = tx
|
|
266
|
+
.select({ createdAt: messages.createdAt })
|
|
267
|
+
.from(messages)
|
|
268
|
+
.where(eq(messages.id, messageId))
|
|
269
|
+
.get();
|
|
270
|
+
return row?.createdAt ?? 0;
|
|
271
|
+
}
|