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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The **Deploy to Cloudflare** button provisions it automatically.
- [Capture from Anywhere](../../wiki/Capture-from-Anywhere) — browser extension, bookmarklet, iOS Shortcuts, share sheet
- [Web UI](../../wiki/Web-UI) — dashboard and mobile interface
- [Obsidian Plugin](../../wiki/Obsidian-Plugin) — install, configure, sync modes
- [API Reference](../../wiki/API-Reference) — /capture, /append, /update, /list, /count, /tags, /stats, /chat, /mcp endpoints
- [API Reference](../../wiki/API-Reference) — /capture, /append, /update, /list, /recall, /forget, /count, /tags, /stats, /chat, /digest, /mcp endpoints

-----

Expand Down
481 changes: 317 additions & 164 deletions src/index.ts

Large diffs are not rendered by default.

51 changes: 48 additions & 3 deletions test/helpers/d1-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,39 @@ export class D1Mock {
return null;
},
async all() {
if (s.includes("recall_count FROM entries")) {
if (s === "SELECT id FROM entries WHERE tags LIKE ?") {
const pattern = String(args[0]);
const tag = pattern.replace(/%"/g, "").replace(/"%/g, "");
const results = db.entries
.filter((e: any) => (JSON.parse(e.tags ?? "[]") as string[]).includes(tag))
.map((e: any) => ({ id: e.id }));
return { results };
}
if (s.includes("SELECT id, recall_count, importance_score FROM entries")) {
const results = db.entries
.filter((e: any) => args.includes(e.id))
.map((e: any) => ({ id: e.id, recall_count: e.recall_count ?? 0 }));
.map((e: any) => ({ id: e.id, recall_count: e.recall_count ?? 0, importance_score: e.importance_score ?? 0 }));
return { results };
}
if (s.includes("FROM entries WHERE id IN") && s.includes("tags NOT LIKE")) {
// recallEntries D1 hydration — filter by IDs, exclude auto-pattern entries, apply after/before
const inMatch = s.match(/WHERE id IN \(([^)]*)\)/);
const idCount = inMatch ? inMatch[1].split(",").length : 0;
const ids = args.slice(0, idCount);
const rest = args.slice(idCount);
let argIdx = 0;
let rows = db.entries.filter((e: any) =>
ids.includes(e.id) && !(JSON.parse(e.tags ?? "[]") as string[]).includes("auto-pattern")
);
if (s.includes("created_at >= ?")) {
const after = Number(rest[argIdx++]);
rows = rows.filter((e: any) => e.created_at >= after);
}
if (s.includes("created_at <= ?")) {
const before = Number(rest[argIdx++]);
rows = rows.filter((e: any) => e.created_at <= before);
}
const results = rows.map((e: any) => ({ id: e.id, content: e.content, tags: e.tags, source: e.source, created_at: e.created_at }));
return { results };
}
if (s.includes("SELECT id, content FROM entries") && s.includes("WHERE tags LIKE") && s.includes("ORDER BY created_at DESC")) {
Expand Down Expand Up @@ -167,7 +196,23 @@ export class D1Mock {
}
if (s.includes("ORDER BY created_at DESC LIMIT")) {
const limit = Number(args[args.length - 1]);
const rows = [...db.entries].sort((a: any, b: any) => b.created_at - a.created_at);
const filterArgs = args.slice(0, -1);
let argIdx = 0;
let rows = [...db.entries];
if (s.includes("tags LIKE ?")) {
const pattern = String(filterArgs[argIdx++]);
const tag = pattern.replace(/%"/g, "").replace(/"%/g, "");
rows = rows.filter((e: any) => (JSON.parse(e.tags ?? "[]") as string[]).includes(tag));
}
if (s.includes("created_at >= ?")) {
const after = Number(filterArgs[argIdx++]);
rows = rows.filter((e: any) => e.created_at >= after);
}
if (s.includes("created_at <= ?")) {
const before = Number(filterArgs[argIdx++]);
rows = rows.filter((e: any) => e.created_at <= before);
}
rows.sort((a: any, b: any) => b.created_at - a.created_at);
return { results: rows.slice(0, limit) };
}
return { results: [] };
Expand Down
2 changes: 2 additions & 0 deletions test/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const PROTECTED_ROUTES: Array<[string, string, unknown?]> = [
["POST", "/append", { id: "abc", addition: "update" }],
["GET", "/list", undefined],
["GET", "/tags", undefined],
["GET", "/recall?query=test", undefined],
["POST", "/forget", { id: "abc" }],
["POST", "/chat", { query: "what?" }],
["POST", "/mcp", undefined],
];
Expand Down
111 changes: 111 additions & 0 deletions test/integration/forget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import worker from "../../src/index";
import { makeTestEnv, makeTestDb, makeVectorizeMock } from "../helpers/make-env";
import { req } from "../helpers/make-request";
import type { Env } from "../../src/index";
import { D1Mock } from "../helpers/d1-mock";

const ctx = { waitUntil: (_: Promise<any>) => {} } as any;

describe("POST /forget", () => {
let env: Env;
let db: D1Mock;

beforeEach(() => {
db = makeTestDb();
env = makeTestEnv(db);
});

it("returns 400 when body is invalid JSON", async () => {
const res = await worker.fetch(
new Request("http://localhost/forget", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer test-token" },
body: "{not json",
}),
env,
ctx
);
expect(res.status).toBe(400);
const data = await res.json() as any;
expect(data.ok).toBe(false);
});

it("returns 400 when id is missing", async () => {
const res = await worker.fetch(req("POST", "/forget", { body: {} }), env, ctx);
expect(res.status).toBe(400);
const data = await res.json() as any;
expect(data.ok).toBe(false);
expect(data.error).toBe("id is required");
});

it("returns 404 for non-existent id", async () => {
const res = await worker.fetch(req("POST", "/forget", { body: { id: "no-such-id" } }), env, ctx);
expect(res.status).toBe(404);
const data = await res.json() as any;
expect(data.ok).toBe(false);
});

it("deletes an existing entry and its vectors", async () => {
const deleteByIdsMock = vi.fn().mockResolvedValue({ mutationId: "m" });
env = makeTestEnv(db, {
VECTORIZE: makeVectorizeMock({ deleteByIds: deleteByIdsMock }),
});
db.entries.push({
id: "entry-1",
content: "Some content",
tags: "[]",
source: "api",
created_at: Date.now(),
vector_ids: '["entry-1","entry-1-update-111"]',
});

const res = await worker.fetch(req("POST", "/forget", { body: { id: "entry-1" } }), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.ok).toBe(true);
expect(data.id).toBe("entry-1");
expect(data.deletedVectors).toBe(2);

expect(db.entries.find((e: any) => e.id === "entry-1")).toBeUndefined();
expect(deleteByIdsMock).toHaveBeenCalledWith(["entry-1", "entry-1-update-111"]);
});

it("trims whitespace from id before lookup", async () => {
db.entries.push({
id: "entry-1",
content: "Some content",
tags: "[]",
source: "api",
created_at: Date.now(),
vector_ids: "[]",
});

const res = await worker.fetch(req("POST", "/forget", { body: { id: " entry-1 " } }), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.id).toBe("entry-1");
});

it("is non-fatal when Vectorize delete fails", async () => {
env = makeTestEnv(db, {
VECTORIZE: makeVectorizeMock({
deleteByIds: vi.fn().mockRejectedValue(new Error("Vectorize down")),
}),
});
db.entries.push({
id: "entry-1",
content: "Some content",
tags: "[]",
source: "api",
created_at: Date.now(),
vector_ids: '["entry-1"]',
});

const res = await worker.fetch(req("POST", "/forget", { body: { id: "entry-1" } }), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.ok).toBe(true);
expect(db.entries.find((e: any) => e.id === "entry-1")).toBeUndefined();
});
});
56 changes: 56 additions & 0 deletions test/integration/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,60 @@ describe("GET /list", () => {
const data = await res.json();
expect(Array.isArray(data)).toBe(true);
});

// ── Filter parity with list_recent (?tag, ?after, ?before) ──────────────────

it("filters by ?tag=", async () => {
db.entries.push(
{ id: "work-1", content: "Work note", tags: '["work"]', source: "api", created_at: 1000, vector_ids: "[]" },
{ id: "idea-1", content: "Idea note", tags: '["idea"]', source: "api", created_at: 2000, vector_ids: "[]" },
);

const res = await worker.fetch(req("GET", "/list?tag=work"), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any[];
expect(data).toHaveLength(1);
expect(data[0].id).toBe("work-1");
});

it("filters by ?after=", async () => {
db.entries.push(
{ id: "old", content: "Old", tags: "[]", source: "api", created_at: 1000, vector_ids: "[]" },
{ id: "new", content: "New", tags: "[]", source: "api", created_at: 2000, vector_ids: "[]" },
);

const res = await worker.fetch(req("GET", "/list?after=1500"), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any[];
expect(data).toHaveLength(1);
expect(data[0].id).toBe("new");
});

it("filters by ?before=", async () => {
db.entries.push(
{ id: "old", content: "Old", tags: "[]", source: "api", created_at: 1000, vector_ids: "[]" },
{ id: "new", content: "New", tags: "[]", source: "api", created_at: 2000, vector_ids: "[]" },
);

const res = await worker.fetch(req("GET", "/list?before=1500"), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any[];
expect(data).toHaveLength(1);
expect(data[0].id).toBe("old");
});

it("combines ?tag=, ?after= and ?before=", async () => {
db.entries.push(
{ id: "work-old", content: "Work old", tags: '["work"]', source: "api", created_at: 1000, vector_ids: "[]" },
{ id: "work-mid", content: "Work mid", tags: '["work"]', source: "api", created_at: 2000, vector_ids: "[]" },
{ id: "work-new", content: "Work new", tags: '["work"]', source: "api", created_at: 3000, vector_ids: "[]" },
{ id: "idea-mid", content: "Idea mid", tags: '["idea"]', source: "api", created_at: 2000, vector_ids: "[]" },
);

const res = await worker.fetch(req("GET", "/list?tag=work&after=1500&before=2500"), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any[];
expect(data).toHaveLength(1);
expect(data[0].id).toBe("work-mid");
});
});
129 changes: 129 additions & 0 deletions test/integration/recall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import worker from "../../src/index";
import { makeTestEnv, makeTestDb, makeVectorizeMock } from "../helpers/make-env";
import { req } from "../helpers/make-request";
import type { Env } from "../../src/index";
import { D1Mock } from "../helpers/d1-mock";

const ctx = { waitUntil: (_: Promise<any>) => {} } as any;

function makeMatch(id: string, score: number, overrides: Record<string, any> = {}) {
return {
id,
score,
metadata: { parentId: id, isUpdate: false, ...overrides },
};
}

describe("GET /recall", () => {
let env: Env;
let db: D1Mock;

beforeEach(() => {
db = makeTestDb();
env = makeTestEnv(db);
});

it("returns 400 when query is missing", async () => {
const res = await worker.fetch(req("GET", "/recall"), env, ctx);
expect(res.status).toBe(400);
const data = await res.json() as any;
expect(data.ok).toBe(false);
expect(data.error).toBe("query is required");
});

it("returns an empty result set with a message when nothing matches", async () => {
env = makeTestEnv(db, {
VECTORIZE: makeVectorizeMock({ query: vi.fn().mockResolvedValue({ matches: [] }) }),
});

const res = await worker.fetch(req("GET", "/recall?query=anything"), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.ok).toBe(true);
expect(data.results).toEqual([]);
expect(data.message).toBe("Nothing found matching that query.");
});

it("returns ranked matches hydrated from D1", async () => {
db.entries.push(
{ id: "entry-1", content: "First memory", tags: '["work"]', source: "api", created_at: 1000, vector_ids: "[]", recall_count: 0, importance_score: 0 },
{ id: "entry-2", content: "Second memory", tags: '["idea"]', source: "api", created_at: 2000, vector_ids: "[]", recall_count: 0, importance_score: 0 },
);
env = makeTestEnv(db, {
VECTORIZE: makeVectorizeMock({
query: vi.fn().mockResolvedValue({
matches: [makeMatch("entry-1", 0.9), makeMatch("entry-2", 0.8)],
}),
}),
});

const res = await worker.fetch(req("GET", "/recall?query=memory"), env, ctx);
expect(res.status).toBe(200);
const data = await res.json() as any;
expect(data.ok).toBe(true);
expect(data.results).toHaveLength(2);
expect(data.results[0]).toMatchObject({ id: "entry-1", content: "First memory", tags: ["work"], source: "api" });
expect(data.results[0].score).toBeCloseTo(90, 0);
expect(data.results[1]).toMatchObject({ id: "entry-2", content: "Second memory" });
expect(typeof data.insight === "string" || data.insight === null).toBe(true);
});

it("dedupes matches that share the same parentId", async () => {
db.entries.push(
{ id: "entry-1", content: "Chunked memory", tags: "[]", source: "api", created_at: 1000, vector_ids: "[]", recall_count: 0, importance_score: 0 },
);
env = makeTestEnv(db, {
VECTORIZE: makeVectorizeMock({
query: vi.fn().mockResolvedValue({
matches: [makeMatch("entry-1", 0.9), makeMatch("entry-1-update-1", 0.85, { parentId: "entry-1", isUpdate: true })],
}),
}),
});

const res = await worker.fetch(req("GET", "/recall?query=memory"), env, ctx);
const data = await res.json() as any;
expect(data.results).toHaveLength(1);
expect(data.results[0].id).toBe("entry-1");
});

it("filters out matches whose parent entry doesn't carry the requested tag", async () => {
db.entries.push(
{ id: "entry-1", content: "Work memory", tags: '["work"]', source: "api", created_at: 1000, vector_ids: "[]", recall_count: 0, importance_score: 0 },
{ id: "entry-2", content: "Idea memory", tags: '["idea"]', source: "api", created_at: 2000, vector_ids: "[]", recall_count: 0, importance_score: 0 },
);
env = makeTestEnv(db, {
VECTORIZE: makeVectorizeMock({
query: vi.fn().mockResolvedValue({
matches: [makeMatch("entry-1", 0.9), makeMatch("entry-2", 0.85)],
}),
}),
});

const res = await worker.fetch(req("GET", "/recall?query=memory&tag=work"), env, ctx);
const data = await res.json() as any;
expect(data.results).toHaveLength(1);
expect(data.results[0].id).toBe("entry-1");
});

it("returns empty results immediately when the tag has no matching entries", async () => {
const queryMock = vi.fn().mockResolvedValue({ matches: [makeMatch("entry-1", 0.9)] });
env = makeTestEnv(db, { VECTORIZE: makeVectorizeMock({ query: queryMock }) });

const res = await worker.fetch(req("GET", "/recall?query=memory&tag=nonexistent"), env, ctx);
const data = await res.json() as any;
expect(data.ok).toBe(true);
expect(data.results).toEqual([]);
// Short-circuits before hitting Vectorize since the tag resolves to no IDs in D1
expect(queryMock).not.toHaveBeenCalled();
});

it("clamps ?topK= to the 1-20 range", async () => {
const queryMock = vi.fn().mockResolvedValue({ matches: [] });
env = makeTestEnv(db, { VECTORIZE: makeVectorizeMock({ query: queryMock }) });

await worker.fetch(req("GET", "/recall?query=memory&topK=999"), env, ctx);
const [, opts] = queryMock.mock.calls[0];
expect(opts.topK).toBeLessThanOrEqual(50);
});
});
Loading