@sentry/junior-memory 0.118.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/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
- export { createMemoryPlugin, memoryPlugin } from "./plugin";
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
+ }
@@ -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,
@@ -85,8 +86,8 @@ function memoryCreateToolContext(ctx: {
85
86
  };
86
87
  }
87
88
 
88
- /** Create Junior's long-term memory plugin registration. */
89
- export function createMemoryPlugin(options: MemoryPluginOptions = {}) {
89
+ /** Register Junior's long-term memory plugin. */
90
+ export function memoryPlugin(options: MemoryPluginOptions = {}) {
90
91
  const modelId = memoryModelId(options);
91
92
  return defineJuniorPlugin({
92
93
  manifest: {
@@ -110,6 +111,12 @@ export function createMemoryPlugin(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 createMemoryPlugin(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,
@@ -147,5 +156,3 @@ export function createMemoryPlugin(options: MemoryPluginOptions = {}) {
147
156
  },
148
157
  });
149
158
  }
150
-
151
- export const memoryPlugin = createMemoryPlugin();
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
+ }