Skip to content

Commit e162a47

Browse files
feat: 记忆系统的实现
1 parent 7ea91a1 commit e162a47

10 files changed

Lines changed: 1272 additions & 7 deletions

File tree

scripts/dev.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [
1515

1616
// Bun --feature flags: enable feature() gates at runtime.
1717
// Default features enabled in dev mode.
18-
const DEFAULT_FEATURES = ["BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE", "AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE"];
18+
const DEFAULT_FEATURES = ["BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE", "AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE", "EXTRACT_MEMORIES", "TEAMMEM"];
1919

2020
// Any env var matching FEATURE_<NAME>=1 will also enable that feature.
2121
// e.g. FEATURE_PROACTIVE=1 bun run dev
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* Tests for findRelevantMemories helper logic.
3+
* The main function (findRelevantMemories) depends on sideQuery (API call)
4+
* and scanMemoryFiles (filesystem), so we test the pure filtering/selection
5+
* logic that doesn't require mocking the API layer.
6+
*/
7+
import { describe, expect, test } from "bun:test"
8+
import type { MemoryHeader } from "../memoryScan"
9+
10+
describe("findRelevantMemories logic", () => {
11+
describe("alreadySurfaced filtering", () => {
12+
test("filters out already surfaced files", () => {
13+
const memories: MemoryHeader[] = [
14+
{
15+
filename: "a.md",
16+
filePath: "/mem/a.md",
17+
mtimeMs: 1000,
18+
description: "Memory A",
19+
type: "user",
20+
},
21+
{
22+
filename: "b.md",
23+
filePath: "/mem/b.md",
24+
mtimeMs: 2000,
25+
description: "Memory B",
26+
type: "project",
27+
},
28+
{
29+
filename: "c.md",
30+
filePath: "/mem/c.md",
31+
mtimeMs: 3000,
32+
description: "Memory C",
33+
type: "feedback",
34+
},
35+
]
36+
const alreadySurfaced = new Set(["/mem/a.md"])
37+
const filtered = memories.filter(m => !alreadySurfaced.has(m.filePath))
38+
expect(filtered).toHaveLength(2)
39+
expect(filtered.map(m => m.filename)).toEqual(["b.md", "c.md"])
40+
})
41+
42+
test("returns all when nothing is already surfaced", () => {
43+
const memories: MemoryHeader[] = [
44+
{
45+
filename: "a.md",
46+
filePath: "/mem/a.md",
47+
mtimeMs: 1000,
48+
description: "A",
49+
type: "user",
50+
},
51+
]
52+
const alreadySurfaced = new Set<string>()
53+
const filtered = memories.filter(m => !alreadySurfaced.has(m.filePath))
54+
expect(filtered).toHaveLength(1)
55+
})
56+
57+
test("returns empty when all are already surfaced", () => {
58+
const memories: MemoryHeader[] = [
59+
{
60+
filename: "a.md",
61+
filePath: "/mem/a.md",
62+
mtimeMs: 1000,
63+
description: "A",
64+
type: "user",
65+
},
66+
]
67+
const alreadySurfaced = new Set(["/mem/a.md"])
68+
const filtered = memories.filter(m => !alreadySurfaced.has(m.filePath))
69+
expect(filtered).toHaveLength(0)
70+
})
71+
})
72+
73+
describe("filename validation against memory list", () => {
74+
test("filters selected filenames against valid set", () => {
75+
const validFilenames = new Set(["a.md", "b.md", "c.md"])
76+
const selected = ["a.md", "x.md", "b.md", "y.md"]
77+
const filtered = selected.filter(f => validFilenames.has(f))
78+
expect(filtered).toEqual(["a.md", "b.md"])
79+
})
80+
81+
test("returns empty when no selected filenames are valid", () => {
82+
const validFilenames = new Set(["a.md", "b.md"])
83+
const selected = ["x.md", "y.md"]
84+
const filtered = selected.filter(f => validFilenames.has(f))
85+
expect(filtered).toEqual([])
86+
})
87+
})
88+
89+
describe("selected filename to header resolution", () => {
90+
test("resolves selected filenames to headers", () => {
91+
const memories: MemoryHeader[] = [
92+
{
93+
filename: "a.md",
94+
filePath: "/mem/a.md",
95+
mtimeMs: 1000,
96+
description: "A",
97+
type: "user",
98+
},
99+
{
100+
filename: "b.md",
101+
filePath: "/mem/b.md",
102+
mtimeMs: 2000,
103+
description: "B",
104+
type: "project",
105+
},
106+
]
107+
const byFilename = new Map(memories.map(m => [m.filename, m]))
108+
const selectedFilenames = ["b.md", "a.md"]
109+
const resolved = selectedFilenames
110+
.map(filename => byFilename.get(filename))
111+
.filter((m): m is MemoryHeader => m !== undefined)
112+
expect(resolved).toHaveLength(2)
113+
expect(resolved[0].filename).toBe("b.md")
114+
expect(resolved[1].filename).toBe("a.md")
115+
})
116+
117+
test("skips unresolved filenames", () => {
118+
const memories: MemoryHeader[] = [
119+
{
120+
filename: "a.md",
121+
filePath: "/mem/a.md",
122+
mtimeMs: 1000,
123+
description: "A",
124+
type: "user",
125+
},
126+
]
127+
const byFilename = new Map(memories.map(m => [m.filename, m]))
128+
const selectedFilenames = ["a.md", "nonexistent.md"]
129+
const resolved = selectedFilenames
130+
.map(filename => byFilename.get(filename))
131+
.filter((m): m is MemoryHeader => m !== undefined)
132+
expect(resolved).toHaveLength(1)
133+
expect(resolved[0].filename).toBe("a.md")
134+
})
135+
})
136+
137+
describe("RelevantMemory output mapping", () => {
138+
test("maps MemoryHeader to RelevantMemory", () => {
139+
const memories: MemoryHeader[] = [
140+
{
141+
filename: "test.md",
142+
filePath: "/mem/test.md",
143+
mtimeMs: 5000,
144+
description: "Test",
145+
type: "feedback",
146+
},
147+
]
148+
const result = memories.map(m => ({
149+
path: m.filePath,
150+
mtimeMs: m.mtimeMs,
151+
}))
152+
expect(result).toEqual([{ path: "/mem/test.md", mtimeMs: 5000 }])
153+
})
154+
})
155+
})
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
truncateEntrypointContent,
4+
MAX_ENTRYPOINT_LINES,
5+
MAX_ENTRYPOINT_BYTES,
6+
ENTRYPOINT_NAME,
7+
buildMemoryLines,
8+
} from "../memdir"
9+
10+
describe("truncateEntrypointContent", () => {
11+
test("returns content unchanged when within limits", () => {
12+
const content = "line1\nline2\nline3"
13+
const result = truncateEntrypointContent(content)
14+
expect(result.content).toBe(content)
15+
expect(result.wasLineTruncated).toBe(false)
16+
expect(result.wasByteTruncated).toBe(false)
17+
expect(result.lineCount).toBe(3)
18+
})
19+
20+
test("handles empty content", () => {
21+
const result = truncateEntrypointContent("")
22+
expect(result.content).toBe("")
23+
expect(result.wasLineTruncated).toBe(false)
24+
expect(result.lineCount).toBe(1) // empty string splits to [""]
25+
})
26+
27+
test("truncates by line count when exceeding MAX_ENTRYPOINT_LINES", () => {
28+
const lines = Array.from({ length: MAX_ENTRYPOINT_LINES + 50 }, (_, i) => `line ${i}`)
29+
const content = lines.join("\n")
30+
const result = truncateEntrypointContent(content)
31+
expect(result.wasLineTruncated).toBe(true)
32+
expect(result.wasByteTruncated).toBe(false)
33+
// Content should contain the warning
34+
expect(result.content).toContain("WARNING")
35+
expect(result.content).toContain(ENTRYPOINT_NAME)
36+
// First MAX_ENTRYPOINT_LINES lines should be preserved
37+
const resultLines = result.content.split("\n")
38+
expect(resultLines[0]).toBe("line 0")
39+
})
40+
41+
test("truncates by byte count when exceeding MAX_ENTRYPOINT_BYTES", () => {
42+
// Create content with few lines but huge size
43+
const hugeLine = "x".repeat(MAX_ENTRYPOINT_BYTES + 1000)
44+
const content = `header\n${hugeLine}`
45+
const result = truncateEntrypointContent(content)
46+
expect(result.wasByteTruncated).toBe(true)
47+
expect(result.content).toContain("WARNING")
48+
expect(result.content.length).toBeLessThan(content.length)
49+
})
50+
51+
test("truncates both when exceeding both limits", () => {
52+
const lines = Array.from(
53+
{ length: MAX_ENTRYPOINT_LINES + 10 },
54+
() => "x".repeat(200),
55+
)
56+
const content = lines.join("\n")
57+
const result = truncateEntrypointContent(content)
58+
expect(result.wasLineTruncated).toBe(true)
59+
expect(result.wasByteTruncated).toBe(true)
60+
})
61+
62+
test("trims whitespace from input", () => {
63+
const content = " line1\nline2\n "
64+
const result = truncateEntrypointContent(content)
65+
expect(result.content).toBe("line1\nline2")
66+
})
67+
})
68+
69+
describe("buildMemoryLines", () => {
70+
test("returns non-empty array", () => {
71+
const lines = buildMemoryLines("test memory", "/tmp/mem")
72+
expect(lines.length).toBeGreaterThan(0)
73+
})
74+
75+
test("starts with the display name as heading", () => {
76+
const lines = buildMemoryLines("my memory", "/tmp/mem")
77+
expect(lines[0]).toBe("# my memory")
78+
})
79+
80+
test("includes the memory directory path", () => {
81+
const lines = buildMemoryLines("test", "/custom/path/memory/")
82+
const joined = lines.join("\n")
83+
expect(joined).toContain("/custom/path/memory/")
84+
})
85+
86+
test("includes MEMORY.md as entrypoint name", () => {
87+
const lines = buildMemoryLines("test", "/tmp/mem")
88+
const joined = lines.join("\n")
89+
expect(joined).toContain("MEMORY.md")
90+
})
91+
92+
test("includes type taxonomy", () => {
93+
const lines = buildMemoryLines("test", "/tmp/mem")
94+
const joined = lines.join("\n")
95+
expect(joined).toContain("user")
96+
expect(joined).toContain("feedback")
97+
expect(joined).toContain("project")
98+
expect(joined).toContain("reference")
99+
})
100+
101+
test("includes How to save section with index when skipIndex=false", () => {
102+
const lines = buildMemoryLines("test", "/tmp/mem", undefined, false)
103+
const joined = lines.join("\n")
104+
expect(joined).toContain("How to save memories")
105+
expect(joined).toContain("Step 2")
106+
})
107+
108+
test("skips Step 2 index instructions when skipIndex=true", () => {
109+
const lines = buildMemoryLines("test", "/tmp/mem", undefined, true)
110+
const joined = lines.join("\n")
111+
expect(joined).toContain("How to save memories")
112+
expect(joined).not.toContain("Step 2")
113+
})
114+
115+
test("includes extra guidelines", () => {
116+
const guidelines = ["Custom rule 1", "Custom rule 2"]
117+
const lines = buildMemoryLines("test", "/tmp/mem", guidelines)
118+
const joined = lines.join("\n")
119+
expect(joined).toContain("Custom rule 1")
120+
expect(joined).toContain("Custom rule 2")
121+
})
122+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
memoryAgeDays,
4+
memoryAge,
5+
memoryFreshnessText,
6+
memoryFreshnessNote,
7+
} from "../memoryAge"
8+
9+
describe("memoryAgeDays", () => {
10+
test("returns 0 for current timestamp", () => {
11+
expect(memoryAgeDays(Date.now())).toBe(0)
12+
})
13+
14+
test("returns 0 for very recent timestamp (1 second ago)", () => {
15+
expect(memoryAgeDays(Date.now() - 1000)).toBe(0)
16+
})
17+
18+
test("returns 1 for yesterday (24 hours ago)", () => {
19+
expect(memoryAgeDays(Date.now() - 86_400_000)).toBe(1)
20+
})
21+
22+
test("returns 7 for a week ago", () => {
23+
expect(memoryAgeDays(Date.now() - 7 * 86_400_000)).toBe(7)
24+
})
25+
26+
test("returns 30 for a month ago", () => {
27+
expect(memoryAgeDays(Date.now() - 30 * 86_400_000)).toBe(30)
28+
})
29+
30+
test("clamps negative (future mtime) to 0", () => {
31+
expect(memoryAgeDays(Date.now() + 86_400_000)).toBe(0)
32+
})
33+
34+
test("floor rounds partial days", () => {
35+
// 1.5 days ago
36+
expect(memoryAgeDays(Date.now() - 1.5 * 86_400_000)).toBe(1)
37+
})
38+
})
39+
40+
describe("memoryAge", () => {
41+
test("returns 'today' for current timestamp", () => {
42+
expect(memoryAge(Date.now())).toBe("today")
43+
})
44+
45+
test("returns 'yesterday' for 1 day ago", () => {
46+
expect(memoryAge(Date.now() - 86_400_000)).toBe("yesterday")
47+
})
48+
49+
test("returns 'N days ago' for older", () => {
50+
expect(memoryAge(Date.now() - 2 * 86_400_000)).toBe("2 days ago")
51+
expect(memoryAge(Date.now() - 47 * 86_400_000)).toBe("47 days ago")
52+
})
53+
})
54+
55+
describe("memoryFreshnessText", () => {
56+
test("returns empty string for today", () => {
57+
expect(memoryFreshnessText(Date.now())).toBe("")
58+
})
59+
60+
test("returns empty string for yesterday", () => {
61+
expect(memoryFreshnessText(Date.now() - 86_400_000)).toBe("")
62+
})
63+
64+
test("returns staleness warning for 2+ days", () => {
65+
const text = memoryFreshnessText(Date.now() - 2 * 86_400_000)
66+
expect(text).toContain("2 days old")
67+
expect(text).toContain("point-in-time")
68+
expect(text).toContain("Verify against current code")
69+
})
70+
71+
test("includes age in days", () => {
72+
const text = memoryFreshnessText(Date.now() - 30 * 86_400_000)
73+
expect(text).toContain("30 days old")
74+
})
75+
})
76+
77+
describe("memoryFreshnessNote", () => {
78+
test("returns empty string for fresh memory", () => {
79+
expect(memoryFreshnessNote(Date.now())).toBe("")
80+
})
81+
82+
test("wraps stale warning in system-reminder tags", () => {
83+
const note = memoryFreshnessNote(Date.now() - 5 * 86_400_000)
84+
expect(note).toMatch(/^<system-reminder>/)
85+
expect(note).toMatch(/<\/system-reminder>\n$/)
86+
expect(note).toContain("5 days old")
87+
})
88+
})

0 commit comments

Comments
 (0)