@ghilteras/opencode-agent-memory 0.5.2 → 1.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/tools.test.ts CHANGED
@@ -1,253 +1,115 @@
1
1
  import { describe, expect, test } from "bun:test";
2
-
3
2
  import { JournalRead, JournalSearch, JournalWrite } from "./tools";
4
3
  import type { JournalContext } from "./tools";
5
4
  import type { JournalEntry, JournalStore } from "./journal";
6
5
 
7
- // Minimal mock store — description tests use the default stubs, while search
8
- // output tests replace search with deterministic fixtures.
9
- const mockStore: JournalStore = {
10
- write: async () => {
11
- throw new Error("not implemented");
12
- },
13
- read: async () => {
14
- throw new Error("not implemented");
15
- },
16
- search: async () => {
17
- throw new Error("not implemented");
18
- },
19
- };
20
-
21
- const mockCtx: JournalContext = {
22
- directory: "/tmp/test",
23
- model: "test-model",
24
- provider: "test-provider",
25
- };
26
-
27
- // ─── Tool description tests ───────────────────────────────────────────────
28
- // These assert semantic properties of the description strings: that guidance
29
- // content is present, that stale/hallucinated text is absent, and that structural
30
- // invariants hold. They use substring/regex assertions, so intra-sentence
31
- // rewording is tolerated; only content-level regressions trip them.
32
-
33
- const WRITE_DESC_NO_TAGS = JournalWrite(mockStore, mockCtx).description;
34
- const WRITE_DESC_WITH_TAGS = JournalWrite(mockStore, mockCtx, [
35
- { name: "perf", description: "Performance optimization" },
36
- { name: "debug", description: "Debugging sessions" },
37
- ]).description;
38
- const READ_DESC = JournalRead(mockStore).description;
39
- const SEARCH_TOOL = JournalSearch(mockStore);
40
- const SEARCH_DESC = SEARCH_TOOL.description;
41
-
42
- function makeEntry(tags: string[]): JournalEntry {
43
- return {
44
- id: "20260101-000000-000",
45
- title: "Retrieved entry",
46
- project: "/tmp/test",
47
- model: "test-model",
48
- provider: "test-provider",
49
- agent: "test-agent",
50
- sessionId: "test-session",
51
- created: new Date("2026-01-01T00:00:00.000Z"),
52
- tags,
53
- body: "Entry body",
54
- filePath: "/tmp/test-entry.md",
55
- };
6
+ const context: JournalContext = { directory: "/tmp/project", model: "m", provider: "p" };
7
+ function entry(): JournalEntry {
8
+ return { id: "20260101-000000-000", title: "Title", project: "/tmp", model: "m", provider: "p", agent: "a", sessionId: "s", created: new Date("2026-01-01T00:00:00.000Z"), tags: ["tag"], body: "Body", filePath: "/tmp/entry.md" };
56
9
  }
10
+ const baseStore: JournalStore = {
11
+ write: async () => entry(),
12
+ read: async () => entry(),
13
+ search: async () => ({ entries: [entry()], total: 1, allTags: ["tag"] }),
14
+ };
57
15
 
58
- async function executeSearch(result: Awaited<ReturnType<JournalStore["search"]>>): Promise<string> {
59
- const store: JournalStore = {
60
- ...mockStore,
61
- search: async () => result,
62
- };
63
- const output = await JournalSearch(store).execute!({}, {} as never);
64
- return String(output);
16
+ function valid(schema: any, value: any): boolean {
17
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
18
+ if (schema.additionalProperties === false && Object.keys(value).some((key) => !(key in schema.properties))) return false;
19
+ if (schema.required?.some((key: string) => !(key in value))) return false;
20
+ for (const [key, field] of Object.entries<any>(schema.properties)) {
21
+ if (!(key in value)) continue;
22
+ const actual = value[key];
23
+ if (field.type === "string" && typeof actual !== "string") return false;
24
+ if (field.type === "integer" && (!Number.isInteger(actual) || actual < (field.minimum ?? -Infinity))) return false;
25
+ }
26
+ return true;
65
27
  }
66
28
 
67
- describe("journal_write description", () => {
68
- test("mentions append-only semantics", () => {
69
- expect(WRITE_DESC_NO_TAGS).toContain("append-only");
70
- });
71
-
72
- test("explains that entries cannot be edited", () => {
73
- expect(WRITE_DESC_NO_TAGS).toMatch(/never edit|cannot be (edited|modified)/i);
74
- });
75
-
76
- test("documents tag format", () => {
77
- expect(WRITE_DESC_NO_TAGS).toMatch(/tags/i);
78
- expect(WRITE_DESC_NO_TAGS).toMatch(/comma.separated/i);
79
- });
80
-
81
- test("contains no dynamic or timestamped content", () => {
82
- // A static description must not embed a date or ISO timestamp
83
- expect(WRITE_DESC_NO_TAGS).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
84
- });
85
-
86
- test("mentions the journal is for recording insights", () => {
87
- expect(WRITE_DESC_NO_TAGS).toMatch(/insight|discovery|decision|observation/i);
88
- });
89
-
90
- test("does not include suggested tags when none provided", () => {
91
- expect(WRITE_DESC_NO_TAGS).not.toMatch(/suggested tags/i);
92
- });
93
-
94
- test("includes suggested tags when provided", () => {
95
- expect(WRITE_DESC_WITH_TAGS).toMatch(/suggested tags/i);
96
- expect(WRITE_DESC_WITH_TAGS).toContain("perf");
97
- expect(WRITE_DESC_WITH_TAGS).toContain("debug");
98
- });
99
-
100
- test("suggested tags are tag names only, not descriptions", () => {
101
- expect(WRITE_DESC_WITH_TAGS).toContain("perf");
102
- expect(WRITE_DESC_WITH_TAGS).toContain("debug");
103
- // Tag descriptions should NOT appear in the tool description
104
- expect(WRITE_DESC_WITH_TAGS).not.toContain("Performance optimization");
105
- expect(WRITE_DESC_WITH_TAGS).not.toContain("Debugging sessions");
106
- });
107
- });
108
-
109
- describe("journal_read description", () => {
110
- test("mentions reading a specific entry by ID", () => {
111
- expect(READ_DESC).toMatch(/by.*ID/i);
112
- });
113
-
114
- test("does not contain write-only guidance", () => {
115
- // Write-scoping guidance (append-only, cannot be edited) belongs only in
116
- // journal_write's description. An agent with read-but-not-write would
117
- // see this guidance without the ability to act on it.
118
- expect(READ_DESC).not.toMatch(/append.only|cannot be (edited|modified)/i);
119
- });
120
-
121
- test("contains no dynamic or timestamped content", () => {
122
- expect(READ_DESC).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
123
- });
124
- });
125
-
126
- describe("journal_search description", () => {
127
- test("mentions searching semantically", () => {
128
- expect(SEARCH_DESC).toMatch(/semantic|similarity|meaning/i);
129
- });
130
-
131
- test("mentions that filters can be combined", () => {
132
- expect(SEARCH_DESC).toMatch(/filter/i);
133
- });
134
-
135
- test("documents the project filter argument", () => {
136
- // The project filter is documented via the tool's args schema, not the
137
- // description prose. Assert the arg exists so a schema regression is caught.
138
- expect(SEARCH_TOOL.args).toBeDefined();
139
- expect(SEARCH_TOOL.args!.project).toBeDefined();
140
- });
141
-
142
- test("mentions pagination (offset)", () => {
143
- expect(SEARCH_DESC).toMatch(/offset|paginate/i);
144
- });
145
-
146
- test("mentions the journal is global across projects", () => {
147
- expect(SEARCH_DESC).toMatch(/global.*project|across.*project/i);
148
- });
149
-
150
- test("advises searching before complex tasks", () => {
151
- expect(SEARCH_DESC).toMatch(/complex task|before start/i);
152
- });
153
-
154
- test("contains no dynamic or timestamped content", () => {
155
- expect(SEARCH_DESC).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
156
- });
157
- });
158
-
159
- describe("journal_search tag inventory output", () => {
160
- const manyTags = Array.from({ length: 21 }, (_, index) => `tag-${index + 1}`);
161
- const exactlyTwentyTags = Array.from({ length: 20 }, (_, index) => `tag-${index + 1}`);
162
-
163
- test("bounds large tag inventories while reporting their count", async () => {
164
- const output = await executeSearch({
165
- entries: [makeEntry(["entry-tag"])],
166
- total: 1,
167
- allTags: manyTags,
168
- });
169
-
170
- expect(output.split("\n")[1]).toBe("Tags in use: 21 (filter with tags=...)");
171
- expect(output).not.toContain("tag-1");
172
- expect(output).not.toContain("tag-21");
173
- });
174
-
175
- test("lists small tag inventories", async () => {
176
- const output = await executeSearch({
177
- entries: [makeEntry(["entry-tag"])],
178
- total: 1,
179
- allTags: ["perf", "debug"],
180
- });
181
-
182
- expect(output).toContain("Tags in use: perf, debug");
183
- });
184
-
185
- test("lists exactly 20 tags at the boundary", async () => {
186
- const output = await executeSearch({
187
- entries: [makeEntry(["entry-tag"])],
188
- total: 1,
189
- allTags: exactlyTwentyTags,
190
- });
191
-
192
- expect(output.split("\n")[1]).toBe(`Tags in use: ${exactlyTwentyTags.join(", ")}`);
193
- });
194
-
195
- test("omits the tag inventory line when no tags exist", async () => {
196
- const output = await executeSearch({
197
- entries: [],
198
- total: 0,
199
- allTags: [],
200
- });
201
-
202
- expect(output).toBe("No journal entries found.");
203
- });
204
-
205
- test("bounds large tag inventories for empty results too", async () => {
206
- const output = await executeSearch({
207
- entries: [],
208
- total: 0,
209
- allTags: manyTags,
210
- });
211
-
212
- expect(output).toContain("No journal entries found.");
213
- expect(output.split("\n")[1]).toBe("Tags in use: 21 (filter with tags=...)");
214
- expect(output).not.toContain("tag-1");
215
- expect(output).not.toContain("tag-21");
29
+ describe("v2 journal tools", () => {
30
+ test("preserves the recognizable names and descriptions", () => {
31
+ expect([JournalWrite(baseStore, context).name, JournalRead(baseStore).name, JournalSearch(baseStore).name]).toEqual(["journal_write", "journal_read", "journal_search"]);
32
+ expect(JournalWrite(baseStore, context).description).toContain("append-only");
33
+ expect(JournalRead(baseStore).description).toContain("by its ID");
34
+ expect(JournalSearch(baseStore).description).toContain("semantic similarity");
35
+ });
36
+
37
+ test("journal_write description preserves its behavioral guidance", () => {
38
+ const description = JournalWrite(baseStore, context).description;
39
+ expect(description).toMatch(/insights.*discoveries|discoveries.*decisions/i);
40
+ expect(description).toMatch(/insights.*technical discoveries.*design decisions.*observations/i);
41
+ expect(description).toMatch(/never edit old ones/i);
42
+ expect(description).toMatch(/comma-separated/i);
43
+ expect(description).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
44
+ expect(description).not.toContain("Suggested tags:");
45
+ const tagged = JournalWrite(baseStore, context, [{ name: "focus", description: "private detail" }]).description;
46
+ expect(tagged).toContain("Suggested tags: focus.");
47
+ expect(tagged).not.toContain("private detail");
48
+ });
49
+
50
+ test("read and search descriptions retain their distinct contracts", () => {
51
+ const read = JournalRead(baseStore).description;
52
+ expect(read).not.toMatch(/append.only|cannot be (edited|modified)/i);
53
+ expect(read).not.toMatch(/\d{4}-\d{2}-\d{2}T/);
54
+ const search = JournalSearch(baseStore);
55
+ expect(search.description).toMatch(/filters are optional/i);
56
+ expect(search.description).toMatch(/global across all projects/i);
57
+ expect(search.input.properties).toHaveProperty("project");
58
+ expect(search.input.properties).toHaveProperty("offset");
59
+ expect(search.description).toMatch(/paginate/i);
60
+ });
61
+
62
+ test("descriptions are nonempty and do not name sibling tools", () => {
63
+ const descriptions = [JournalWrite(baseStore, context).description, JournalRead(baseStore).description, JournalSearch(baseStore).description];
64
+ expect(descriptions.every((description) => description.trim().length > 0)).toBe(true);
65
+ for (const [index, description] of descriptions.entries()) {
66
+ for (const [otherIndex, name] of ["journal_write", "journal_read", "journal_search"].entries()) {
67
+ if (index !== otherIndex) expect(description).not.toContain(name);
68
+ }
69
+ }
216
70
  });
217
- });
218
71
 
219
- // ─── Cross-tool invariants ───────────────────────────────────────────────────
220
-
221
- describe("cross-tool invariants", () => {
222
- test("no description mentions another journal tool by name", () => {
223
- const toolNames = ["journal_search", "journal_read", "journal_write"];
224
- for (const name of toolNames) {
225
- const desc =
226
- name === "journal_write"
227
- ? WRITE_DESC_NO_TAGS
228
- : name === "journal_read"
229
- ? READ_DESC
230
- : SEARCH_DESC;
231
- const others = toolNames.filter((n) => n !== name);
232
- for (const other of others) {
233
- expect(desc).not.toContain(other);
234
- }
72
+ test("uses JSON schemas, rejects malformed and unknown arguments", () => {
73
+ const write = JournalWrite(baseStore, context).input as any;
74
+ const read = JournalRead(baseStore).input as any;
75
+ const search = JournalSearch(baseStore).input as any;
76
+ expect(write.type).toBe("object");
77
+ expect(valid(write, { title: "t", body: "b" })).toBe(true);
78
+ expect(valid(write, { title: 4, body: "b" })).toBe(false);
79
+ expect(valid(write, { title: "t", body: "b", extra: true })).toBe(false);
80
+ expect(valid(read, {})).toBe(false);
81
+ expect(valid(read, { id: "id", extra: 1 })).toBe(false);
82
+ expect(valid(search, { limit: 0 })).toBe(false);
83
+ expect(valid(search, { offset: 1.5 })).toBe(false);
84
+ expect(valid(search, { unexpected: true })).toBe(false);
85
+ expect(valid(search, { tags: "a,b", limit: 2, offset: 0 })).toBe(true);
86
+ });
87
+
88
+ test("all execute methods return the v2 {content} result", async () => {
89
+ const writeResult = await JournalWrite(baseStore, context).execute({ title: "t", body: "b" }, { agent: "a", sessionID: "s" });
90
+ const readResult = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
91
+ const searchResult = await JournalSearch(baseStore).execute({}, {} as any);
92
+ for (const result of [writeResult, readResult, searchResult]) {
93
+ expect(Object.keys(result)).toEqual(["content"]);
94
+ expect(typeof result.content).toBe("string");
235
95
  }
96
+ expect(writeResult.content).toContain("Journal entry created:");
97
+ expect(readResult.content).toContain("Body");
98
+ expect(searchResult.content).toContain("Found 1 entries");
236
99
  });
237
100
 
238
- test("all descriptions are non-empty strings", () => {
239
- expect(typeof WRITE_DESC_NO_TAGS).toBe("string");
240
- expect(WRITE_DESC_NO_TAGS.length).toBeGreaterThan(0);
241
- expect(typeof READ_DESC).toBe("string");
242
- expect(READ_DESC.length).toBeGreaterThan(0);
243
- expect(typeof SEARCH_DESC).toBe("string");
244
- expect(SEARCH_DESC.length).toBeGreaterThan(0);
101
+ test("retains bounded tag inventory output formatting", async () => {
102
+ const populated: JournalStore = { ...baseStore, search: async () => ({ entries: [entry()], total: 1, allTags: ["alpha", "beta"] }) };
103
+ expect((await JournalSearch(populated).execute({}, {} as any)).content).toContain("Tags in use: alpha, beta");
104
+ const empty: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: [] }) };
105
+ expect((await JournalSearch(empty).execute({}, {} as any)).content).toBe("No journal entries found.");
106
+ const store: JournalStore = { ...baseStore, search: async () => ({ entries: [], total: 0, allTags: Array.from({ length: 21 }, (_, i) => `tag-${i}`) }) };
107
+ const result = await JournalSearch(store).execute({}, {} as any);
108
+ expect(result.content).toBe("No journal entries found.\nTags in use: 21 (filter with tags=...)");
245
109
  });
246
110
 
247
- test("no description contains the retired invitation phrase", () => {
248
- const retired = "record thoughts, discoveries, and decisions as you work";
249
- expect(WRITE_DESC_NO_TAGS).not.toContain(retired);
250
- expect(READ_DESC).not.toContain(retired);
251
- expect(SEARCH_DESC).not.toContain(retired);
111
+ test("journal_read formats metadata before the body", async () => {
112
+ const result = await JournalRead(baseStore).execute({ id: "entry" }, {} as any);
113
+ expect(result.content).toBe("title: Title\ncreated: 2026-01-01T00:00:00.000Z\nproject: /tmp\nmodel: m\nprovider: p\nagent: a\nsession: s\ntags: tag\n\nBody");
252
114
  });
253
115
  });
package/src/tools.ts CHANGED
@@ -1,5 +1,3 @@
1
- import { tool } from "@opencode-ai/plugin";
2
-
3
1
  import type { JournalStore, JournalTag } from "./journal";
4
2
 
5
3
  export type JournalContext = {
@@ -11,142 +9,99 @@ export type JournalContext = {
11
9
  const MAX_TAGS_TO_LIST = 20;
12
10
 
13
11
  function formatTagsInUse(allTags: readonly string[]): string {
14
- if (allTags.length === 0) {
15
- return "";
16
- }
17
-
18
- // Retrieved entries already show their own tags, so avoid repeating the
19
- // entire global inventory when it grows beyond a useful size.
20
- if (allTags.length > MAX_TAGS_TO_LIST) {
21
- return `\nTags in use: ${allTags.length} (filter with tags=...)`;
22
- }
12
+ if (allTags.length === 0) return "";
13
+ if (allTags.length > MAX_TAGS_TO_LIST) return `\nTags in use: ${allTags.length} (filter with tags=...)`;
23
14
  return `\nTags in use: ${allTags.join(", ")}`;
24
15
  }
25
16
 
26
- export function JournalWrite(
27
- store: JournalStore,
28
- ctx: JournalContext,
29
- tags?: readonly JournalTag[],
30
- ) {
31
- const suggestedTags =
32
- tags && tags.length > 0
33
- ? ` Suggested tags: ${tags.map((t) => t.name).join(", ")}.`
34
- : "";
35
- return tool({
36
- description:
37
- "Write a new append-only journal entry. Use this to capture insights, technical discoveries, " +
38
- "design decisions, observations, or reflections. " +
39
- "Entries are append-only: you write new entries but never edit old ones. " +
40
- "Tags are optional comma-separated names, e.g. \"perf, debugging\"." +
41
- suggestedTags,
42
- args: {
43
- title: tool.schema.string(),
44
- body: tool.schema.string(),
45
- tags: tool.schema.string().optional(),
46
- },
47
- async execute(args, toolCtx) {
48
- const tags = args.tags
49
- ? args.tags
50
- .split(",")
51
- .map((t: string) => t.trim())
52
- .filter(Boolean)
53
- : undefined;
54
-
55
- const entry = await store.write({
56
- title: args.title,
57
- body: args.body,
58
- project: ctx.directory,
59
- model: ctx.model,
60
- provider: ctx.provider,
61
- agent: toolCtx.agent,
62
- sessionId: toolCtx.sessionID,
63
- tags,
64
- });
17
+ type JsonSchema = {
18
+ type: "object";
19
+ properties: Record<string, { type: string; description?: string; minimum?: number }>;
20
+ required?: string[];
21
+ additionalProperties: false;
22
+ };
23
+ type ToolDefinition = {
24
+ name: string;
25
+ description: string;
26
+ input: JsonSchema;
27
+ execute(input: Record<string, unknown>, context: { agent: string; sessionID: string }): Promise<{ content: string }>;
28
+ };
65
29
 
66
- return `Journal entry created: ${entry.id}\n title: ${entry.title}\n created: ${entry.created.toISOString()}`;
30
+ export function JournalWrite(store: JournalStore, ctx: JournalContext, tags?: readonly JournalTag[]): ToolDefinition {
31
+ const suggestedTags = tags?.length ? ` Suggested tags: ${tags.map((tag) => tag.name).join(", ")}.` : "";
32
+ return {
33
+ name: "journal_write",
34
+ description: "Write a new append-only journal entry. Use this to capture insights, technical discoveries, design decisions, observations, or reflections. Entries are append-only: you write new entries but never edit old ones. Tags are optional comma-separated names, e.g. \"perf, debugging\"." + suggestedTags,
35
+ input: {
36
+ type: "object",
37
+ properties: {
38
+ title: { type: "string", description: "Entry title" },
39
+ body: { type: "string", description: "Entry body" },
40
+ tags: { type: "string", description: "Optional comma-separated tag names" },
41
+ },
42
+ required: ["title", "body"],
43
+ additionalProperties: false,
67
44
  },
68
- });
69
- }
70
-
71
- export function JournalRead(store: JournalStore) {
72
- return tool({
73
- description:
74
- "Read a specific journal entry by its ID. Returns the full entry " +
75
- "including metadata and body.",
76
- args: {
77
- id: tool.schema.string(),
45
+ async execute(input, toolCtx) {
46
+ const title = input.title as string;
47
+ const body = input.body as string;
48
+ const tags = typeof input.tags === "string" ? input.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : undefined;
49
+ const entry = await store.write({ title, body, project: ctx.directory, model: ctx.model, provider: ctx.provider, agent: toolCtx.agent, sessionId: toolCtx.sessionID, tags });
50
+ return { content: `Journal entry created: ${entry.id}\n title: ${entry.title}\n created: ${entry.created.toISOString()}` };
78
51
  },
79
- async execute(args) {
80
- const entry = await store.read(args.id);
52
+ };
53
+ }
81
54
 
55
+ export function JournalRead(store: JournalStore): ToolDefinition {
56
+ return {
57
+ name: "journal_read",
58
+ description: "Read a specific journal entry by its ID. Returns the full entry including metadata and body.",
59
+ input: { type: "object", properties: { id: { type: "string", description: "Journal entry ID" } }, required: ["id"], additionalProperties: false },
60
+ async execute(input) {
61
+ const entry = await store.read(input.id as string);
82
62
  const meta = [
83
- `title: ${entry.title}`,
84
- `created: ${entry.created.toISOString()}`,
63
+ `title: ${entry.title}`, `created: ${entry.created.toISOString()}`,
85
64
  entry.project ? `project: ${entry.project}` : null,
86
65
  entry.model ? `model: ${entry.model}` : null,
87
66
  entry.provider ? `provider: ${entry.provider}` : null,
88
67
  entry.agent ? `agent: ${entry.agent}` : null,
89
68
  entry.sessionId ? `session: ${entry.sessionId}` : null,
90
- entry.tags.length > 0
91
- ? `tags: ${entry.tags.join(", ")}`
92
- : null,
93
- ]
94
- .filter(Boolean)
95
- .join("\n");
96
-
97
- return `${meta}\n\n${entry.body}`;
69
+ entry.tags.length ? `tags: ${entry.tags.join(", ")}` : null,
70
+ ].filter(Boolean).join("\n");
71
+ return { content: `${meta}\n\n${entry.body}` };
98
72
  },
99
- });
73
+ };
100
74
  }
101
75
 
102
- export function JournalSearch(store: JournalStore) {
103
- return tool({
104
- description:
105
- "Search journal entries using semantic similarity. Returns matching entries " +
106
- "sorted by relevance. All filters are optional and combined with AND logic. " +
107
- "Use with no arguments to list recent entries. Use offset to paginate. " +
108
- "Before starting complex tasks, search the journal for relevant past context. " +
109
- "The journal is global across all projects but each entry records which project it was written from.",
110
- args: {
111
- text: tool.schema.string().optional(),
112
- project: tool.schema.string().optional(),
113
- tags: tool.schema.string().optional(),
114
- limit: tool.schema.number().int().positive().optional(),
115
- offset: tool.schema.number().int().nonnegative().optional(),
76
+ export function JournalSearch(store: JournalStore): ToolDefinition {
77
+ return {
78
+ name: "journal_search",
79
+ description: "Search journal entries using semantic similarity. Returns matching entries sorted by relevance. All filters are optional and combined with AND logic. Use with no arguments to list recent entries. Use offset to paginate. Before starting complex tasks, search the journal for relevant past context. The journal is global across all projects but each entry records which project it was written from.",
80
+ input: {
81
+ type: "object",
82
+ properties: {
83
+ text: { type: "string", description: "Semantic search text" },
84
+ project: { type: "string", description: "Filter by project directory" },
85
+ tags: { type: "string", description: "Comma-separated tags; all must match" },
86
+ limit: { type: "integer", minimum: 1, description: "Maximum entries to return" },
87
+ offset: { type: "integer", minimum: 0, description: "Number of entries to skip" },
88
+ },
89
+ additionalProperties: false,
116
90
  },
117
- async execute(args) {
118
- const tags = args.tags
119
- ? args.tags
120
- .split(",")
121
- .map((t: string) => t.trim())
122
- .filter(Boolean)
123
- : undefined;
124
-
91
+ async execute(input) {
125
92
  const result = await store.search({
126
- text: args.text,
127
- project: args.project,
128
- tags,
129
- limit: args.limit,
130
- offset: args.offset,
93
+ text: input.text as string | undefined,
94
+ project: input.project as string | undefined,
95
+ tags: typeof input.tags === "string" ? input.tags.split(",").map((tag) => tag.trim()).filter(Boolean) : undefined,
96
+ limit: input.limit as number | undefined,
97
+ offset: input.offset as number | undefined,
131
98
  });
132
-
133
99
  const tagsLine = formatTagsInUse(result.allTags);
134
- if (result.entries.length === 0) {
135
- return `No journal entries found.${tagsLine}`;
136
- }
137
-
138
- const offset = args.offset ?? 0;
100
+ if (!result.entries.length) return { content: `No journal entries found.${tagsLine}` };
101
+ const offset = (input.offset as number | undefined) ?? 0;
139
102
  const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
140
-
141
- const lines = result.entries.map((e) => {
142
- const tagStr =
143
- e.tags.length > 0
144
- ? ` [${e.tags.join(", ")}]`
145
- : "";
146
- return `${e.id}\n ${e.title}${tagStr}\n ${e.created.toISOString()}`;
147
- });
148
-
149
- return `${header}${tagsLine}\n\n${lines.join("\n\n")}`;
103
+ const lines = result.entries.map((entry) => `${entry.id}\n ${entry.title}${entry.tags.length ? ` [${entry.tags.join(", ")}]` : ""}\n ${entry.created.toISOString()}`);
104
+ return { content: `${header}${tagsLine}\n\n${lines.join("\n\n")}` };
150
105
  },
151
- });
106
+ };
152
107
  }