@sentry/junior-memory 0.119.0 → 0.120.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/README.md +22 -6
- package/dist/agent.d.ts +11 -1
- package/dist/api.d.ts +45 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +633 -269
- package/dist/index.js.map +1 -1
- package/dist/personal-store.d.ts +39 -0
- package/dist/personal.d.ts +21 -0
- package/dist/ranking.d.ts +20 -0
- package/dist/recall.d.ts +4 -1
- package/dist/store.d.ts +25 -0
- package/dist/user-pages.d.ts +2 -2
- package/migrations/0005_short_jamie_braddock.sql +1 -0
- package/migrations/meta/0005_snapshot.json +364 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -2
- package/src/agent.ts +65 -1
- package/src/api.ts +168 -0
- package/src/db/schema.ts +5 -0
- package/src/index.ts +6 -0
- package/src/personal-store.ts +195 -0
- package/src/personal.ts +120 -0
- package/src/plugin.ts +9 -0
- package/src/ranking.ts +98 -0
- package/src/recall.ts +31 -4
- package/src/store.ts +151 -236
- package/src/user-pages.ts +27 -62
package/src/api.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated REST resources for personal memories.
|
|
3
|
+
*
|
|
4
|
+
* HTTP identity is one verified viewer. Linked platform actors are resolved
|
|
5
|
+
* behind the route boundary and used only to authorize personal scopes.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import {
|
|
9
|
+
pluginApiRouteRequestContextSchema,
|
|
10
|
+
type PluginApiRouteRequestContext,
|
|
11
|
+
type PluginRouteApp,
|
|
12
|
+
type PluginUserPageActor,
|
|
13
|
+
} from "@sentry/junior-plugin-api";
|
|
14
|
+
import type { MemoryDb, MemoryRecord } from "./store";
|
|
15
|
+
import {
|
|
16
|
+
createViewerMemories,
|
|
17
|
+
InvalidMemoryCursorError,
|
|
18
|
+
PersonalMemoryNotFoundError,
|
|
19
|
+
} from "./personal";
|
|
20
|
+
|
|
21
|
+
export const memoryApiSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
content: z.string().min(1),
|
|
24
|
+
createdAt: z.iso.datetime(),
|
|
25
|
+
expiresAt: z.iso.datetime().optional(),
|
|
26
|
+
id: z.string().min(1),
|
|
27
|
+
kind: z.enum(["preference", "procedure", "knowledge"]),
|
|
28
|
+
observedAt: z.iso.datetime(),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
|
|
32
|
+
export const memoryListResponseSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
memories: z.array(memoryApiSchema),
|
|
35
|
+
nextCursor: z.string().min(1).optional(),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
|
|
39
|
+
export type MemoryApi = z.output<typeof memoryApiSchema>;
|
|
40
|
+
export type MemoryListResponse = z.output<typeof memoryListResponseSchema>;
|
|
41
|
+
|
|
42
|
+
const memoryListQuerySchema = z
|
|
43
|
+
.object({
|
|
44
|
+
cursor: z.string().min(1).max(1_000).optional(),
|
|
45
|
+
limit: z.coerce.number().int().min(1).max(50).default(25),
|
|
46
|
+
q: z.string().trim().max(200).optional(),
|
|
47
|
+
})
|
|
48
|
+
.strict();
|
|
49
|
+
|
|
50
|
+
interface MemoryApiOptions {
|
|
51
|
+
actors(email: string): Promise<PluginUserPageActor[]>;
|
|
52
|
+
db: MemoryDb;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function json(body: unknown, status = 200): Response {
|
|
56
|
+
return Response.json(body, {
|
|
57
|
+
headers: { "cache-control": "no-store" },
|
|
58
|
+
status,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function apiMemory(memory: MemoryRecord): z.output<typeof memoryApiSchema> {
|
|
63
|
+
return {
|
|
64
|
+
content: memory.content,
|
|
65
|
+
createdAt: new Date(memory.createdAtMs).toISOString(),
|
|
66
|
+
...(memory.expiresAtMs !== undefined
|
|
67
|
+
? { expiresAt: new Date(memory.expiresAtMs).toISOString() }
|
|
68
|
+
: {}),
|
|
69
|
+
id: memory.id,
|
|
70
|
+
kind: memory.kind,
|
|
71
|
+
observedAt: new Date(memory.observedAtMs).toISOString(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function viewerEmail(
|
|
76
|
+
context: PluginApiRouteRequestContext | undefined,
|
|
77
|
+
): string | undefined {
|
|
78
|
+
const parsed = pluginApiRouteRequestContextSchema.safeParse(context);
|
|
79
|
+
if (!parsed.success || parsed.data.auth.user.emailVerified !== true) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
return parsed.data.auth.user.email?.trim().toLowerCase() || undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Create the authenticated personal-memory REST app. */
|
|
86
|
+
export function createMemoryApi(options: MemoryApiOptions): PluginRouteApp {
|
|
87
|
+
return {
|
|
88
|
+
async fetch(request, context) {
|
|
89
|
+
const email = viewerEmail(context);
|
|
90
|
+
if (!email) {
|
|
91
|
+
return json({ error: "Authentication required." }, 401);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const url = new URL(request.url);
|
|
95
|
+
const memoryPath = /^\/memories\/([^/]+)$/.exec(url.pathname);
|
|
96
|
+
const isCollection = url.pathname === "/memories";
|
|
97
|
+
if (!isCollection && !memoryPath) {
|
|
98
|
+
return json({ error: "Not found." }, 404);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const actors = await options.actors(email);
|
|
102
|
+
const memories = createViewerMemories(options.db, actors);
|
|
103
|
+
try {
|
|
104
|
+
if (
|
|
105
|
+
isCollection &&
|
|
106
|
+
(request.method === "GET" || request.method === "HEAD")
|
|
107
|
+
) {
|
|
108
|
+
const query = memoryListQuerySchema.parse({
|
|
109
|
+
cursor: url.searchParams.get("cursor") ?? undefined,
|
|
110
|
+
limit: url.searchParams.get("limit") ?? undefined,
|
|
111
|
+
q: url.searchParams.get("q") ?? undefined,
|
|
112
|
+
});
|
|
113
|
+
const page = await memories.list({
|
|
114
|
+
cursor: query.cursor,
|
|
115
|
+
limit: query.limit,
|
|
116
|
+
...(query.q ? { query: query.q } : {}),
|
|
117
|
+
});
|
|
118
|
+
const body = memoryListResponseSchema.parse({
|
|
119
|
+
memories: page.memories.map(apiMemory),
|
|
120
|
+
...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
|
|
121
|
+
});
|
|
122
|
+
return request.method === "HEAD"
|
|
123
|
+
? new Response(null, {
|
|
124
|
+
headers: { "cache-control": "no-store" },
|
|
125
|
+
status: 200,
|
|
126
|
+
})
|
|
127
|
+
: json(body);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (
|
|
131
|
+
memoryPath &&
|
|
132
|
+
(request.method === "GET" || request.method === "HEAD")
|
|
133
|
+
) {
|
|
134
|
+
const memory = memoryApiSchema.parse(
|
|
135
|
+
apiMemory(await memories.get(decodeURIComponent(memoryPath[1]!))),
|
|
136
|
+
);
|
|
137
|
+
return request.method === "HEAD"
|
|
138
|
+
? new Response(null, {
|
|
139
|
+
headers: { "cache-control": "no-store" },
|
|
140
|
+
status: 200,
|
|
141
|
+
})
|
|
142
|
+
: json(memory);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (memoryPath && request.method === "DELETE") {
|
|
146
|
+
await memories.archive(decodeURIComponent(memoryPath[1]!));
|
|
147
|
+
return new Response(null, {
|
|
148
|
+
headers: { "cache-control": "no-store" },
|
|
149
|
+
status: 204,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (
|
|
154
|
+
error instanceof z.ZodError ||
|
|
155
|
+
error instanceof InvalidMemoryCursorError
|
|
156
|
+
) {
|
|
157
|
+
return json({ error: "Invalid memory request." }, 400);
|
|
158
|
+
}
|
|
159
|
+
if (error instanceof PersonalMemoryNotFoundError) {
|
|
160
|
+
return json({ error: error.message }, 404);
|
|
161
|
+
}
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return json({ error: "Method not allowed." }, 405);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
package/src/db/schema.ts
CHANGED
|
@@ -58,6 +58,11 @@ export const juniorMemoryMemories = pgTable(
|
|
|
58
58
|
.where(
|
|
59
59
|
sql`${table.archivedAtMs} IS NULL AND ${table.expiresAtMs} IS NOT NULL`,
|
|
60
60
|
),
|
|
61
|
+
index("junior_memory_memories_search_idx")
|
|
62
|
+
.using("gin", sql`to_tsvector('english', ${table.content})`)
|
|
63
|
+
.where(
|
|
64
|
+
sql`${table.archivedAtMs} IS NULL AND ${table.supersededAtMs} IS NULL AND ${table.supersededById} IS NULL`,
|
|
65
|
+
),
|
|
61
66
|
uniqueIndex("junior_memory_memories_idempotency_idx")
|
|
62
67
|
.on(table.scope, table.scopeKey, table.idempotencyKey)
|
|
63
68
|
.where(
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { memoryPlugin } from "./plugin";
|
|
2
|
+
export {
|
|
3
|
+
memoryApiSchema,
|
|
4
|
+
memoryListResponseSchema,
|
|
5
|
+
type MemoryApi,
|
|
6
|
+
type MemoryListResponse,
|
|
7
|
+
} from "./api";
|
|
2
8
|
export type { MemoryPluginOptions } from "./plugin";
|
|
3
9
|
export { createMemoryStore } from "./store";
|
|
4
10
|
export type {
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL operations over personal memories owned by one authenticated viewer.
|
|
3
|
+
*
|
|
4
|
+
* A viewer may resolve to several runtime actors. This store combines their
|
|
5
|
+
* personal scopes without treating one actor as the canonical identity.
|
|
6
|
+
*/
|
|
7
|
+
import { and, asc, desc, eq, gt, ilike, lt, or, sql } from "drizzle-orm";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { juniorMemoryEmbeddings, juniorMemoryMemories } from "./db/schema";
|
|
10
|
+
import { deriveMemoryScope, type ResolvedMemoryScope } from "./scope";
|
|
11
|
+
import {
|
|
12
|
+
activeVisiblePredicate,
|
|
13
|
+
archiveExpiredMemoryBatch,
|
|
14
|
+
parseMemoryRow,
|
|
15
|
+
type MemoryDb,
|
|
16
|
+
type MemoryRecord,
|
|
17
|
+
} from "./store";
|
|
18
|
+
import type { MemoryRuntimeContext } from "./types";
|
|
19
|
+
|
|
20
|
+
const nonEmptyStringSchema = z.string().min(1);
|
|
21
|
+
const personalMemoryCursorSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
createdAtMs: z.number().finite(),
|
|
24
|
+
id: nonEmptyStringSchema,
|
|
25
|
+
})
|
|
26
|
+
.strict();
|
|
27
|
+
const personalMemoryPageInputSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
cursor: personalMemoryCursorSchema.optional(),
|
|
30
|
+
limit: z.number().int().min(1).max(50),
|
|
31
|
+
query: z.string().max(200).optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
|
|
35
|
+
export type PersonalMemoryCursor = z.output<typeof personalMemoryCursorSchema>;
|
|
36
|
+
|
|
37
|
+
export type PersonalMemoryPageInput = z.output<
|
|
38
|
+
typeof personalMemoryPageInputSchema
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
export interface PersonalMemoryPage {
|
|
42
|
+
memories: MemoryRecord[];
|
|
43
|
+
nextCursor?: PersonalMemoryCursor;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Expected failure when a viewer does not own the requested memory. */
|
|
47
|
+
export class PersonalMemoryNotFoundError extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super("Memory was not found for the authenticated viewer.");
|
|
50
|
+
this.name = "PersonalMemoryNotFoundError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Viewer-scoped personal memory operations shared by dashboard and REST. */
|
|
55
|
+
export interface PersonalMemoryCollection {
|
|
56
|
+
/** Archive one exact personal memory owned by a linked actor. */
|
|
57
|
+
archive(id: string): Promise<MemoryRecord>;
|
|
58
|
+
/** Read one exact personal memory owned by a linked actor. */
|
|
59
|
+
get(id: string): Promise<MemoryRecord>;
|
|
60
|
+
/** List one stable page across every linked personal scope. */
|
|
61
|
+
list(input: PersonalMemoryPageInput): Promise<PersonalMemoryPage>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function personalScopes(
|
|
65
|
+
runtimeContexts: MemoryRuntimeContext[],
|
|
66
|
+
): ResolvedMemoryScope[] {
|
|
67
|
+
return [
|
|
68
|
+
...new Map(
|
|
69
|
+
runtimeContexts.map((context) => {
|
|
70
|
+
const scope = deriveMemoryScope(context, "personal");
|
|
71
|
+
return [`${scope.scope}:${scope.scopeKey}`, scope] as const;
|
|
72
|
+
}),
|
|
73
|
+
).values(),
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function searchTerms(query: string): string[] {
|
|
78
|
+
return [
|
|
79
|
+
...new Set(
|
|
80
|
+
query
|
|
81
|
+
.toLowerCase()
|
|
82
|
+
.split(/[^a-z0-9_'-]+/)
|
|
83
|
+
.map((term) => term.trim())
|
|
84
|
+
.filter((term) => term.length >= 2),
|
|
85
|
+
),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build storage operations for every personal scope linked to one viewer. */
|
|
90
|
+
export function createPersonalMemoryCollection(
|
|
91
|
+
db: MemoryDb,
|
|
92
|
+
runtimeContexts: MemoryRuntimeContext[],
|
|
93
|
+
options: { now?: () => number } = {},
|
|
94
|
+
): PersonalMemoryCollection {
|
|
95
|
+
const scopes = personalScopes(runtimeContexts);
|
|
96
|
+
const getNowMs = () => options.now?.() ?? Date.now();
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
async archive(id) {
|
|
100
|
+
const memoryId = nonEmptyStringSchema.parse(id);
|
|
101
|
+
const nowMs = getNowMs();
|
|
102
|
+
const predicate = activeVisiblePredicate({ nowMs, scopes });
|
|
103
|
+
if (!predicate) {
|
|
104
|
+
throw new PersonalMemoryNotFoundError();
|
|
105
|
+
}
|
|
106
|
+
const updated = await db
|
|
107
|
+
.update(juniorMemoryMemories)
|
|
108
|
+
.set({
|
|
109
|
+
archivedAtMs: nowMs,
|
|
110
|
+
archiveReason: "user_removed",
|
|
111
|
+
})
|
|
112
|
+
.where(and(predicate, eq(juniorMemoryMemories.id, memoryId)))
|
|
113
|
+
.returning();
|
|
114
|
+
if (!updated[0]) {
|
|
115
|
+
throw new PersonalMemoryNotFoundError();
|
|
116
|
+
}
|
|
117
|
+
await db
|
|
118
|
+
.delete(juniorMemoryEmbeddings)
|
|
119
|
+
.where(eq(juniorMemoryEmbeddings.memoryId, memoryId));
|
|
120
|
+
return parseMemoryRow(updated[0]);
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async get(id) {
|
|
124
|
+
const memoryId = nonEmptyStringSchema.parse(id);
|
|
125
|
+
const nowMs = getNowMs();
|
|
126
|
+
const predicate = activeVisiblePredicate({ nowMs, scopes });
|
|
127
|
+
if (!predicate) {
|
|
128
|
+
throw new PersonalMemoryNotFoundError();
|
|
129
|
+
}
|
|
130
|
+
const rows = await db
|
|
131
|
+
.select()
|
|
132
|
+
.from(juniorMemoryMemories)
|
|
133
|
+
.where(and(predicate, eq(juniorMemoryMemories.id, memoryId)))
|
|
134
|
+
.limit(1);
|
|
135
|
+
if (!rows[0]) {
|
|
136
|
+
throw new PersonalMemoryNotFoundError();
|
|
137
|
+
}
|
|
138
|
+
return parseMemoryRow(rows[0]);
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async list(input) {
|
|
142
|
+
input = personalMemoryPageInputSchema.parse(input);
|
|
143
|
+
const nowMs = getNowMs();
|
|
144
|
+
await archiveExpiredMemoryBatch({ db, nowMs, scopes });
|
|
145
|
+
const active = activeVisiblePredicate({ nowMs, scopes });
|
|
146
|
+
if (!active) {
|
|
147
|
+
return { memories: [] };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const cursor = input.cursor
|
|
151
|
+
? or(
|
|
152
|
+
lt(juniorMemoryMemories.createdAtMs, input.cursor.createdAtMs),
|
|
153
|
+
and(
|
|
154
|
+
eq(juniorMemoryMemories.createdAtMs, input.cursor.createdAtMs),
|
|
155
|
+
gt(juniorMemoryMemories.id, input.cursor.id),
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
: undefined;
|
|
159
|
+
const terms = input.query ? searchTerms(input.query) : [];
|
|
160
|
+
const search =
|
|
161
|
+
input.query === undefined
|
|
162
|
+
? undefined
|
|
163
|
+
: terms.length === 0
|
|
164
|
+
? sql`false`
|
|
165
|
+
: or(
|
|
166
|
+
...terms.map((term) =>
|
|
167
|
+
ilike(juniorMemoryMemories.content, `%${term}%`),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
const rows = await db
|
|
171
|
+
.select()
|
|
172
|
+
.from(juniorMemoryMemories)
|
|
173
|
+
.where(and(active, cursor, search))
|
|
174
|
+
.orderBy(
|
|
175
|
+
desc(juniorMemoryMemories.createdAtMs),
|
|
176
|
+
asc(juniorMemoryMemories.id),
|
|
177
|
+
)
|
|
178
|
+
.limit(input.limit + 1);
|
|
179
|
+
const hasNextPage = rows.length > input.limit;
|
|
180
|
+
const memories = rows.slice(0, input.limit).map(parseMemoryRow);
|
|
181
|
+
const last = memories.at(-1);
|
|
182
|
+
return {
|
|
183
|
+
memories,
|
|
184
|
+
...(hasNextPage && last
|
|
185
|
+
? {
|
|
186
|
+
nextCursor: {
|
|
187
|
+
createdAtMs: last.createdAtMs,
|
|
188
|
+
id: last.id,
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
: {}),
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/personal.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated-viewer memory access shared by REST and dashboard projections.
|
|
3
|
+
*
|
|
4
|
+
* Viewer identity may resolve to multiple platform actors. This module keeps
|
|
5
|
+
* that federation behind one personal-memory collection.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { PluginUserPageActor } from "@sentry/junior-plugin-api";
|
|
9
|
+
import { createPersonalMemoryCollection } from "./personal-store";
|
|
10
|
+
import type { MemoryDb, MemoryRecord } from "./store";
|
|
11
|
+
import type { MemoryRuntimeContext } from "./types";
|
|
12
|
+
|
|
13
|
+
const cursorSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
createdAtMs: z.number().finite(),
|
|
16
|
+
id: z.string().min(1),
|
|
17
|
+
query: z.string().max(200).optional(),
|
|
18
|
+
version: z.literal(1),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
|
|
22
|
+
export interface ViewerMemoryPage {
|
|
23
|
+
memories: MemoryRecord[];
|
|
24
|
+
nextCursor?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ViewerMemoryPageInput {
|
|
28
|
+
cursor?: string;
|
|
29
|
+
limit: number;
|
|
30
|
+
query?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class InvalidMemoryCursorError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super("Memory cursor is invalid.");
|
|
36
|
+
this.name = "InvalidMemoryCursorError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { PersonalMemoryNotFoundError } from "./personal-store";
|
|
41
|
+
|
|
42
|
+
function runtimeContext(actor: PluginUserPageActor): MemoryRuntimeContext {
|
|
43
|
+
if (actor.platform === "slack") {
|
|
44
|
+
return {
|
|
45
|
+
actor,
|
|
46
|
+
source: {
|
|
47
|
+
platform: "slack",
|
|
48
|
+
type: "priv",
|
|
49
|
+
teamId: actor.teamId,
|
|
50
|
+
channelId: "DDASHBOARD",
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
actor,
|
|
56
|
+
source: {
|
|
57
|
+
platform: "local",
|
|
58
|
+
type: "priv",
|
|
59
|
+
conversationId: "local:dashboard:memories",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeCursor(value: string | undefined, query: string | undefined) {
|
|
65
|
+
if (!value) return undefined;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = cursorSchema.parse(
|
|
68
|
+
JSON.parse(Buffer.from(value, "base64url").toString("utf8")),
|
|
69
|
+
);
|
|
70
|
+
if (parsed.query !== query) {
|
|
71
|
+
throw new InvalidMemoryCursorError();
|
|
72
|
+
}
|
|
73
|
+
return { createdAtMs: parsed.createdAtMs, id: parsed.id };
|
|
74
|
+
} catch {
|
|
75
|
+
throw new InvalidMemoryCursorError();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function encodeCursor(
|
|
80
|
+
cursor: { createdAtMs: number; id: string },
|
|
81
|
+
query: string | undefined,
|
|
82
|
+
): string {
|
|
83
|
+
return Buffer.from(
|
|
84
|
+
JSON.stringify({ ...cursor, ...(query ? { query } : {}), version: 1 }),
|
|
85
|
+
"utf8",
|
|
86
|
+
).toString("base64url");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build personal-memory operations authorized by a viewer's linked actors. */
|
|
90
|
+
export function createViewerMemories(
|
|
91
|
+
db: MemoryDb,
|
|
92
|
+
actors: PluginUserPageActor[],
|
|
93
|
+
) {
|
|
94
|
+
const collection = createPersonalMemoryCollection(
|
|
95
|
+
db,
|
|
96
|
+
actors.map(runtimeContext),
|
|
97
|
+
);
|
|
98
|
+
return {
|
|
99
|
+
async archive(id: string): Promise<MemoryRecord> {
|
|
100
|
+
return await collection.archive(id);
|
|
101
|
+
},
|
|
102
|
+
async get(id: string): Promise<MemoryRecord> {
|
|
103
|
+
return await collection.get(id);
|
|
104
|
+
},
|
|
105
|
+
async list(input: ViewerMemoryPageInput): Promise<ViewerMemoryPage> {
|
|
106
|
+
const query = input.query?.trim() || undefined;
|
|
107
|
+
const page = await collection.list({
|
|
108
|
+
cursor: decodeCursor(input.cursor, query),
|
|
109
|
+
limit: input.limit,
|
|
110
|
+
...(query ? { query } : {}),
|
|
111
|
+
});
|
|
112
|
+
return {
|
|
113
|
+
memories: page.memories,
|
|
114
|
+
...(page.nextCursor
|
|
115
|
+
? { nextCursor: encodeCursor(page.nextCursor, query) }
|
|
116
|
+
: {}),
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineJuniorPlugin } from "@sentry/junior-plugin-api";
|
|
2
2
|
import { createMemoryAgent } from "./agent";
|
|
3
|
+
import { createMemoryApi } from "./api";
|
|
3
4
|
import { createMemoryCliCommand } from "./cli";
|
|
4
5
|
import {
|
|
5
6
|
createMemoryCreateTool,
|
|
@@ -110,6 +111,12 @@ export function memoryPlugin(options: MemoryPluginOptions = {}) {
|
|
|
110
111
|
},
|
|
111
112
|
userPages: [createMemoryUserPage()],
|
|
112
113
|
hooks: {
|
|
114
|
+
apiRoutes(ctx) {
|
|
115
|
+
return createMemoryApi({
|
|
116
|
+
actors: ctx.viewer.actors,
|
|
117
|
+
db: ctx.db as MemoryDb,
|
|
118
|
+
});
|
|
119
|
+
},
|
|
113
120
|
tools(ctx) {
|
|
114
121
|
const agent = createMemoryAgent(ctx.model);
|
|
115
122
|
const context = memoryToolContext({
|
|
@@ -135,10 +142,12 @@ export function memoryPlugin(options: MemoryPluginOptions = {}) {
|
|
|
135
142
|
},
|
|
136
143
|
async userPrompt(ctx) {
|
|
137
144
|
return await createMemoryPromptContributions({
|
|
145
|
+
agent: createMemoryAgent(ctx.model),
|
|
138
146
|
...(ctx.conversationId ? { conversationId: ctx.conversationId } : {}),
|
|
139
147
|
...(ctx.actor ? { actor: ctx.actor } : {}),
|
|
140
148
|
db: ctx.db as MemoryDb,
|
|
141
149
|
embedder: ctx.embedder,
|
|
150
|
+
log: ctx.log,
|
|
142
151
|
maxVectorDistance: recallMaxVectorDistance(options),
|
|
143
152
|
source: ctx.source,
|
|
144
153
|
text: ctx.text,
|
package/src/ranking.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { MemoryRecord } from "./store";
|
|
2
|
+
|
|
3
|
+
const RECIPROCAL_RANK_FUSION_K = 60;
|
|
4
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export interface MemoryMatch {
|
|
7
|
+
exactIdentifier: boolean;
|
|
8
|
+
lexical?: {
|
|
9
|
+
rank: number;
|
|
10
|
+
};
|
|
11
|
+
memory: MemoryRecord;
|
|
12
|
+
sourceKey: string;
|
|
13
|
+
vector?: {
|
|
14
|
+
rank: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function reciprocalRank(rank: number): number {
|
|
19
|
+
return 1 / (RECIPROCAL_RANK_FUSION_K + rank);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function matchScore(match: MemoryMatch): number {
|
|
23
|
+
return (
|
|
24
|
+
(match.vector ? reciprocalRank(match.vector.rank) : 0) +
|
|
25
|
+
(match.lexical ? reciprocalRank(match.lexical.rank) : 0)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function currentChannel(
|
|
30
|
+
match: Pick<MemoryMatch, "sourceKey">,
|
|
31
|
+
channelPrefix: string | undefined,
|
|
32
|
+
): boolean {
|
|
33
|
+
return channelPrefix ? match.sourceKey.startsWith(channelPrefix) : false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function observedAgeRank(memory: MemoryRecord, nowMs: number): number {
|
|
37
|
+
const ageMs = Math.max(0, nowMs - memory.observedAtMs);
|
|
38
|
+
if (ageMs <= 7 * ONE_DAY_MS) {
|
|
39
|
+
return 3;
|
|
40
|
+
}
|
|
41
|
+
if (ageMs <= 30 * ONE_DAY_MS) {
|
|
42
|
+
return 2;
|
|
43
|
+
}
|
|
44
|
+
if (ageMs <= 90 * ONE_DAY_MS) {
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Keep exact structured-identifier matches in the strongest relevance tier,
|
|
52
|
+
* then fuse lexical and vector ranks without comparing provider raw scores.
|
|
53
|
+
*/
|
|
54
|
+
export function rankMemoryMatches(
|
|
55
|
+
matches: MemoryMatch[],
|
|
56
|
+
options: {
|
|
57
|
+
channelPrefix?: string;
|
|
58
|
+
nowMs: number;
|
|
59
|
+
},
|
|
60
|
+
): MemoryMatch[] {
|
|
61
|
+
const byId = new Map<string, MemoryMatch>();
|
|
62
|
+
for (const match of matches) {
|
|
63
|
+
const existing = byId.get(match.memory.id);
|
|
64
|
+
if (!existing) {
|
|
65
|
+
byId.set(match.memory.id, match);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
byId.set(match.memory.id, {
|
|
69
|
+
...existing,
|
|
70
|
+
exactIdentifier: existing.exactIdentifier || match.exactIdentifier,
|
|
71
|
+
...(match.lexical ? { lexical: match.lexical } : {}),
|
|
72
|
+
...(match.vector ? { vector: match.vector } : {}),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return [...byId.values()].sort((left, right) => {
|
|
76
|
+
const identifierDelta =
|
|
77
|
+
Number(right.exactIdentifier) - Number(left.exactIdentifier);
|
|
78
|
+
if (identifierDelta !== 0) {
|
|
79
|
+
return identifierDelta;
|
|
80
|
+
}
|
|
81
|
+
const scoreDelta = matchScore(right) - matchScore(left);
|
|
82
|
+
if (scoreDelta !== 0) {
|
|
83
|
+
return scoreDelta;
|
|
84
|
+
}
|
|
85
|
+
const channelDelta =
|
|
86
|
+
Number(currentChannel(right, options.channelPrefix)) -
|
|
87
|
+
Number(currentChannel(left, options.channelPrefix));
|
|
88
|
+
if (channelDelta !== 0) {
|
|
89
|
+
return channelDelta;
|
|
90
|
+
}
|
|
91
|
+
return (
|
|
92
|
+
observedAgeRank(right.memory, options.nowMs) -
|
|
93
|
+
observedAgeRank(left.memory, options.nowMs) ||
|
|
94
|
+
right.memory.observedAtMs - left.memory.observedAtMs ||
|
|
95
|
+
left.memory.id.localeCompare(right.memory.id)
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
}
|