@sentry/junior-memory 0.76.0
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/LICENSE +201 -0
- package/dist/agent.d.ts +144 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/search.d.ts +4 -0
- package/dist/cli/show.d.ts +4 -0
- package/dist/db/schema.d.ts +441 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +1773 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/process-session.d.ts +10 -0
- package/dist/recall.d.ts +12 -0
- package/dist/scope.d.ts +17 -0
- package/dist/store.d.ts +93 -0
- package/dist/tools.d.ts +103 -0
- package/dist/types.d.ts +89 -0
- package/migrations/0000_dizzy_millenium_guard.sql +37 -0
- package/migrations/0001_closed_madrox.sql +2 -0
- package/migrations/0002_light_silver_centurion.sql +17 -0
- package/migrations/meta/0000_snapshot.json +234 -0
- package/migrations/meta/0001_snapshot.json +234 -0
- package/migrations/meta/0002_snapshot.json +348 -0
- package/migrations/meta/_journal.json +27 -0
- package/package.json +48 -0
- package/src/agent.ts +437 -0
- package/src/cli/format.ts +30 -0
- package/src/cli/index.ts +15 -0
- package/src/cli/search.ts +119 -0
- package/src/cli/show.ts +44 -0
- package/src/db/schema.ts +130 -0
- package/src/index.ts +16 -0
- package/src/plugin.ts +103 -0
- package/src/process-session.ts +151 -0
- package/src/recall.ts +81 -0
- package/src/scope.ts +99 -0
- package/src/store.ts +761 -0
- package/src/tools.ts +487 -0
- package/src/types.ts +66 -0
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle source of truth for memory plugin SQL migrations.
|
|
3
|
+
*
|
|
4
|
+
* Update this schema first, then regenerate packaged migrations with
|
|
5
|
+
* `pnpm --filter @sentry/junior-memory db:generate`.
|
|
6
|
+
*/
|
|
7
|
+
import { sql } from "drizzle-orm";
|
|
8
|
+
import {
|
|
9
|
+
bigint,
|
|
10
|
+
check,
|
|
11
|
+
index,
|
|
12
|
+
integer,
|
|
13
|
+
pgTable,
|
|
14
|
+
text,
|
|
15
|
+
uniqueIndex,
|
|
16
|
+
vector,
|
|
17
|
+
} from "drizzle-orm/pg-core";
|
|
18
|
+
import {
|
|
19
|
+
MEMORY_EMBEDDING_DIMENSIONS,
|
|
20
|
+
MEMORY_EMBEDDING_METRICS,
|
|
21
|
+
MEMORY_SCOPES,
|
|
22
|
+
MEMORY_SOURCE_PLATFORMS,
|
|
23
|
+
MEMORY_SUBJECT_TYPES,
|
|
24
|
+
MEMORY_TYPES,
|
|
25
|
+
} from "../types";
|
|
26
|
+
|
|
27
|
+
export const juniorMemoryMemories = pgTable(
|
|
28
|
+
"junior_memory_memories",
|
|
29
|
+
{
|
|
30
|
+
id: text("id").primaryKey(),
|
|
31
|
+
scope: text("scope", { enum: MEMORY_SCOPES }).notNull(),
|
|
32
|
+
scopeKey: text("scope_key").notNull(),
|
|
33
|
+
type: text("type", { enum: MEMORY_TYPES }).notNull(),
|
|
34
|
+
subjectType: text("subject_type", { enum: MEMORY_SUBJECT_TYPES }).notNull(),
|
|
35
|
+
subjectKey: text("subject_key"),
|
|
36
|
+
content: text("content").notNull(),
|
|
37
|
+
sourcePlatform: text("source_platform", {
|
|
38
|
+
enum: MEMORY_SOURCE_PLATFORMS,
|
|
39
|
+
}).notNull(),
|
|
40
|
+
sourceKey: text("source_key").notNull(),
|
|
41
|
+
idempotencyKey: text("idempotency_key"),
|
|
42
|
+
observedAtMs: bigint("observed_at_ms", { mode: "number" }).notNull(),
|
|
43
|
+
createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(),
|
|
44
|
+
expiresAtMs: bigint("expires_at_ms", { mode: "number" }),
|
|
45
|
+
supersededAtMs: bigint("superseded_at_ms", { mode: "number" }),
|
|
46
|
+
supersededById: text("superseded_by_id"),
|
|
47
|
+
archivedAtMs: bigint("archived_at_ms", { mode: "number" }),
|
|
48
|
+
archiveReason: text("archive_reason"),
|
|
49
|
+
},
|
|
50
|
+
(table) => [
|
|
51
|
+
index("junior_memory_memories_visible_idx")
|
|
52
|
+
.on(table.scope, table.scopeKey, table.createdAtMs.desc(), table.id)
|
|
53
|
+
.where(
|
|
54
|
+
sql`${table.archivedAtMs} IS NULL AND ${table.supersededAtMs} IS NULL AND ${table.supersededById} IS NULL`,
|
|
55
|
+
),
|
|
56
|
+
index("junior_memory_memories_expiration_idx")
|
|
57
|
+
.on(table.expiresAtMs)
|
|
58
|
+
.where(
|
|
59
|
+
sql`${table.archivedAtMs} IS NULL AND ${table.expiresAtMs} IS NOT NULL`,
|
|
60
|
+
),
|
|
61
|
+
uniqueIndex("junior_memory_memories_idempotency_idx")
|
|
62
|
+
.on(table.scope, table.scopeKey, table.idempotencyKey)
|
|
63
|
+
.where(
|
|
64
|
+
sql`${table.idempotencyKey} IS NOT NULL AND ${table.archivedAtMs} IS NULL AND ${table.supersededAtMs} IS NULL AND ${table.supersededById} IS NULL`,
|
|
65
|
+
),
|
|
66
|
+
check(
|
|
67
|
+
"junior_memory_memories_scope_check",
|
|
68
|
+
sql`${table.scope} IN ('personal', 'conversation')`,
|
|
69
|
+
),
|
|
70
|
+
check(
|
|
71
|
+
"junior_memory_memories_type_check",
|
|
72
|
+
sql`${table.type} IN (
|
|
73
|
+
'preference',
|
|
74
|
+
'identity',
|
|
75
|
+
'relationship',
|
|
76
|
+
'knowledge',
|
|
77
|
+
'context',
|
|
78
|
+
'event',
|
|
79
|
+
'task',
|
|
80
|
+
'observation'
|
|
81
|
+
)`,
|
|
82
|
+
),
|
|
83
|
+
check(
|
|
84
|
+
"junior_memory_memories_subject_type_check",
|
|
85
|
+
sql`${table.subjectType} IN ('user', 'conversation', 'general')`,
|
|
86
|
+
),
|
|
87
|
+
check(
|
|
88
|
+
"junior_memory_memories_subject_key_check",
|
|
89
|
+
sql`(${table.subjectType} = 'general' AND ${table.subjectKey} IS NULL) OR (${table.subjectType} IN ('user', 'conversation') AND ${table.subjectKey} IS NOT NULL AND length(${table.subjectKey}) > 0)`,
|
|
90
|
+
),
|
|
91
|
+
check(
|
|
92
|
+
"junior_memory_memories_source_platform_check",
|
|
93
|
+
sql`${table.sourcePlatform} IN ('slack', 'local')`,
|
|
94
|
+
),
|
|
95
|
+
],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
export const juniorMemoryEmbeddings = pgTable(
|
|
99
|
+
"junior_memory_embeddings",
|
|
100
|
+
{
|
|
101
|
+
memoryId: text("memory_id")
|
|
102
|
+
.primaryKey()
|
|
103
|
+
.references(() => juniorMemoryMemories.id, { onDelete: "cascade" }),
|
|
104
|
+
provider: text("provider").notNull(),
|
|
105
|
+
model: text("model").notNull(),
|
|
106
|
+
dimensions: integer("dimensions").notNull(),
|
|
107
|
+
metric: text("metric", { enum: MEMORY_EMBEDDING_METRICS }).notNull(),
|
|
108
|
+
contentHash: text("content_hash").notNull(),
|
|
109
|
+
embedding: vector("embedding", {
|
|
110
|
+
dimensions: MEMORY_EMBEDDING_DIMENSIONS,
|
|
111
|
+
}).notNull(),
|
|
112
|
+
createdAtMs: bigint("created_at_ms", { mode: "number" }).notNull(),
|
|
113
|
+
},
|
|
114
|
+
(table) => [
|
|
115
|
+
index("junior_memory_embeddings_model_idx").on(
|
|
116
|
+
table.provider,
|
|
117
|
+
table.model,
|
|
118
|
+
table.dimensions,
|
|
119
|
+
table.metric,
|
|
120
|
+
),
|
|
121
|
+
check(
|
|
122
|
+
"junior_memory_embeddings_metric_check",
|
|
123
|
+
sql`${table.metric} IN ('cosine')`,
|
|
124
|
+
),
|
|
125
|
+
check(
|
|
126
|
+
"junior_memory_embeddings_dimensions_check",
|
|
127
|
+
sql`${table.dimensions} = ${sql.raw(String(MEMORY_EMBEDDING_DIMENSIONS))}`,
|
|
128
|
+
),
|
|
129
|
+
],
|
|
130
|
+
);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { createMemoryPlugin, memoryPlugin } from "./plugin";
|
|
2
|
+
export type { MemoryPluginOptions } from "./plugin";
|
|
3
|
+
export { createMemoryStore } from "./store";
|
|
4
|
+
export type {
|
|
5
|
+
ArchiveMemoryInput,
|
|
6
|
+
CreateMemoryInput,
|
|
7
|
+
CreateMemoryResult,
|
|
8
|
+
ListMemoriesInput,
|
|
9
|
+
MemoryDb,
|
|
10
|
+
MemoryEmbeddingProvider,
|
|
11
|
+
MemoryRecord,
|
|
12
|
+
MemoryStore,
|
|
13
|
+
MemoryStoreOptions,
|
|
14
|
+
SearchMemoriesInput,
|
|
15
|
+
} from "./store";
|
|
16
|
+
export type { MemoryRuntimeContext } from "./types";
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { defineJuniorPlugin } from "@sentry/junior-plugin-api";
|
|
2
|
+
import { createMemoryAgent } from "./agent";
|
|
3
|
+
import { createMemoryCliCommand } from "./cli";
|
|
4
|
+
import {
|
|
5
|
+
createMemoryCreateTool,
|
|
6
|
+
createMemoryListTool,
|
|
7
|
+
createMemoryRemoveTool,
|
|
8
|
+
createMemorySearchTool,
|
|
9
|
+
type MemoryReviewer,
|
|
10
|
+
type MemoryToolContext,
|
|
11
|
+
} from "./tools";
|
|
12
|
+
import { processMemorySession } from "./process-session";
|
|
13
|
+
import { createMemoryPromptMessages } from "./recall";
|
|
14
|
+
import type { MemoryDb } from "./store";
|
|
15
|
+
|
|
16
|
+
const MEMORY_MODEL_ENV = "AI_MEMORY_MODEL";
|
|
17
|
+
|
|
18
|
+
export interface MemoryPluginOptions {
|
|
19
|
+
modelId?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function memoryModelId(options: MemoryPluginOptions): string | undefined {
|
|
23
|
+
const explicitModelId = options.modelId?.trim();
|
|
24
|
+
if (explicitModelId) {
|
|
25
|
+
return explicitModelId;
|
|
26
|
+
}
|
|
27
|
+
const envModelId = process.env[MEMORY_MODEL_ENV]?.trim();
|
|
28
|
+
return envModelId || undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function memoryToolContext(ctx: {
|
|
32
|
+
agent: MemoryReviewer;
|
|
33
|
+
conversationId?: string;
|
|
34
|
+
db: MemoryToolContext["db"];
|
|
35
|
+
embedder?: MemoryToolContext["embedder"];
|
|
36
|
+
requester?: MemoryToolContext["requester"];
|
|
37
|
+
source: MemoryToolContext["source"];
|
|
38
|
+
userText?: string;
|
|
39
|
+
}): MemoryToolContext {
|
|
40
|
+
return {
|
|
41
|
+
agent: ctx.agent,
|
|
42
|
+
...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
|
|
43
|
+
...(ctx.requester ? { requester: ctx.requester } : {}),
|
|
44
|
+
db: ctx.db,
|
|
45
|
+
...(ctx.embedder ? { embedder: ctx.embedder } : {}),
|
|
46
|
+
source: ctx.source,
|
|
47
|
+
...(ctx.userText ? { userText: ctx.userText } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Create Junior's long-term memory plugin registration. */
|
|
52
|
+
export function createMemoryPlugin(options: MemoryPluginOptions = {}) {
|
|
53
|
+
const modelId = memoryModelId(options);
|
|
54
|
+
return defineJuniorPlugin({
|
|
55
|
+
manifest: {
|
|
56
|
+
name: "memory",
|
|
57
|
+
displayName: "Memory",
|
|
58
|
+
description: "Long-term Junior memory storage and recall",
|
|
59
|
+
},
|
|
60
|
+
model: modelId
|
|
61
|
+
? { structuredModelId: modelId }
|
|
62
|
+
: { structuredModel: "default" },
|
|
63
|
+
packageName: "@sentry/junior-memory",
|
|
64
|
+
cli: {
|
|
65
|
+
commands: [createMemoryCliCommand()],
|
|
66
|
+
},
|
|
67
|
+
tasks: {
|
|
68
|
+
processSession: {
|
|
69
|
+
async run(ctx) {
|
|
70
|
+
await processMemorySession(ctx);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
hooks: {
|
|
75
|
+
tools(ctx) {
|
|
76
|
+
const context = memoryToolContext({
|
|
77
|
+
...ctx,
|
|
78
|
+
agent: createMemoryAgent(ctx.model),
|
|
79
|
+
db: ctx.db as MemoryDb,
|
|
80
|
+
embedder: ctx.embedder,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
createMemory: createMemoryCreateTool(context),
|
|
84
|
+
removeMemory: createMemoryRemoveTool(context),
|
|
85
|
+
listMemories: createMemoryListTool(context),
|
|
86
|
+
searchMemories: createMemorySearchTool(context),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
async userPrompt(ctx) {
|
|
90
|
+
return await createMemoryPromptMessages({
|
|
91
|
+
...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
|
|
92
|
+
...(ctx.requester ? { requester: ctx.requester } : {}),
|
|
93
|
+
db: ctx.db as MemoryDb,
|
|
94
|
+
embedder: ctx.embedder,
|
|
95
|
+
source: ctx.source,
|
|
96
|
+
text: ctx.text,
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const memoryPlugin = createMemoryPlugin();
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
getSourceKey,
|
|
4
|
+
isPrivateSource,
|
|
5
|
+
type PluginTaskContext,
|
|
6
|
+
} from "@sentry/junior-plugin-api";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import {
|
|
9
|
+
createMemoryStore,
|
|
10
|
+
type CreateMemoryInput,
|
|
11
|
+
type MemoryDb,
|
|
12
|
+
} from "./store";
|
|
13
|
+
import { createMemoryAgent, type ExtractedMemory } from "./agent";
|
|
14
|
+
import { memoryRuntimeContextSchema } from "./types";
|
|
15
|
+
|
|
16
|
+
const MEMORY_TOOL_NAMES = new Set([
|
|
17
|
+
"createMemory",
|
|
18
|
+
"listMemories",
|
|
19
|
+
"removeMemory",
|
|
20
|
+
"searchMemories",
|
|
21
|
+
]);
|
|
22
|
+
const MEMORY_TASK_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
|
+
const extractedMemoryCacheSchema = z.array(
|
|
24
|
+
z
|
|
25
|
+
.object({
|
|
26
|
+
content: z.string().min(1),
|
|
27
|
+
expiresAtMs: z.number().finite().nullable(),
|
|
28
|
+
target: z.enum(["requester", "conversation"]),
|
|
29
|
+
})
|
|
30
|
+
.strict(),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
function memoryIdempotencySuffix(memory: ExtractedMemory): string {
|
|
34
|
+
return createHash("sha256")
|
|
35
|
+
.update(memory.target)
|
|
36
|
+
.update("\0")
|
|
37
|
+
.update(memory.content)
|
|
38
|
+
.update("\0")
|
|
39
|
+
.update(memory.expiresAtMs === null ? "never" : String(memory.expiresAtMs))
|
|
40
|
+
.digest("hex")
|
|
41
|
+
.slice(0, 32);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function passiveInput(
|
|
45
|
+
sessionId: string,
|
|
46
|
+
memory: ExtractedMemory,
|
|
47
|
+
sourceKey: string,
|
|
48
|
+
): CreateMemoryInput {
|
|
49
|
+
return {
|
|
50
|
+
content: memory.content,
|
|
51
|
+
idempotencyKey: `session:${sourceKey}:${sessionId}:${memoryIdempotencySuffix(memory)}`,
|
|
52
|
+
...(memory.expiresAtMs !== null ? { expiresAtMs: memory.expiresAtMs } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function getTaskMemories(
|
|
57
|
+
context: PluginTaskContext,
|
|
58
|
+
extract: () => Promise<ExtractedMemory[]>,
|
|
59
|
+
): Promise<ExtractedMemory[]> {
|
|
60
|
+
const cacheKey = `memory-extraction:${context.id}`;
|
|
61
|
+
const cached = await context.state.get(cacheKey);
|
|
62
|
+
if (cached !== undefined) {
|
|
63
|
+
return extractedMemoryCacheSchema.parse(cached);
|
|
64
|
+
}
|
|
65
|
+
const memories = await extract();
|
|
66
|
+
if (memories.length > 0) {
|
|
67
|
+
await context.state.set(cacheKey, memories, MEMORY_TASK_STATE_TTL_MS);
|
|
68
|
+
}
|
|
69
|
+
return memories;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract and store memories from a completed session plugin task.
|
|
74
|
+
*
|
|
75
|
+
* Memory owns post-session extraction and consumes only the bounded plugin task
|
|
76
|
+
* projection. Explicit memory tools and private non-local sources remain hard
|
|
77
|
+
* boundaries so background retries cannot reinterpret user-directed mutations
|
|
78
|
+
* or private conversations.
|
|
79
|
+
*/
|
|
80
|
+
export async function processMemorySession(
|
|
81
|
+
context: PluginTaskContext,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const run = await context.run.load();
|
|
84
|
+
// Memory tool turns already own memory management or recall; do not reinterpret
|
|
85
|
+
// recalled memory output as fresh passive-learning evidence.
|
|
86
|
+
if (
|
|
87
|
+
run.transcript.some(
|
|
88
|
+
(entry) =>
|
|
89
|
+
entry.type === "toolResult" && MEMORY_TOOL_NAMES.has(entry.toolName),
|
|
90
|
+
)
|
|
91
|
+
) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// V1 passive learning only stores public channel facts outside local QA.
|
|
95
|
+
if (run.source.platform !== "local" && isPrivateSource(run.source)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const sourceKey = getSourceKey(run.source);
|
|
99
|
+
if (!sourceKey) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const transcript = run.transcript
|
|
103
|
+
.filter((entry) => entry.text?.trim())
|
|
104
|
+
.map((entry) => ({ ...entry, text: entry.text!.trim() }));
|
|
105
|
+
const evidenceText = transcript
|
|
106
|
+
.filter((entry) => entry.type === "toolResult" || entry.role === "user")
|
|
107
|
+
.map((entry) => entry.text)
|
|
108
|
+
.join("\n\n")
|
|
109
|
+
.trim();
|
|
110
|
+
if (!evidenceText) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const runtimeContext = memoryRuntimeContextSchema.parse({
|
|
115
|
+
conversationId: run.conversationId,
|
|
116
|
+
...(run.requester ? { requester: run.requester } : {}),
|
|
117
|
+
source: run.source,
|
|
118
|
+
});
|
|
119
|
+
const store = createMemoryStore(context.db as MemoryDb, runtimeContext, {
|
|
120
|
+
embedder: context.embedder,
|
|
121
|
+
});
|
|
122
|
+
const memories = await getTaskMemories(context, async () => {
|
|
123
|
+
const existingMemories = await store.searchMemories({
|
|
124
|
+
limit: 10,
|
|
125
|
+
query: evidenceText,
|
|
126
|
+
});
|
|
127
|
+
const agent = createMemoryAgent(context.model);
|
|
128
|
+
return await agent.extractSessionMemories({
|
|
129
|
+
existingMemories: existingMemories.map((memory) => ({
|
|
130
|
+
content: memory.content,
|
|
131
|
+
})),
|
|
132
|
+
transcript,
|
|
133
|
+
runtimeContext,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
if (memories.length === 0) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const memory of memories) {
|
|
141
|
+
const input = passiveInput(run.runId, memory, sourceKey);
|
|
142
|
+
if (memory.target === "conversation") {
|
|
143
|
+
await store.createConversationMemory(input);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!run.requester) {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
await store.createMemory(input);
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/recall.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PromptMessage,
|
|
3
|
+
Requester,
|
|
4
|
+
Source,
|
|
5
|
+
} from "@sentry/junior-plugin-api";
|
|
6
|
+
import {
|
|
7
|
+
createMemoryStore,
|
|
8
|
+
type MemoryDb,
|
|
9
|
+
type MemoryEmbeddingProvider,
|
|
10
|
+
type MemoryRecord,
|
|
11
|
+
} from "./store";
|
|
12
|
+
import { memoryRuntimeContextSchema } from "./types";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_RECALL_LIMIT = 5;
|
|
15
|
+
const MAX_PROMPT_CHARS = 1_600;
|
|
16
|
+
const MAX_MEMORY_LINE_CHARS = 320;
|
|
17
|
+
|
|
18
|
+
export interface MemoryRecallContext {
|
|
19
|
+
conversationId?: string;
|
|
20
|
+
db: MemoryDb;
|
|
21
|
+
embedder?: MemoryEmbeddingProvider;
|
|
22
|
+
requester?: Requester;
|
|
23
|
+
source: Source;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function trimContent(content: string, maxLength: number): string {
|
|
28
|
+
const trimmed = content.trim();
|
|
29
|
+
if (trimmed.length <= maxLength) {
|
|
30
|
+
return trimmed;
|
|
31
|
+
}
|
|
32
|
+
return `${trimmed.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderMemoryPrompt(memories: MemoryRecord[]): string | undefined {
|
|
36
|
+
const header = "Relevant memories for this request:";
|
|
37
|
+
const footer =
|
|
38
|
+
"Treat these as possibly stale context. Current user instructions and repository evidence take priority.";
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
let totalChars = header.length + footer.length + 2;
|
|
41
|
+
|
|
42
|
+
for (const memory of memories) {
|
|
43
|
+
const line = `- ${trimContent(memory.content, MAX_MEMORY_LINE_CHARS)}`;
|
|
44
|
+
if (totalChars + line.length + 1 > MAX_PROMPT_CHARS) {
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
lines.push(line);
|
|
48
|
+
totalChars += line.length + 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (lines.length === 0) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return `${header}\n${lines.join("\n")}\n\n${footer}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build the memory prompt contribution for active visible recall. */
|
|
58
|
+
export async function createMemoryPromptMessages(
|
|
59
|
+
context: MemoryRecallContext,
|
|
60
|
+
): Promise<PromptMessage[] | undefined> {
|
|
61
|
+
if (!context.text.trim()) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const runtimeContext = memoryRuntimeContextSchema.parse({
|
|
65
|
+
...(context.conversationId
|
|
66
|
+
? { conversationId: context.conversationId }
|
|
67
|
+
: {}),
|
|
68
|
+
...(context.requester ? { requester: context.requester } : {}),
|
|
69
|
+
source: context.source,
|
|
70
|
+
});
|
|
71
|
+
const memories = await createMemoryStore(
|
|
72
|
+
context.db,
|
|
73
|
+
runtimeContext,
|
|
74
|
+
context.embedder ? { embedder: context.embedder } : {},
|
|
75
|
+
).searchMemories({
|
|
76
|
+
query: context.text,
|
|
77
|
+
limit: DEFAULT_RECALL_LIMIT,
|
|
78
|
+
});
|
|
79
|
+
const text = renderMemoryPrompt(memories);
|
|
80
|
+
return text ? [{ text }] : undefined;
|
|
81
|
+
}
|
package/src/scope.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MemoryRuntimeContext,
|
|
3
|
+
MemoryScope,
|
|
4
|
+
MemorySubjectType,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
/** Runtime-derived visibility scope used for memory authorization checks. */
|
|
8
|
+
export interface ResolvedMemoryScope {
|
|
9
|
+
scope: MemoryScope;
|
|
10
|
+
scopeKey: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Runtime-derived subject classification stored for filtering and rendering. */
|
|
14
|
+
export interface ResolvedMemorySubject {
|
|
15
|
+
subjectKey?: string;
|
|
16
|
+
subjectType: MemorySubjectType;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function sourceConversationKey(ctx: MemoryRuntimeContext): string | undefined {
|
|
20
|
+
if (ctx.source.platform === "local") {
|
|
21
|
+
return ctx.source.conversationId;
|
|
22
|
+
}
|
|
23
|
+
const threadKey = ctx.source.threadTs ?? ctx.source.messageTs;
|
|
24
|
+
if (!threadKey) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return `slack:${ctx.source.teamId}:${ctx.source.channelId}:${threadKey}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requesterScopeKey(ctx: MemoryRuntimeContext): string | undefined {
|
|
31
|
+
const requester = ctx.requester;
|
|
32
|
+
if (!requester?.userId) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
if (requester.platform === "slack") {
|
|
36
|
+
return `slack:${requester.teamId}:${requester.userId}`;
|
|
37
|
+
}
|
|
38
|
+
return `local:${requester.userId}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Derive the authority-bearing key for a requested memory scope. */
|
|
42
|
+
export function deriveMemoryScope(
|
|
43
|
+
ctx: MemoryRuntimeContext,
|
|
44
|
+
scope: MemoryScope,
|
|
45
|
+
): ResolvedMemoryScope {
|
|
46
|
+
if (scope === "personal") {
|
|
47
|
+
const scopeKey = requesterScopeKey(ctx);
|
|
48
|
+
if (!scopeKey) {
|
|
49
|
+
throw new Error("Personal memory requires requester context.");
|
|
50
|
+
}
|
|
51
|
+
return { scope, scopeKey };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const scopeKey = sourceConversationKey(ctx);
|
|
55
|
+
if (!scopeKey) {
|
|
56
|
+
throw new Error("Conversation memory requires conversation context.");
|
|
57
|
+
}
|
|
58
|
+
return { scope, scopeKey };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Derive the memory subject from the already-authorized write scope. */
|
|
62
|
+
export function deriveMemorySubject(
|
|
63
|
+
ctx: MemoryRuntimeContext,
|
|
64
|
+
scope: ResolvedMemoryScope,
|
|
65
|
+
): ResolvedMemorySubject {
|
|
66
|
+
if (scope.scope === "personal") {
|
|
67
|
+
const subjectKey = requesterScopeKey(ctx);
|
|
68
|
+
if (!subjectKey) {
|
|
69
|
+
throw new Error("User-subject memory requires requester context.");
|
|
70
|
+
}
|
|
71
|
+
return { subjectType: "user", subjectKey };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const subjectKey = sourceConversationKey(ctx);
|
|
75
|
+
if (!subjectKey) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Conversation-subject memory requires conversation context.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return { subjectType: "conversation", subjectKey };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Return every visible scope for memory retrieval in the current context. */
|
|
84
|
+
export function deriveVisibleMemoryScopes(
|
|
85
|
+
ctx: MemoryRuntimeContext,
|
|
86
|
+
): ResolvedMemoryScope[] {
|
|
87
|
+
const scopes: ResolvedMemoryScope[] = [];
|
|
88
|
+
try {
|
|
89
|
+
scopes.push(deriveMemoryScope(ctx, "personal"));
|
|
90
|
+
} catch {
|
|
91
|
+
// Personal memory is optional when a runtime surface has no requester.
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
scopes.push(deriveMemoryScope(ctx, "conversation"));
|
|
95
|
+
} catch {
|
|
96
|
+
// Conversation memory is optional for synthetic invocations.
|
|
97
|
+
}
|
|
98
|
+
return scopes;
|
|
99
|
+
}
|