@sentry/junior-memory 0.119.0 → 0.121.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 +794 -270
- package/dist/index.js.map +1 -1
- package/dist/operational-report.d.ts +7 -0
- 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 +5 -7
- 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/operational-report.ts +191 -0
- package/src/personal-store.ts +195 -0
- package/src/personal.ts +120 -0
- package/src/plugin.ts +16 -0
- package/src/ranking.ts +98 -0
- package/src/recall.ts +32 -13
- 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,191 @@
|
|
|
1
|
+
import type { PluginOperationalReportContent } from "@sentry/junior-plugin-api";
|
|
2
|
+
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { juniorMemoryEmbeddings, juniorMemoryMemories } from "./db/schema";
|
|
5
|
+
import type { MemoryDb } from "./store";
|
|
6
|
+
|
|
7
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
8
|
+
const WINDOWS = [7, 30, 90] as const;
|
|
9
|
+
|
|
10
|
+
const memoryDaySchema = z
|
|
11
|
+
.object({
|
|
12
|
+
conversation: z.number().int().nonnegative(),
|
|
13
|
+
date: z.string().date(),
|
|
14
|
+
personal: z.number().int().nonnegative(),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
|
|
18
|
+
function queryRows(result: unknown): unknown[] {
|
|
19
|
+
if (
|
|
20
|
+
typeof result !== "object" ||
|
|
21
|
+
result === null ||
|
|
22
|
+
!("rows" in result) ||
|
|
23
|
+
!Array.isArray(result.rows)
|
|
24
|
+
) {
|
|
25
|
+
throw new TypeError("Memory activity query did not return rows");
|
|
26
|
+
}
|
|
27
|
+
return result.rows;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function startOfUtcDay(value: number): Date {
|
|
31
|
+
const date = new Date(value);
|
|
32
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
33
|
+
return date;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function aggregateMemoryDays(args: { db: MemoryDb; nowMs: number }) {
|
|
37
|
+
const end = startOfUtcDay(args.nowMs);
|
|
38
|
+
const start = startOfUtcDay(args.nowMs - (WINDOWS.at(-1)! - 1) * DAY_MS);
|
|
39
|
+
const endExclusiveMs = end.getTime() + DAY_MS;
|
|
40
|
+
const table = juniorMemoryMemories;
|
|
41
|
+
const result = await args.db.execute(sql`
|
|
42
|
+
WITH days AS (
|
|
43
|
+
SELECT generate_series(
|
|
44
|
+
date_trunc('day', ${start}::timestamptz AT TIME ZONE 'UTC'),
|
|
45
|
+
date_trunc('day', ${end}::timestamptz AT TIME ZONE 'UTC'),
|
|
46
|
+
interval '1 day'
|
|
47
|
+
) AS day
|
|
48
|
+
), daily AS (
|
|
49
|
+
SELECT
|
|
50
|
+
date_trunc(
|
|
51
|
+
'day',
|
|
52
|
+
to_timestamp(${table.createdAtMs} / 1000.0) AT TIME ZONE 'UTC'
|
|
53
|
+
) AS day,
|
|
54
|
+
count(*) FILTER (
|
|
55
|
+
WHERE ${table.scope} = 'personal'
|
|
56
|
+
)::integer AS personal,
|
|
57
|
+
count(*) FILTER (
|
|
58
|
+
WHERE ${table.scope} = 'conversation'
|
|
59
|
+
)::integer AS conversation
|
|
60
|
+
FROM ${table}
|
|
61
|
+
WHERE ${table.createdAtMs} >= ${start.getTime()}
|
|
62
|
+
AND ${table.createdAtMs} < ${endExclusiveMs}
|
|
63
|
+
GROUP BY date_trunc(
|
|
64
|
+
'day',
|
|
65
|
+
to_timestamp(${table.createdAtMs} / 1000.0) AT TIME ZONE 'UTC'
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
SELECT
|
|
69
|
+
to_char(days.day, 'YYYY-MM-DD') AS date,
|
|
70
|
+
coalesce(daily.personal, 0)::integer AS personal,
|
|
71
|
+
coalesce(daily.conversation, 0)::integer AS conversation
|
|
72
|
+
FROM days
|
|
73
|
+
LEFT JOIN daily ON daily.day = days.day
|
|
74
|
+
ORDER BY days.day
|
|
75
|
+
`);
|
|
76
|
+
return z.array(memoryDaySchema).parse(queryRows(result));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatCount(value: number): string {
|
|
80
|
+
return new Intl.NumberFormat("en-US").format(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatPercent(value: number): string {
|
|
84
|
+
return new Intl.NumberFormat("en-US", {
|
|
85
|
+
maximumFractionDigits: 0,
|
|
86
|
+
style: "percent",
|
|
87
|
+
}).format(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build aggregate memory storage and indexing diagnostics for the System page. */
|
|
91
|
+
export async function buildMemoryOperationalReport(args: {
|
|
92
|
+
db: MemoryDb;
|
|
93
|
+
nowMs: number;
|
|
94
|
+
}): Promise<PluginOperationalReportContent> {
|
|
95
|
+
const active = and(
|
|
96
|
+
isNull(juniorMemoryMemories.archivedAtMs),
|
|
97
|
+
isNull(juniorMemoryMemories.supersededAtMs),
|
|
98
|
+
isNull(juniorMemoryMemories.supersededById),
|
|
99
|
+
or(
|
|
100
|
+
isNull(juniorMemoryMemories.expiresAtMs),
|
|
101
|
+
gt(juniorMemoryMemories.expiresAtMs, args.nowMs),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
const [[counts], memoryDays] = await Promise.all([
|
|
105
|
+
args.db
|
|
106
|
+
.select({
|
|
107
|
+
active: sql<number>`count(*) filter (where ${active})`.mapWith(Number),
|
|
108
|
+
conversation:
|
|
109
|
+
sql<number>`count(*) filter (where ${active} and ${juniorMemoryMemories.scope} = 'conversation')`.mapWith(
|
|
110
|
+
Number,
|
|
111
|
+
),
|
|
112
|
+
createdThirtyDays:
|
|
113
|
+
sql<number>`count(*) filter (where ${juniorMemoryMemories.createdAtMs} >= ${args.nowMs - 30 * DAY_MS})`.mapWith(
|
|
114
|
+
Number,
|
|
115
|
+
),
|
|
116
|
+
embedded:
|
|
117
|
+
sql<number>`count(${juniorMemoryEmbeddings.memoryId}) filter (where ${active})`.mapWith(
|
|
118
|
+
Number,
|
|
119
|
+
),
|
|
120
|
+
personal:
|
|
121
|
+
sql<number>`count(*) filter (where ${active} and ${juniorMemoryMemories.scope} = 'personal')`.mapWith(
|
|
122
|
+
Number,
|
|
123
|
+
),
|
|
124
|
+
})
|
|
125
|
+
.from(juniorMemoryMemories)
|
|
126
|
+
.leftJoin(
|
|
127
|
+
juniorMemoryEmbeddings,
|
|
128
|
+
eq(juniorMemoryEmbeddings.memoryId, juniorMemoryMemories.id),
|
|
129
|
+
),
|
|
130
|
+
aggregateMemoryDays(args),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const activeCount = counts?.active ?? 0;
|
|
134
|
+
const embeddedCount = counts?.embedded ?? 0;
|
|
135
|
+
const embeddingCoverage = activeCount === 0 ? 0 : embeddedCount / activeCount;
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
generatedAt: new Date(args.nowMs).toISOString(),
|
|
139
|
+
title: "Memory",
|
|
140
|
+
metrics: [
|
|
141
|
+
{
|
|
142
|
+
label: "active memories",
|
|
143
|
+
tone: activeCount > 0 ? "good" : "neutral",
|
|
144
|
+
value: formatCount(activeCount),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
label: "created · 30d",
|
|
148
|
+
value: formatCount(counts?.createdThirtyDays ?? 0),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
label: "personal",
|
|
152
|
+
value: formatCount(counts?.personal ?? 0),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
label: "conversation",
|
|
156
|
+
value: formatCount(counts?.conversation ?? 0),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
label: "embedding coverage",
|
|
160
|
+
tone:
|
|
161
|
+
activeCount === 0
|
|
162
|
+
? "neutral"
|
|
163
|
+
: embeddedCount === activeCount
|
|
164
|
+
? "good"
|
|
165
|
+
: "warning",
|
|
166
|
+
value: formatPercent(embeddingCoverage),
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
widgets: [
|
|
170
|
+
{
|
|
171
|
+
categories: memoryDays.map((day) => ({
|
|
172
|
+
id: day.date,
|
|
173
|
+
label: day.date,
|
|
174
|
+
values: {
|
|
175
|
+
conversation: day.conversation,
|
|
176
|
+
personal: day.personal,
|
|
177
|
+
},
|
|
178
|
+
})),
|
|
179
|
+
description: "Memories stored per day by scope",
|
|
180
|
+
id: "memories-created",
|
|
181
|
+
series: [
|
|
182
|
+
{ key: "personal", label: "Personal" },
|
|
183
|
+
{ key: "conversation", label: "Conversation" },
|
|
184
|
+
],
|
|
185
|
+
timeRangeDays: [...WINDOWS],
|
|
186
|
+
title: "Memories created",
|
|
187
|
+
type: "bar_chart",
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -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
|
+
}
|