Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/services/api-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,16 @@ export async function handleListMemories(
}

const sortedTimeline: any[] = [];
const pairs = Array.from(linkedPairs.values())
const pairValues = Array.from(linkedPairs.values());
const pairs = pairValues
.filter((p) => p.memory && p.prompt)
.sort((a, b) => b.memory.createdAt - a.memory.createdAt);
// A memory or prompt whose counterpart is missing (linked prompt deleted,
// or prompt capture off) must still show up in the timeline, unlinked.
for (const pair of pairValues) {
if (pair.memory && !pair.prompt) standalone.push(pair.memory);
else if (pair.prompt && !pair.memory) standalone.push(pair.prompt);
}
for (const pair of pairs) {
sortedTimeline.push(pair.memory);
sortedTimeline.push(pair.prompt);
Expand Down
109 changes: 109 additions & 0 deletions tests/memory-timeline-orphan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { cleanupTursoTestDirectory } from "./turso-test-utils.js";

describe("memory timeline listing", () => {
let baseDir: string;

afterEach(async () => {
await cleanupTursoTestDirectory(baseDir);
});

it("keeps memories whose linked prompt is missing from the timeline", async () => {
baseDir = mkdtempSync(join(tmpdir(), "timeline-orphan-"));

const { CONFIG } = await import("../src/config.js");
CONFIG.storagePath = baseDir;

const { ensureTursoReady } = await import("../src/services/turso/ready.js");
await ensureTursoReady();

const { tursoConnectionManager } = await import("../src/services/turso/connection-manager.js");
const { tursoShardManager } = await import("../src/services/turso/shard-manager.js");
const { tursoVectorSearch } = await import("../src/services/turso/vector-search.js");
const { handleListMemories } = await import("../src/services/api-handlers.js");

const dims = CONFIG.embeddingDimensions;
const vector = new Float32Array(dims);
vector[0] = 1;

const scopeHash = "a1b2c3d4e5f67890";
const containerTag = `opencode_project_${scopeHash}`;

const shard = await tursoShardManager.createShard("project", scopeHash, 0);
const db = await tursoConnectionManager.getConnection(shard.dbPath);

await tursoVectorSearch.insertVector(db, {
id: "mem_orphan_link",
content: "Memory whose linked prompt was deleted",
vector,
containerTag,
metadata: JSON.stringify({ promptId: "prompt_never_existed" }),
createdAt: Date.now(),
updatedAt: Date.now(),
});

const result = await handleListMemories(undefined, 1, 20, true);

expect(result.success).toBe(true);
const data = result.data as { items: Array<{ id: string }> };
expect(data.items.some((m) => m.id === "mem_orphan_link")).toBe(true);
});

it("renders linked memory-prompt pairs together in the timeline", async () => {
baseDir = mkdtempSync(join(tmpdir(), "timeline-pair-"));

const { CONFIG } = await import("../src/config.js");
CONFIG.storagePath = baseDir;

const { ensureTursoReady } = await import("../src/services/turso/ready.js");
await ensureTursoReady();

const { tursoConnectionManager } = await import("../src/services/turso/connection-manager.js");
const { tursoShardManager } = await import("../src/services/turso/shard-manager.js");
const { tursoVectorSearch } = await import("../src/services/turso/vector-search.js");
const { handleListMemories } = await import("../src/services/api-handlers.js");
const { userPromptManager } =
await import("../src/services/user-prompt/user-prompt-manager.js");

const dims = CONFIG.embeddingDimensions;
const vector = new Float32Array(dims);
vector[0] = 1;

const scopeHash = "a1b2c3d4e5f67890";
const containerTag = `opencode_project_${scopeHash}`;
const now = Date.now();

const shard = await tursoShardManager.createShard("project", scopeHash, 0);
const db = await tursoConnectionManager.getConnection(shard.dbPath);

const promptId = await userPromptManager.savePrompt(
"session-pair",
"msg-pair",
"C:/proj",
"captured prompt"
);
await userPromptManager.markAsCaptured(promptId);

await tursoVectorSearch.insertVector(db, {
id: "mem_linked",
content: "Linked memory",
vector,
containerTag,
metadata: JSON.stringify({ promptId }),
createdAt: now,
updatedAt: now,
});
await userPromptManager.linkMemoryToPrompt(promptId, "mem_linked");

const result = await handleListMemories(undefined, 1, 20, true);
expect(result.success).toBe(true);
const items = (result.data as { items: Array<{ id: string; type: string }> }).items;
const ids = items.map((i) => i.id);
expect(ids).toContain("mem_linked");
expect(ids).toContain(promptId);
expect(ids.indexOf("mem_linked")).toBeLessThan(ids.indexOf(promptId));
});
});