@poncho-ai/harness 0.2.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/memory.ts ADDED
@@ -0,0 +1,700 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
6
+ import type { StateProviderName } from "./state.js";
7
+
8
+ export interface MainMemory {
9
+ content: string;
10
+ updatedAt: number;
11
+ }
12
+
13
+ export interface MemoryConfig {
14
+ enabled?: boolean;
15
+ provider?: StateProviderName;
16
+ url?: string;
17
+ token?: string;
18
+ table?: string;
19
+ region?: string;
20
+ ttl?: number;
21
+ maxRecallConversations?: number;
22
+ }
23
+
24
+ export interface MemoryStore {
25
+ getMainMemory(): Promise<MainMemory>;
26
+ updateMainMemory(input: { content: string; mode?: "replace" | "append" }): Promise<MainMemory>;
27
+ }
28
+
29
+ type MainMemoryPayload = {
30
+ main: MainMemory;
31
+ };
32
+
33
+ type RecallItem = {
34
+ conversationId: string;
35
+ title: string;
36
+ updatedAt: number;
37
+ content: string;
38
+ };
39
+
40
+ const DEFAULT_MAIN_MEMORY: MainMemory = {
41
+ content: "",
42
+ updatedAt: 0,
43
+ };
44
+ const LOCAL_MEMORY_FILE = "local-memory.json";
45
+
46
+ const getStateDirectory = (): string => {
47
+ const cwd = process.cwd();
48
+ const home = homedir();
49
+ const isServerless =
50
+ process.env.VERCEL === "1" ||
51
+ process.env.VERCEL_ENV !== undefined ||
52
+ process.env.VERCEL_URL !== undefined ||
53
+ process.env.AWS_LAMBDA_FUNCTION_NAME !== undefined ||
54
+ process.env.AWS_EXECUTION_ENV?.includes("AWS_Lambda") === true ||
55
+ process.env.LAMBDA_TASK_ROOT !== undefined ||
56
+ process.env.NOW_REGION !== undefined ||
57
+ cwd.startsWith("/var/task") ||
58
+ home.startsWith("/var/task") ||
59
+ process.env.SERVERLESS === "1";
60
+ if (isServerless) {
61
+ return "/tmp/.poncho/state";
62
+ }
63
+ return resolve(homedir(), ".poncho", "state");
64
+ };
65
+
66
+ const projectScopedMemoryPath = (workingDir: string): string => {
67
+ const projectName = basename(workingDir).replace(/[^a-zA-Z0-9_-]+/g, "-") || "project";
68
+ const projectHash = createHash("sha256").update(workingDir).digest("hex").slice(0, 12);
69
+ return resolve(getStateDirectory(), `${projectName}-${projectHash}-${LOCAL_MEMORY_FILE}`);
70
+ };
71
+
72
+ const scoreText = (text: string, query: string): number => {
73
+ const normalized = query.trim().toLowerCase();
74
+ const tokens = normalized.split(/\s+/).filter(Boolean);
75
+ if (tokens.length === 0) {
76
+ return 0;
77
+ }
78
+ const haystack = text.toLowerCase();
79
+ let score = haystack.includes(normalized) ? 5 : 0;
80
+ for (const token of tokens) {
81
+ if (haystack.includes(token)) {
82
+ score += 1;
83
+ }
84
+ }
85
+ return score;
86
+ };
87
+
88
+ class InMemoryMemoryStore implements MemoryStore {
89
+ private mainMemory: MainMemory = { ...DEFAULT_MAIN_MEMORY };
90
+ private readonly ttlMs?: number;
91
+
92
+ constructor(ttlSeconds?: number) {
93
+ this.ttlMs = typeof ttlSeconds === "number" ? ttlSeconds * 1000 : undefined;
94
+ }
95
+
96
+ private isExpired(updatedAt: number): boolean {
97
+ return typeof this.ttlMs === "number" && Date.now() - updatedAt > this.ttlMs;
98
+ }
99
+
100
+ async getMainMemory(): Promise<MainMemory> {
101
+ if (this.mainMemory.updatedAt > 0 && this.isExpired(this.mainMemory.updatedAt)) {
102
+ this.mainMemory = { ...DEFAULT_MAIN_MEMORY };
103
+ }
104
+ return this.mainMemory;
105
+ }
106
+
107
+ async updateMainMemory(input: {
108
+ content: string;
109
+ mode?: "replace" | "append";
110
+ }): Promise<MainMemory> {
111
+ const now = Date.now();
112
+ const existing = await this.getMainMemory();
113
+ const nextContent =
114
+ input.mode === "append" && existing.content
115
+ ? `${existing.content}\n\n${input.content}`.trim()
116
+ : input.content;
117
+ this.mainMemory = {
118
+ content: nextContent.trim(),
119
+ updatedAt: now,
120
+ };
121
+ return this.mainMemory;
122
+ }
123
+ }
124
+
125
+ class FileMainMemoryStore implements MemoryStore {
126
+ private readonly filePath: string;
127
+ private readonly ttlMs?: number;
128
+ private loaded = false;
129
+ private writing = Promise.resolve();
130
+ private mainMemory: MainMemory = { ...DEFAULT_MAIN_MEMORY };
131
+
132
+ constructor(workingDir: string, ttlSeconds?: number) {
133
+ this.filePath = projectScopedMemoryPath(workingDir);
134
+ this.ttlMs = typeof ttlSeconds === "number" ? ttlSeconds * 1000 : undefined;
135
+ }
136
+
137
+ private isExpired(updatedAt: number): boolean {
138
+ return typeof this.ttlMs === "number" && Date.now() - updatedAt > this.ttlMs;
139
+ }
140
+
141
+ private async ensureLoaded(): Promise<void> {
142
+ if (this.loaded) {
143
+ return;
144
+ }
145
+ this.loaded = true;
146
+ try {
147
+ const raw = await readFile(this.filePath, "utf8");
148
+ const parsed = JSON.parse(raw) as MainMemoryPayload;
149
+ const content = typeof parsed.main?.content === "string" ? parsed.main.content : "";
150
+ const updatedAt = typeof parsed.main?.updatedAt === "number" ? parsed.main.updatedAt : 0;
151
+ this.mainMemory = { content, updatedAt };
152
+ } catch {
153
+ // Missing or invalid file should not crash local mode.
154
+ }
155
+ }
156
+
157
+ private async persist(): Promise<void> {
158
+ const payload: MainMemoryPayload = { main: this.mainMemory };
159
+ this.writing = this.writing.then(async () => {
160
+ await mkdir(dirname(this.filePath), { recursive: true });
161
+ await writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
162
+ });
163
+ await this.writing;
164
+ }
165
+
166
+ async getMainMemory(): Promise<MainMemory> {
167
+ await this.ensureLoaded();
168
+ if (this.mainMemory.updatedAt > 0 && this.isExpired(this.mainMemory.updatedAt)) {
169
+ this.mainMemory = { ...DEFAULT_MAIN_MEMORY };
170
+ await this.persist();
171
+ }
172
+ return this.mainMemory;
173
+ }
174
+
175
+ async updateMainMemory(input: {
176
+ content: string;
177
+ mode?: "replace" | "append";
178
+ }): Promise<MainMemory> {
179
+ await this.ensureLoaded();
180
+ const existing = await this.getMainMemory();
181
+ const nextContent =
182
+ input.mode === "append" && existing.content
183
+ ? `${existing.content}\n\n${input.content}`.trim()
184
+ : input.content;
185
+ this.mainMemory = {
186
+ content: nextContent.trim(),
187
+ updatedAt: Date.now(),
188
+ };
189
+ await this.persist();
190
+ return this.mainMemory;
191
+ }
192
+ }
193
+
194
+ abstract class KeyValueMainMemoryStoreBase implements MemoryStore {
195
+ protected readonly ttl?: number;
196
+ protected readonly memoryFallback: InMemoryMemoryStore;
197
+
198
+ constructor(ttl?: number) {
199
+ this.ttl = ttl;
200
+ this.memoryFallback = new InMemoryMemoryStore(ttl);
201
+ }
202
+
203
+ protected abstract getRaw(key: string): Promise<string | undefined>;
204
+ protected abstract setRaw(key: string, value: string): Promise<void>;
205
+ protected abstract setRawWithTtl(key: string, value: string, ttl: number): Promise<void>;
206
+
207
+ protected async readPayload(key: string): Promise<MainMemoryPayload> {
208
+ try {
209
+ const raw = await this.getRaw(key);
210
+ if (!raw) {
211
+ return { main: { ...DEFAULT_MAIN_MEMORY } };
212
+ }
213
+ const parsed = JSON.parse(raw) as MainMemoryPayload;
214
+ const content = typeof parsed.main?.content === "string" ? parsed.main.content : "";
215
+ const updatedAt = typeof parsed.main?.updatedAt === "number" ? parsed.main.updatedAt : 0;
216
+ return { main: { content, updatedAt } };
217
+ } catch {
218
+ const main = await this.memoryFallback.getMainMemory();
219
+ return { main };
220
+ }
221
+ }
222
+
223
+ protected async writePayload(key: string, payload: MainMemoryPayload): Promise<void> {
224
+ try {
225
+ const serialized = JSON.stringify(payload);
226
+ if (typeof this.ttl === "number") {
227
+ await this.setRawWithTtl(key, serialized, Math.max(1, this.ttl));
228
+ } else {
229
+ await this.setRaw(key, serialized);
230
+ }
231
+ } catch {
232
+ await this.memoryFallback.updateMainMemory({
233
+ content: payload.main.content,
234
+ mode: "replace",
235
+ });
236
+ }
237
+ }
238
+
239
+ async getMainMemory(): Promise<MainMemory> {
240
+ const payload = await this.readPayload(this.key());
241
+ return payload.main;
242
+ }
243
+
244
+ async updateMainMemory(input: {
245
+ content: string;
246
+ mode?: "replace" | "append";
247
+ }): Promise<MainMemory> {
248
+ const key = this.key();
249
+ const payload = await this.readPayload(key);
250
+ const nextContent =
251
+ input.mode === "append" && payload.main.content
252
+ ? `${payload.main.content}\n\n${input.content}`.trim()
253
+ : input.content;
254
+ payload.main = {
255
+ content: nextContent.trim(),
256
+ updatedAt: Date.now(),
257
+ };
258
+ await this.writePayload(key, payload);
259
+ return payload.main;
260
+ }
261
+
262
+ protected abstract key(): string;
263
+ }
264
+
265
+ class UpstashMemoryStore extends KeyValueMainMemoryStoreBase {
266
+ private readonly baseUrl: string;
267
+ private readonly token: string;
268
+ private readonly storageKey: string;
269
+
270
+ constructor(options: {
271
+ baseUrl: string;
272
+ token: string;
273
+ storageKey: string;
274
+ ttl?: number;
275
+ }) {
276
+ super(options.ttl);
277
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
278
+ this.token = options.token;
279
+ this.storageKey = options.storageKey;
280
+ }
281
+
282
+ protected key(): string {
283
+ return this.storageKey;
284
+ }
285
+
286
+ private headers(): HeadersInit {
287
+ return {
288
+ Authorization: `Bearer ${this.token}`,
289
+ "Content-Type": "application/json",
290
+ };
291
+ }
292
+
293
+ protected async getRaw(key: string): Promise<string | undefined> {
294
+ const response = await fetch(`${this.baseUrl}/get/${encodeURIComponent(key)}`, {
295
+ method: "POST",
296
+ headers: this.headers(),
297
+ });
298
+ if (!response.ok) {
299
+ return undefined;
300
+ }
301
+ const payload = (await response.json()) as { result?: string | null };
302
+ return payload.result ?? undefined;
303
+ }
304
+
305
+ protected async setRaw(key: string, value: string): Promise<void> {
306
+ await fetch(
307
+ `${this.baseUrl}/set/${encodeURIComponent(key)}/${encodeURIComponent(value)}`,
308
+ { method: "POST", headers: this.headers() },
309
+ );
310
+ }
311
+
312
+ protected async setRawWithTtl(key: string, value: string, ttl: number): Promise<void> {
313
+ await fetch(
314
+ `${this.baseUrl}/setex/${encodeURIComponent(key)}/${Math.max(1, ttl)}/${encodeURIComponent(
315
+ value,
316
+ )}`,
317
+ { method: "POST", headers: this.headers() },
318
+ );
319
+ }
320
+ }
321
+
322
+ class RedisMemoryStore extends KeyValueMainMemoryStoreBase {
323
+ private readonly storageKey: string;
324
+ private readonly clientPromise: Promise<
325
+ | {
326
+ get: (key: string) => Promise<string | null>;
327
+ set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
328
+ }
329
+ | undefined
330
+ >;
331
+
332
+ constructor(options: {
333
+ url: string;
334
+ storageKey: string;
335
+ ttl?: number;
336
+ }) {
337
+ super(options.ttl);
338
+ this.storageKey = options.storageKey;
339
+ this.clientPromise = (async () => {
340
+ try {
341
+ const redisModule = (await import("redis")) as unknown as {
342
+ createClient: (args: { url: string }) => {
343
+ connect: () => Promise<unknown>;
344
+ get: (key: string) => Promise<string | null>;
345
+ set: (key: string, value: string, options?: { EX?: number }) => Promise<unknown>;
346
+ };
347
+ };
348
+ const client = redisModule.createClient({ url: options.url });
349
+ await client.connect();
350
+ return client;
351
+ } catch {
352
+ return undefined;
353
+ }
354
+ })();
355
+ }
356
+
357
+ protected key(): string {
358
+ return this.storageKey;
359
+ }
360
+
361
+ protected async getRaw(key: string): Promise<string | undefined> {
362
+ const client = await this.clientPromise;
363
+ if (!client) {
364
+ throw new Error("Redis unavailable");
365
+ }
366
+ const value = await client.get(key);
367
+ return value ?? undefined;
368
+ }
369
+
370
+ protected async setRaw(key: string, value: string): Promise<void> {
371
+ const client = await this.clientPromise;
372
+ if (!client) {
373
+ throw new Error("Redis unavailable");
374
+ }
375
+ await client.set(key, value);
376
+ }
377
+
378
+ protected async setRawWithTtl(key: string, value: string, ttl: number): Promise<void> {
379
+ const client = await this.clientPromise;
380
+ if (!client) {
381
+ throw new Error("Redis unavailable");
382
+ }
383
+ await client.set(key, value, { EX: Math.max(1, ttl) });
384
+ }
385
+ }
386
+
387
+ class DynamoDbMemoryStore extends KeyValueMainMemoryStoreBase {
388
+ private readonly storageKey: string;
389
+ private readonly table: string;
390
+ private readonly clientPromise: Promise<
391
+ | {
392
+ send: (command: unknown) => Promise<unknown>;
393
+ GetItemCommand: new (input: unknown) => unknown;
394
+ PutItemCommand: new (input: unknown) => unknown;
395
+ }
396
+ | undefined
397
+ >;
398
+
399
+ constructor(options: {
400
+ table: string;
401
+ storageKey: string;
402
+ region?: string;
403
+ ttl?: number;
404
+ }) {
405
+ super(options.ttl);
406
+ this.storageKey = options.storageKey;
407
+ this.table = options.table;
408
+ this.clientPromise = (async () => {
409
+ try {
410
+ const module = (await import("@aws-sdk/client-dynamodb")) as {
411
+ DynamoDBClient: new (input: { region?: string }) => {
412
+ send: (command: unknown) => Promise<unknown>;
413
+ };
414
+ GetItemCommand: new (input: unknown) => unknown;
415
+ PutItemCommand: new (input: unknown) => unknown;
416
+ };
417
+ const client = new module.DynamoDBClient({ region: options.region });
418
+ return {
419
+ send: client.send.bind(client),
420
+ GetItemCommand: module.GetItemCommand,
421
+ PutItemCommand: module.PutItemCommand,
422
+ };
423
+ } catch {
424
+ return undefined;
425
+ }
426
+ })();
427
+ }
428
+
429
+ protected key(): string {
430
+ return this.storageKey;
431
+ }
432
+
433
+ protected async getRaw(key: string): Promise<string | undefined> {
434
+ const client = await this.clientPromise;
435
+ if (!client) {
436
+ throw new Error("DynamoDB unavailable");
437
+ }
438
+ const result = (await client.send(
439
+ new client.GetItemCommand({
440
+ TableName: this.table,
441
+ Key: { runId: { S: key } },
442
+ }),
443
+ )) as {
444
+ Item?: {
445
+ value?: { S?: string };
446
+ };
447
+ };
448
+ return result.Item?.value?.S;
449
+ }
450
+
451
+ protected async setRaw(key: string, value: string): Promise<void> {
452
+ const client = await this.clientPromise;
453
+ if (!client) {
454
+ throw new Error("DynamoDB unavailable");
455
+ }
456
+ await client.send(
457
+ new client.PutItemCommand({
458
+ TableName: this.table,
459
+ Item: {
460
+ runId: { S: key },
461
+ value: { S: value },
462
+ },
463
+ }),
464
+ );
465
+ }
466
+
467
+ protected async setRawWithTtl(key: string, value: string, ttl: number): Promise<void> {
468
+ const client = await this.clientPromise;
469
+ if (!client) {
470
+ throw new Error("DynamoDB unavailable");
471
+ }
472
+ const ttlEpoch = Math.floor(Date.now() / 1000) + Math.max(1, ttl);
473
+ await client.send(
474
+ new client.PutItemCommand({
475
+ TableName: this.table,
476
+ Item: {
477
+ runId: { S: key },
478
+ value: { S: value },
479
+ ttl: { N: String(ttlEpoch) },
480
+ },
481
+ }),
482
+ );
483
+ }
484
+ }
485
+
486
+ export const createMemoryStore = (
487
+ agentId: string,
488
+ config?: MemoryConfig,
489
+ options?: { workingDir?: string },
490
+ ): MemoryStore => {
491
+ const provider = config?.provider ?? "local";
492
+ const ttl = config?.ttl;
493
+ const storageKey = `poncho:memory:${agentId}:main`;
494
+ const workingDir = options?.workingDir ?? process.cwd();
495
+ if (provider === "local") {
496
+ return new FileMainMemoryStore(workingDir, ttl);
497
+ }
498
+ if (provider === "memory") {
499
+ return new InMemoryMemoryStore(ttl);
500
+ }
501
+ if (provider === "upstash") {
502
+ const url =
503
+ config?.url ??
504
+ process.env.UPSTASH_REDIS_REST_URL ??
505
+ process.env.KV_REST_API_URL ??
506
+ "";
507
+ const token =
508
+ config?.token ??
509
+ process.env.UPSTASH_REDIS_REST_TOKEN ??
510
+ process.env.KV_REST_API_TOKEN ??
511
+ "";
512
+ if (url && token) {
513
+ return new UpstashMemoryStore({
514
+ baseUrl: url,
515
+ token,
516
+ storageKey,
517
+ ttl,
518
+ });
519
+ }
520
+ return new InMemoryMemoryStore(ttl);
521
+ }
522
+ if (provider === "redis") {
523
+ const url = config?.url ?? process.env.REDIS_URL ?? "";
524
+ if (url) {
525
+ return new RedisMemoryStore({
526
+ url,
527
+ storageKey,
528
+ ttl,
529
+ });
530
+ }
531
+ return new InMemoryMemoryStore(ttl);
532
+ }
533
+ if (provider === "dynamodb") {
534
+ const table = config?.table ?? process.env.PONCHO_DYNAMODB_TABLE ?? "";
535
+ if (table) {
536
+ return new DynamoDbMemoryStore({
537
+ table,
538
+ storageKey,
539
+ region: config?.region,
540
+ ttl,
541
+ });
542
+ }
543
+ return new InMemoryMemoryStore(ttl);
544
+ }
545
+ return new InMemoryMemoryStore(ttl);
546
+ };
547
+
548
+ const asRecallCorpus = (raw: unknown): RecallItem[] => {
549
+ if (!Array.isArray(raw)) {
550
+ return [];
551
+ }
552
+ return raw
553
+ .map((item) => {
554
+ if (!item || typeof item !== "object") {
555
+ return undefined;
556
+ }
557
+ const record = item as Record<string, unknown>;
558
+ const conversationId =
559
+ typeof record.conversationId === "string" ? record.conversationId : "";
560
+ const title = typeof record.title === "string" ? record.title : "Conversation";
561
+ const updatedAt = typeof record.updatedAt === "number" ? record.updatedAt : 0;
562
+ const content = typeof record.content === "string" ? record.content : "";
563
+ if (!conversationId || !content) {
564
+ return undefined;
565
+ }
566
+ return { conversationId, title, updatedAt, content } satisfies RecallItem;
567
+ })
568
+ .filter((item): item is RecallItem => Boolean(item));
569
+ };
570
+
571
+ const buildRecallSnippet = (content: string, query: string, maxChars = 360): string => {
572
+ const normalized = query.trim().toLowerCase();
573
+ const index = content.toLowerCase().indexOf(normalized);
574
+ if (index === -1) {
575
+ return content.slice(0, maxChars);
576
+ }
577
+ const start = Math.max(0, index - 120);
578
+ const end = Math.min(content.length, index + normalized.length + 180);
579
+ return content.slice(start, end);
580
+ };
581
+
582
+ export const createMemoryTools = (
583
+ store: MemoryStore,
584
+ options?: { maxRecallConversations?: number },
585
+ ): ToolDefinition[] => {
586
+ const maxRecallConversations = Math.max(1, options?.maxRecallConversations ?? 20);
587
+ return [
588
+ defineTool({
589
+ name: "memory_main_get",
590
+ description: "Get the current persistent main memory document.",
591
+ inputSchema: {
592
+ type: "object",
593
+ properties: {},
594
+ additionalProperties: false,
595
+ },
596
+ handler: async () => {
597
+ const memory = await store.getMainMemory();
598
+ return { memory };
599
+ },
600
+ }),
601
+ defineTool({
602
+ name: "memory_main_update",
603
+ description:
604
+ "Update persistent main memory when new stable preferences, long-term goals, or durable facts appear. Proactively evaluate every turn whether memory should be updated, and avoid storing ephemeral details.",
605
+ inputSchema: {
606
+ type: "object",
607
+ properties: {
608
+ mode: {
609
+ type: "string",
610
+ enum: ["replace", "append"],
611
+ description: "replace overwrites memory; append adds content to the end",
612
+ },
613
+ content: {
614
+ type: "string",
615
+ description: "The memory content to write",
616
+ },
617
+ },
618
+ required: ["content"],
619
+ additionalProperties: false,
620
+ },
621
+ handler: async (input) => {
622
+ const content = typeof input.content === "string" ? input.content.trim() : "";
623
+ if (!content) {
624
+ throw new Error("content is required");
625
+ }
626
+ const mode =
627
+ input.mode === "append" || input.mode === "replace"
628
+ ? input.mode
629
+ : "replace";
630
+ const memory = await store.updateMainMemory({ content, mode });
631
+ return { ok: true, memory };
632
+ },
633
+ }),
634
+ defineTool({
635
+ name: "conversation_recall",
636
+ description:
637
+ "Recall relevant snippets from previous conversations when prior context is likely important (for example: 'as we discussed', 'last time', or ambiguous references).",
638
+ inputSchema: {
639
+ type: "object",
640
+ properties: {
641
+ query: {
642
+ type: "string",
643
+ description: "Search query for past conversation recall",
644
+ },
645
+ limit: {
646
+ type: "number",
647
+ description: "Maximum snippets to return",
648
+ },
649
+ excludeConversationId: {
650
+ type: "string",
651
+ description: "Optional conversation id to exclude from recall",
652
+ },
653
+ },
654
+ required: ["query"],
655
+ additionalProperties: false,
656
+ },
657
+ handler: async (input, context) => {
658
+ const query = typeof input.query === "string" ? input.query.trim() : "";
659
+ if (!query) {
660
+ throw new Error("query is required");
661
+ }
662
+ const limit = Math.max(
663
+ 1,
664
+ Math.min(5, typeof input.limit === "number" ? input.limit : 3),
665
+ );
666
+ const excludeConversationId =
667
+ typeof input.excludeConversationId === "string"
668
+ ? input.excludeConversationId
669
+ : "";
670
+ const corpus = asRecallCorpus(context.parameters.__conversationRecallCorpus).slice(
671
+ 0,
672
+ maxRecallConversations,
673
+ );
674
+ const results = corpus
675
+ .filter((item) =>
676
+ excludeConversationId ? item.conversationId !== excludeConversationId : true,
677
+ )
678
+ .map((item) => ({
679
+ ...item,
680
+ score: scoreText(`${item.title}\n${item.content}`, query),
681
+ }))
682
+ .filter((item) => item.score > 0)
683
+ .sort((a, b) => {
684
+ if (b.score === a.score) {
685
+ return b.updatedAt - a.updatedAt;
686
+ }
687
+ return b.score - a.score;
688
+ })
689
+ .slice(0, limit)
690
+ .map((item) => ({
691
+ conversationId: item.conversationId,
692
+ title: item.title,
693
+ updatedAt: item.updatedAt,
694
+ snippet: buildRecallSnippet(item.content, query),
695
+ }));
696
+ return { results };
697
+ },
698
+ }),
699
+ ];
700
+ };