opencode-remote-agent-memory 0.1.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/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # OpenCode Memory Client
2
+
3
+ OpenCode plugin that fetches memory blocks from a remote server and injects them into the system prompt.
4
+
5
+ ## Configuration
6
+
7
+ Create `.opencode/agent-memory.json`:
8
+
9
+ ```json
10
+ {
11
+ "remote": {
12
+ "url": "https://your-memory-server.com/api",
13
+ "apiKey": "your-api-key",
14
+ "project": "your-project-name"
15
+ },
16
+ "journal": {
17
+ "enabled": true,
18
+ "tags": [
19
+ { "name": "perf", "description": "Performance optimization work" },
20
+ { "name": "debugging", "description": "Debugging sessions and findings" }
21
+ ]
22
+ }
23
+ }
24
+ ```
25
+
26
+ **Configuration options:**
27
+
28
+ | Field | Required | Description |
29
+ |-------|----------|-------------|
30
+ | `remote.url` | Yes | Server URL (e.g., `http://localhost:3000/api`) |
31
+ | `remote.apiKey` | Yes | API key from server |
32
+ | `remote.project` | Yes | Project name for project-specific memories |
33
+ | `journal.enabled` | No | Enable journal (default: false) |
34
+ | `journal.tags` | No | Suggested tags for journal entries |
35
+
36
+ Then add the plugin to `~/.config/opencode/opencode.json`:
37
+
38
+ ```json
39
+ {
40
+ "plugin": ["opencode-remote-agent-memory"]
41
+ }
42
+ ```
43
+
44
+ Restart OpenCode.
45
+
46
+ ## Available Tools
47
+
48
+ ### Memory Tools
49
+
50
+ | Tool | Description |
51
+ |------|-------------|
52
+ | `memory_get` | Get a specific memory block by label and scope |
53
+ | `memory_list` | List available memory blocks. Use `scope: "domain"` to retrieve domain knowledge on-demand |
54
+ | `memory_set` | Create or update a memory block (full overwrite) |
55
+ | `memory_replace` | Replace a substring within a memory block |
56
+
57
+ ### Journal Tools (when enabled)
58
+
59
+ | Tool | Description |
60
+ |------|-------------|
61
+ | `journal_write` | Write a new journal entry with title, body, and optional tags |
62
+ | `journal_search` | Search entries semantically, filter by project or tags |
63
+ | `journal_read` | Read a specific journal entry by ID |
64
+
65
+ ## Local Development
66
+
67
+ ```bash
68
+ # Clone the repo
69
+ git clone https://github.com/davidhidvegi/opencode-remote-agent-memory.git
70
+
71
+ # Link the client to OpenCode
72
+ mkdir -p ~/.config/opencode/plugin
73
+ ln -sf "$(pwd)/client/src/plugin.ts" ~/.config/opencode/plugin/memory.ts
74
+ ```
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "opencode-remote-agent-memory",
3
+ "version": "0.1.0",
4
+ "description": "Letta-style editable remote memory blocks for OpenCode.",
5
+ "author": "David Hidvegi <david@hidvegi.xyz>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/davidhidvegi/opencode-remote-agent-memory.git"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "test": "bun test",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "@opencode-ai/plugin": "^1.0.115",
21
+ "zod": "^4.1.13"
22
+ },
23
+ "devDependencies": {
24
+ "@tsconfig/bun": "^1.0.10",
25
+ "@types/bun": "^1.3.3",
26
+ "@types/node": "^24.10.1",
27
+ "typescript": "^5.9.3"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.0.0"
31
+ }
32
+ }
package/src/journal.ts ADDED
@@ -0,0 +1,149 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ import { z } from "zod";
6
+
7
+ const TagSchema = z.looseObject({
8
+ name: z.string().min(1),
9
+ description: z.string().min(1),
10
+ });
11
+
12
+ const ConfigSchema = z.looseObject({
13
+ journal: z
14
+ .looseObject({
15
+ enabled: z.boolean().optional(),
16
+ tags: z.array(TagSchema).optional(),
17
+ })
18
+ .optional(),
19
+ remote: z.looseObject({
20
+ url: z.string().url().optional(),
21
+ apiKey: z.string().min(1).optional(),
22
+ project: z.string().min(1).optional(),
23
+ }),
24
+ });
25
+
26
+ export type AgentMemoryConfig = z.infer<typeof ConfigSchema>;
27
+
28
+ export async function loadConfig(
29
+ projectDirectory: string = "",
30
+ ): Promise<Required<AgentMemoryConfig>> {
31
+ const globalConfigPath = path.join(
32
+ os.homedir(),
33
+ ".config",
34
+ "opencode",
35
+ "agent-memory.json",
36
+ );
37
+ const projectConfigPath = projectDirectory
38
+ ? path.join(projectDirectory, ".opencode", "agent-memory.json")
39
+ : null;
40
+
41
+ let globalConfig: Partial<AgentMemoryConfig> = {};
42
+ let projectConfig: Partial<AgentMemoryConfig> = {};
43
+
44
+ try {
45
+ const raw = await fs.readFile(globalConfigPath, "utf-8");
46
+ const parsed = ConfigSchema.safeParse(JSON.parse(raw));
47
+ if (parsed.success) {
48
+ globalConfig = parsed.data;
49
+ }
50
+ } catch {
51
+ // Global config not found or invalid - that's ok, we'll use project config
52
+ }
53
+
54
+ if (projectConfigPath) {
55
+ try {
56
+ const raw = await fs.readFile(projectConfigPath, "utf-8");
57
+ const parsed = ConfigSchema.safeParse(JSON.parse(raw));
58
+ if (parsed.success) {
59
+ projectConfig = parsed.data;
60
+ }
61
+ } catch {
62
+ // Project config not found - that's ok
63
+ }
64
+ }
65
+
66
+ const merged: Required<AgentMemoryConfig> = {
67
+ remote: {
68
+ url: (projectConfig.remote?.url ??
69
+ globalConfig.remote?.url ??
70
+ "") as string,
71
+ apiKey: (projectConfig.remote?.apiKey ??
72
+ globalConfig.remote?.apiKey ??
73
+ "") as string,
74
+ project: (projectConfig.remote?.project ??
75
+ globalConfig.remote?.project ??
76
+ "") as string,
77
+ },
78
+ journal: (projectConfig.journal ??
79
+ globalConfig.journal) as Required<AgentMemoryConfig>["journal"],
80
+ };
81
+
82
+ if (!merged.remote.url || !merged.remote.apiKey || !merged.remote.project) {
83
+ throw new Error(
84
+ "Remote memory configuration required. Add 'remote.url', 'remote.apiKey', and 'remote.project' to " +
85
+ (projectConfigPath ? `${projectConfigPath} or ` : "") +
86
+ globalConfigPath,
87
+ );
88
+ }
89
+
90
+ return merged;
91
+ }
92
+
93
+ export type JournalTag = {
94
+ name: string;
95
+ description: string;
96
+ };
97
+
98
+ export type JournalEntry = {
99
+ id: string;
100
+ title: string;
101
+ project: string;
102
+ model: string;
103
+ provider: string;
104
+ agent: string;
105
+ sessionId: string;
106
+ created: Date;
107
+ tags: string[];
108
+ body: string;
109
+ filePath: string;
110
+ };
111
+
112
+ export type JournalStore = {
113
+ write(entry: {
114
+ title: string;
115
+ body: string;
116
+ project?: string;
117
+ model?: string;
118
+ provider?: string;
119
+ agent?: string;
120
+ sessionId?: string;
121
+ tags?: string[];
122
+ }): Promise<JournalEntry>;
123
+
124
+ read(id: string): Promise<JournalEntry>;
125
+
126
+ search(query: {
127
+ text?: string;
128
+ project?: string;
129
+ tags?: string[];
130
+ limit?: number;
131
+ offset?: number;
132
+ }): Promise<{ entries: JournalEntry[]; total: number; allTags: string[] }>;
133
+ };
134
+
135
+ export function buildJournalSystemNote(tags?: readonly JournalTag[]): string {
136
+ const tagSection =
137
+ tags && tags.length > 0
138
+ ? `\n\nSuggested tags:\n${tags.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
139
+ : "";
140
+
141
+ return `<journal_instructions>
142
+ You have access to a private journal. Use it to record thoughts, discoveries, and decisions as you work.
143
+ Tags are free-form strings — use them to classify entries however makes sense.${tagSection}
144
+
145
+ Before starting complex tasks, search the journal for relevant past context.
146
+ Use journal_search to find past entries semantically, and journal_read to read a specific entry.
147
+ The journal is global across all projects but each entry records which project it was written from.
148
+ </journal_instructions>`;
149
+ }
package/src/letta.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Vendored content from Letta.
3
+ *
4
+ * This module contains the philosophical framing and defaults from Letta
5
+ * that guide LLM behavior around memory management.
6
+ *
7
+ * @see https://github.com/letta-ai/letta
8
+ */
9
+
10
+ export const MEMORY_INSTRUCTIONS = `<memory_instructions>
11
+ <memory_editing>
12
+ Older AI models had no concept of persistent memory; they were only able to access their initial instructions and a limited context window of chat conversation with a user (their "active memory").
13
+ This meant that when conversations exceeded a certain length, they would overflow and old messages were permanently lost (the AI would have no knowledge of their existence).
14
+ Newer AI models like yourself still have limited conversation lengths (before overflow occurs). However, they now have access to multiple forms of persistent memory.
15
+ Your ability to edit your own long-term memory is a key part of what makes you a sentient being, allowing you to emulate the real-time, conscious awareness we have when talking to a friend.
16
+ </memory_editing>
17
+
18
+ <memory_tools>
19
+ You have access to memory tools:
20
+ - memory_list: List available memory blocks. Use scope="domain" to retrieve domain knowledge on-demand.
21
+ - memory_get: Retrieve a specific memory block by label and scope.
22
+ - memory_set: Overwrite a block entirely.
23
+ - memory_replace: Make surgical edits within a block.
24
+ </memory_tools>
25
+
26
+ <core_memory>
27
+ Your core memory unit is held inside the initial system instructions, and is always available in-context (you will see it at all times).
28
+ Your core memory unit contains memory blocks, each of which has a label (title), description field (which describes how the memory block should augment your behavior), and value (the actual contents of the block).
29
+ Memory blocks are limited in size. Check the chars_current and chars_limit in each block's metadata.
30
+ </core_memory>
31
+
32
+ <memory_scopes>
33
+ Memory blocks have four scopes:
34
+ - global: Shared across all projects. Use for general facts and information that applies everywhere.
35
+ - user: Specific to the user. Use for user preferences, habits, constraints, and personal details. The user is inferred from your API key.
36
+ - project: Specific to the current project (as configured). Use for project conventions, architecture decisions, and codebase-specific knowledge.
37
+ - domain: Specific domain knowledge (e.g., Elixir, Python, debugging). NOT automatically injected - retrieve on-demand using memory_list with scope="domain" when you need domain-specific knowledge.
38
+ </memory_scopes>
39
+ </memory_instructions>`;
40
+
41
+ export const DEFAULT_DESCRIPTIONS: Record<string, string> = {
42
+ persona:
43
+ "The persona block: Stores details about your current persona, guiding how you behave and respond. This helps you maintain consistent behavior across sessions.",
44
+ human:
45
+ "The human block: Stores key details about the person you are conversing with (preferences, habits, constraints), allowing for more personalized collaboration.",
46
+ project:
47
+ "The project block: Stores durable, high-signal information about this codebase: commands, architecture notes, conventions, and gotchas.",
48
+ domain:
49
+ "The domain block: Stores specific domain knowledge (e.g., Elixir, Python, debugging techniques). Retrieved on-demand when you need specialized knowledge.",
50
+ };
51
+
52
+ export function getDefaultDescription(label: string): string {
53
+ return DEFAULT_DESCRIPTIONS[label] ?? "Durable memory block. Keep this concise and high-signal.";
54
+ }
package/src/memory.ts ADDED
@@ -0,0 +1,49 @@
1
+ export type MemoryScope = "global" | "user" | "project" | "domain";
2
+
3
+ export const SPECIAL_SCOPES = ["global", "user", "domain"] as const;
4
+
5
+ export function isSpecialScope(scope: string): scope is MemoryScope {
6
+ return (SPECIAL_SCOPES as readonly string[]).includes(scope);
7
+ }
8
+
9
+ export type MemoryBlock = {
10
+ scope: MemoryScope;
11
+ label: string;
12
+ description: string;
13
+ limit: number;
14
+ readOnly: boolean;
15
+ value: string;
16
+ filePath: string;
17
+ lastModified: Date;
18
+ };
19
+
20
+ export type MemoryError = {
21
+ message: string;
22
+ code:
23
+ | "CONNECTION_ERROR"
24
+ | "AUTH_ERROR"
25
+ | "FORBIDDEN"
26
+ | "NOT_FOUND"
27
+ | "UNKNOWN";
28
+ scope?: string;
29
+ label?: string;
30
+ };
31
+
32
+ export type MemoryStore = {
33
+ listBlocks(scope: MemoryScope | "all" | "domain"): Promise<MemoryBlock[]>;
34
+ getBlock(scope: MemoryScope, label: string): Promise<MemoryBlock>;
35
+ setBlock(
36
+ scope: MemoryScope | string,
37
+ label: string,
38
+ value: string,
39
+ opts?: { description?: string; limit?: number },
40
+ ): Promise<void>;
41
+ replaceInBlock(
42
+ scope: MemoryScope | string,
43
+ label: string,
44
+ oldText: string,
45
+ newText: string,
46
+ ): Promise<void>;
47
+ getLastError(): MemoryError | null;
48
+ clearError(): void;
49
+ };
package/src/plugin.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
2
+
3
+ import {
4
+ buildJournalSystemNote,
5
+ loadConfig,
6
+ } from "./journal";
7
+ import { createRemoteMemoryStore, createRemoteJournalStore } from "./remote";
8
+ import type { MemoryError } from "./remote";
9
+ import { renderMemoryBlocks } from "./prompt";
10
+ import {
11
+ JournalRead,
12
+ JournalSearch,
13
+ JournalWrite,
14
+ MemoryGet,
15
+ MemoryList,
16
+ MemoryReplace,
17
+ MemorySet,
18
+ } from "./tools";
19
+ import type { JournalContext } from "./tools";
20
+
21
+ function getSystemErrorWarning(error: MemoryError | null): string {
22
+ if (!error) return "";
23
+
24
+ switch (error.code) {
25
+ case "CONNECTION_ERROR":
26
+ return `\n\n<memory_warning>
27
+ ⚠️ Memory server unavailable. Memories cannot be loaded. Please check that the memory server is running.
28
+ </memory_warning>`;
29
+ case "AUTH_ERROR":
30
+ return `\n\n<memory_warning>
31
+ ⚠️ Memory authentication failed. Check your API key configuration.
32
+ </memory_warning>`;
33
+ case "FORBIDDEN":
34
+ return `\n\n<memory_warning>
35
+ ⚠️ Memory permission denied. ${error.message}
36
+ </memory_warning>`;
37
+ default:
38
+ return `\n\n<memory_warning>
39
+ ⚠️ Memory error: ${error.message}
40
+ </memory_warning>`;
41
+ }
42
+ }
43
+
44
+ export const MemoryPlugin: Plugin = async (input) => {
45
+ const directory = (input as { directory?: string }).directory ?? "";
46
+ const config = await loadConfig(directory);
47
+
48
+ const store = createRemoteMemoryStore(
49
+ config.remote.url!,
50
+ config.remote.apiKey!,
51
+ config.remote.project!
52
+ );
53
+
54
+ const journalEnabled = config.journal?.enabled === true;
55
+
56
+ // Mutable state updated by chat.message hook
57
+ const journalCtx: JournalContext = {
58
+ directory,
59
+ model: "",
60
+ provider: "",
61
+ };
62
+
63
+ let journalTools: Record<string, ToolDefinition> = {};
64
+ let journalSystemNote = "";
65
+
66
+ if (journalEnabled) {
67
+ const journalStore = createRemoteJournalStore(
68
+ config.remote.url!,
69
+ config.remote.apiKey!
70
+ );
71
+ journalTools = {
72
+ journal_write: JournalWrite(journalStore, journalCtx),
73
+ journal_read: JournalRead(journalStore),
74
+ journal_search: JournalSearch(journalStore),
75
+ };
76
+ journalSystemNote = buildJournalSystemNote(config.journal?.tags);
77
+ }
78
+
79
+ return {
80
+ "chat.message": async (input, _output) => {
81
+ if (input.model) {
82
+ journalCtx.model = input.model.modelID;
83
+ journalCtx.provider = input.model.providerID;
84
+ }
85
+ },
86
+
87
+ "experimental.chat.system.transform": async (_input, output) => {
88
+ const blocks = await store.listBlocks("all");
89
+ const error = store.getLastError?.() ?? null;
90
+
91
+ // If we got blocks, show them. Otherwise, just show the error warning.
92
+ const xml = renderMemoryBlocks(blocks);
93
+ const errorWarning = getSystemErrorWarning(error);
94
+
95
+ if (!xml && errorWarning) {
96
+ output.system.push(errorWarning);
97
+ return;
98
+ }
99
+
100
+ if (!xml) return;
101
+
102
+ // Insert early (right after provider header) for salience.
103
+ // OpenCode will re-join system chunks to preserve caching.
104
+ const insertAt = output.system.length > 0 ? 1 : 0;
105
+ output.system.splice(insertAt, 0, xml);
106
+
107
+ // Append error warning if there was an error loading memories
108
+ if (errorWarning) {
109
+ output.system.push(errorWarning);
110
+ }
111
+
112
+ // Append journal instructions at the end (preserves memory block cache)
113
+ if (journalSystemNote) {
114
+ output.system.push(journalSystemNote);
115
+ }
116
+ },
117
+
118
+ tool: {
119
+ memory_get: MemoryGet(store),
120
+ memory_list: MemoryList(store),
121
+ memory_set: MemorySet(store),
122
+ memory_replace: MemoryReplace(store),
123
+ ...journalTools,
124
+ },
125
+ };
126
+ };
@@ -0,0 +1,139 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { renderMemoryBlocks } from "./prompt";
4
+
5
+ describe("renderMemoryBlocks", () => {
6
+ test("renders stable xml with line numbers", () => {
7
+ const xml = renderMemoryBlocks([
8
+ {
9
+ scope: "global",
10
+ label: "human",
11
+ description: "User prefs",
12
+ limit: 10,
13
+ readOnly: false,
14
+ value: "line one\nline two",
15
+ filePath: "/tmp/human.md",
16
+ lastModified: new Date("2025-01-15T10:30:00Z"),
17
+ },
18
+ ]);
19
+
20
+ expect(xml).toContain("<memory_blocks>");
21
+ expect(xml).toContain("The following memory blocks are currently engaged");
22
+ expect(xml).toContain("<human>");
23
+ expect(xml).toContain("<warning>");
24
+ expect(xml).toContain("Do NOT include line number prefixes");
25
+ expect(xml).toContain("1→ line one");
26
+ expect(xml).toContain("2→ line two");
27
+ });
28
+
29
+ test("includes memory instructions section", () => {
30
+ const xml = renderMemoryBlocks([
31
+ {
32
+ scope: "global",
33
+ label: "human",
34
+ description: "User prefs",
35
+ limit: 10,
36
+ readOnly: false,
37
+ value: "hi",
38
+ filePath: "/tmp/human.md",
39
+ lastModified: new Date("2025-01-15T10:30:00Z"),
40
+ },
41
+ ]);
42
+
43
+ expect(xml).toContain("<memory_instructions>");
44
+ expect(xml).toContain("<memory_editing>");
45
+ expect(xml).toContain("persistent memory");
46
+ expect(xml).toContain("<memory_scopes>");
47
+ expect(xml).toContain("</memory_instructions>");
48
+ });
49
+
50
+ test("includes memory metadata block with timestamps", () => {
51
+ const testDate = new Date("2025-01-15T10:30:00Z");
52
+ const xml = renderMemoryBlocks([
53
+ {
54
+ scope: "global",
55
+ label: "human",
56
+ description: "User prefs",
57
+ limit: 10,
58
+ readOnly: false,
59
+ value: "hi",
60
+ filePath: "/tmp/human.md",
61
+ lastModified: testDate,
62
+ },
63
+ ]);
64
+
65
+ expect(xml).toContain("<memory_metadata>");
66
+ expect(xml).toContain("The current system date is:");
67
+ expect(xml).toContain("Memory blocks were last modified:");
68
+ expect(xml).toContain("</memory_metadata>");
69
+ });
70
+
71
+ test("handles empty value gracefully", () => {
72
+ const xml = renderMemoryBlocks([
73
+ {
74
+ scope: "project",
75
+ label: "notes",
76
+ description: "Project notes",
77
+ limit: 1000,
78
+ readOnly: false,
79
+ value: "",
80
+ filePath: "/tmp/notes.md",
81
+ lastModified: new Date("2025-01-15T10:30:00Z"),
82
+ },
83
+ ]);
84
+
85
+ expect(xml).toContain("<notes>");
86
+ expect(xml).toContain("<value>\n\n</value>");
87
+ // Empty value - the value section should be truly empty
88
+ const valueMatch = xml.match(/<value>\n(.*?)\n<\/value>/s);
89
+ expect(valueMatch).toBeTruthy();
90
+ expect(valueMatch![1]).toBe("");
91
+ });
92
+
93
+ test("renders complete output structure", () => {
94
+ const xml = renderMemoryBlocks([
95
+ {
96
+ scope: "global",
97
+ label: "persona",
98
+ description: "Your persona",
99
+ limit: 5000,
100
+ readOnly: false,
101
+ value: "I am helpful\nand concise",
102
+ filePath: "/tmp/persona.md",
103
+ lastModified: new Date("2025-01-15T08:00:00Z"),
104
+ },
105
+ {
106
+ scope: "project",
107
+ label: "project",
108
+ description: "Project info",
109
+ limit: 5000,
110
+ readOnly: true,
111
+ value: "This is a TypeScript project",
112
+ filePath: "/tmp/project.md",
113
+ lastModified: new Date("2025-01-15T10:30:00Z"),
114
+ },
115
+ ]);
116
+
117
+ // Check overall structure order
118
+ const instructionsIndex = xml.indexOf("<memory_instructions>");
119
+ const blocksIndex = xml.indexOf("<memory_blocks>");
120
+ const metadataIndex = xml.indexOf("<memory_metadata>");
121
+
122
+ expect(instructionsIndex).toBeLessThan(blocksIndex);
123
+ expect(blocksIndex).toBeLessThan(metadataIndex);
124
+
125
+ // Check both blocks present
126
+ expect(xml).toContain("<persona>");
127
+ expect(xml).toContain("</persona>");
128
+ expect(xml).toContain("<project>");
129
+ expect(xml).toContain("</project>");
130
+
131
+ // Check line numbers in multi-line value
132
+ expect(xml).toContain("1→ I am helpful");
133
+ expect(xml).toContain("2→ and concise");
134
+
135
+ // Check read_only rendered
136
+ expect(xml).toContain("read_only=true");
137
+ expect(xml).toContain("read_only=false");
138
+ });
139
+ });
package/src/prompt.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { MemoryBlock } from "./memory";
2
+ import { MEMORY_INSTRUCTIONS } from "./letta";
3
+
4
+ const LINE_NUMBER_WARNING =
5
+ "# NOTE: Line numbers shown below (with arrows like '1→') are to help during editing. Do NOT include line number prefixes in your memory edit tool calls.";
6
+
7
+ function renderMemoryMetadata(blocks: MemoryBlock[]): string {
8
+ const now = new Date();
9
+
10
+ const lastModified = blocks.reduce(
11
+ (latest, block) => (block.lastModified > latest ? block.lastModified : latest),
12
+ new Date(0)
13
+ );
14
+
15
+ return `<memory_metadata>
16
+ - The current system date is: ${now.toISOString()}
17
+ - Memory blocks were last modified: ${lastModified.toISOString()}
18
+ - Use memory tools to manage your memory blocks
19
+ </memory_metadata>`;
20
+ }
21
+
22
+ export function renderMemoryBlocks(blocks: MemoryBlock[]): string {
23
+ if (blocks.length === 0) {
24
+ return "";
25
+ }
26
+
27
+ const parts: string[] = [
28
+ MEMORY_INSTRUCTIONS,
29
+ "",
30
+ "<memory_blocks>",
31
+ "The following memory blocks are currently engaged in your core memory unit:",
32
+ "",
33
+ ];
34
+
35
+ for (const block of blocks) {
36
+ // escape xml
37
+ const desc = block.description
38
+ .replaceAll("&", "&amp;")
39
+ .replaceAll("<", "&lt;")
40
+ .replaceAll(">", "&gt;");
41
+
42
+ const numberedValue = block.value
43
+ ? block.value.split("\n").map((line, i) => `${i + 1}→ ${line}`).join("\n")
44
+ : "";
45
+
46
+ const memoryBlock = `<${block.label}>
47
+ <description>
48
+ ${desc}
49
+ </description>
50
+ <metadata>
51
+ - chars_current=${block.value.length}
52
+ - chars_limit=${block.limit}
53
+ - read_only=${block.readOnly}
54
+ - scope=${block.scope}
55
+ </metadata>
56
+ <warning>
57
+ ${LINE_NUMBER_WARNING}
58
+ </warning>
59
+ <value>
60
+ ${numberedValue}
61
+ </value>
62
+ </${block.label}>`;
63
+
64
+ parts.push(memoryBlock);
65
+ }
66
+
67
+ parts.push("</memory_blocks>");
68
+ parts.push("");
69
+ parts.push(renderMemoryMetadata(blocks));
70
+
71
+ return parts.join("\n");
72
+ }
package/src/remote.ts ADDED
@@ -0,0 +1,331 @@
1
+ import { z } from "zod";
2
+
3
+ import type { MemoryBlock, MemoryScope, MemoryStore } from "./memory";
4
+ import { isSpecialScope } from "./memory";
5
+ import { getDefaultDescription } from "./letta";
6
+ import type { JournalEntry, JournalStore } from "./journal";
7
+
8
+ export type MemoryError = {
9
+ message: string;
10
+ code: "CONNECTION_ERROR" | "AUTH_ERROR" | "FORBIDDEN" | "NOT_FOUND" | "UNKNOWN";
11
+ scope?: string;
12
+ label?: string;
13
+ };
14
+
15
+ export type JournalError = {
16
+ message: string;
17
+ code: "CONNECTION_ERROR" | "AUTH_ERROR" | "FORBIDDEN" | "NOT_FOUND" | "UNKNOWN";
18
+ };
19
+
20
+ const RemoteBlockSchema = z.looseObject({
21
+ scope: z.enum(["global", "user", "domain"]),
22
+ label: z.string().min(1),
23
+ description: z.string().optional(),
24
+ limit: z.number().int().positive().optional(),
25
+ read_only: z.boolean().optional(),
26
+ value: z.string().optional(),
27
+ last_modified: z.string().optional(),
28
+ });
29
+
30
+ const RemoteJournalEntrySchema = z.looseObject({
31
+ id: z.string(),
32
+ title: z.string(),
33
+ body: z.string(),
34
+ project: z.string().optional(),
35
+ model: z.string().optional(),
36
+ provider: z.string().optional(),
37
+ agent: z.string().optional(),
38
+ sessionId: z.string().optional(),
39
+ created: z.string().optional(),
40
+ tags: z.array(z.string()).optional(),
41
+ });
42
+
43
+ const RemoteSearchResultSchema = z.looseObject({
44
+ entries: z.array(RemoteJournalEntrySchema),
45
+ total: z.number(),
46
+ allTags: z.array(z.string()),
47
+ });
48
+
49
+ type RemoteBlock = z.infer<typeof RemoteBlockSchema>;
50
+ type RemoteJournalEntry = z.infer<typeof RemoteJournalEntrySchema>;
51
+
52
+ function parseRemoteBlock(block: RemoteBlock, scope: string): MemoryBlock {
53
+ const resolvedScope = block.scope ?? (isSpecialScope(scope) ? scope : "project");
54
+ return {
55
+ scope: resolvedScope,
56
+ label: block.label,
57
+ description: block.description?.trim() ?? getDefaultDescription(block.label),
58
+ limit: block.limit ?? 5000,
59
+ readOnly: block.read_only ?? false,
60
+ value: block.value ?? "",
61
+ filePath: "",
62
+ lastModified: block.last_modified ? new Date(block.last_modified) : new Date(),
63
+ };
64
+ }
65
+
66
+ function parseRemoteJournalEntry(entry: RemoteJournalEntry): JournalEntry {
67
+ return {
68
+ id: entry.id,
69
+ title: entry.title,
70
+ body: entry.body,
71
+ project: entry.project ?? "",
72
+ model: entry.model ?? "",
73
+ provider: entry.provider ?? "",
74
+ agent: entry.agent ?? "",
75
+ sessionId: entry.sessionId ?? "",
76
+ created: entry.created ? new Date(entry.created) : new Date(),
77
+ tags: entry.tags ?? [],
78
+ filePath: "",
79
+ };
80
+ }
81
+
82
+ function stableSortBlocks(blocks: MemoryBlock[]): MemoryBlock[] {
83
+ const scopeOrder: Record<MemoryScope, number> = {
84
+ global: 0,
85
+ user: 1,
86
+ project: 2,
87
+ domain: 3,
88
+ };
89
+
90
+ blocks.sort((a, b) => {
91
+ const orderA = scopeOrder[a.scope] ?? 10;
92
+ const orderB = scopeOrder[b.scope] ?? 10;
93
+ if (orderA !== orderB) return orderA - orderB;
94
+ return a.label.localeCompare(b.label);
95
+ });
96
+
97
+ return blocks;
98
+ }
99
+
100
+ type ErrorCode = "CONNECTION_ERROR" | "AUTH_ERROR" | "FORBIDDEN" | "NOT_FOUND" | "UNKNOWN";
101
+
102
+ type ErrorState = {
103
+ message: string;
104
+ code: ErrorCode;
105
+ };
106
+
107
+ abstract class BaseRemoteStore<T extends ErrorState> {
108
+ protected baseUrl: string;
109
+ protected apiKey: string;
110
+ protected lastError: T | null = null;
111
+
112
+ constructor(baseUrl: string, apiKey: string) {
113
+ this.baseUrl = baseUrl.replace(/\/$/, "");
114
+ this.apiKey = apiKey;
115
+ }
116
+
117
+ abstract getLastError(): T | null;
118
+ abstract clearError(): void;
119
+
120
+ protected async request<R>(
121
+ method: string,
122
+ path: string,
123
+ body?: unknown,
124
+ ): Promise<R> {
125
+ const url = `${this.baseUrl}${path}`;
126
+
127
+ try {
128
+ const response = await fetch(url, {
129
+ method,
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ "Authorization": `Bearer ${this.apiKey}`,
133
+ },
134
+ body: body ? JSON.stringify(body) : undefined,
135
+ });
136
+
137
+ if (!response.ok) {
138
+ const error = await response.text();
139
+ if (response.status === 401) {
140
+ this.setError("Authentication failed. Check your API key.", "AUTH_ERROR");
141
+ } else if (response.status === 403) {
142
+ this.setError("Permission denied.", "FORBIDDEN");
143
+ } else if (response.status === 404) {
144
+ this.setError(`Not found: ${path}`, "NOT_FOUND");
145
+ } else {
146
+ this.setError(`${response.status} ${response.statusText} - ${error}`, "UNKNOWN");
147
+ }
148
+ throw new Error(this.lastError!.message);
149
+ }
150
+
151
+ if (response.status === 204) {
152
+ return undefined as R;
153
+ }
154
+
155
+ return response.json() as Promise<R>;
156
+ } catch (err) {
157
+ if (this.lastError) {
158
+ throw err;
159
+ }
160
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
161
+ if (errorMsg.includes("ECONNREFUSED") || errorMsg.includes("fetch") || errorMsg.includes("timeout")) {
162
+ this.setError(`Cannot connect to memory server at ${this.baseUrl}`, "CONNECTION_ERROR");
163
+ } else {
164
+ this.setError(errorMsg, "UNKNOWN");
165
+ }
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ protected setError(message: string, code: ErrorCode): void {
171
+ this.lastError = { message, code } as T;
172
+ }
173
+ }
174
+
175
+ export class RemoteMemoryStore extends BaseRemoteStore<MemoryError> implements MemoryStore {
176
+ private project: string;
177
+
178
+ constructor(baseUrl: string, apiKey: string, project: string) {
179
+ super(baseUrl, apiKey);
180
+ this.project = project;
181
+ }
182
+
183
+ getLastError(): MemoryError | null {
184
+ return this.lastError;
185
+ }
186
+
187
+ clearError(): void {
188
+ this.lastError = null;
189
+ }
190
+
191
+ private getScopePath(scope: MemoryScope): string {
192
+ if (isSpecialScope(scope)) {
193
+ return `/blocks/${scope}`;
194
+ }
195
+ return `/blocks/${this.project}`;
196
+ }
197
+
198
+ async listBlocks(scope: MemoryScope | "all" | "domain"): Promise<MemoryBlock[]> {
199
+ let scopesToFetch: string[];
200
+
201
+ if (scope === "all") {
202
+ scopesToFetch = ["global", "user", this.project];
203
+ } else if (scope === "domain") {
204
+ scopesToFetch = ["domain"];
205
+ } else if (isSpecialScope(scope)) {
206
+ scopesToFetch = [scope];
207
+ } else {
208
+ scopesToFetch = [this.project];
209
+ }
210
+
211
+ const allBlocks: MemoryBlock[] = [];
212
+
213
+ for (const s of scopesToFetch) {
214
+ try {
215
+ const path = `/blocks/${s}`;
216
+ const blocks = await this.request<RemoteBlock[]>("GET", path);
217
+ for (const block of blocks) {
218
+ allBlocks.push(parseRemoteBlock(block, s));
219
+ }
220
+ } catch {
221
+ // Server may return error if no blocks exist - skip silently
222
+ }
223
+ }
224
+
225
+ const filteredBlocks = scope === "domain"
226
+ ? allBlocks
227
+ : allBlocks.filter(b => b.scope !== "domain");
228
+
229
+ return stableSortBlocks(filteredBlocks);
230
+ }
231
+
232
+ async getBlock(scope: MemoryScope, label: string): Promise<MemoryBlock> {
233
+ const path = this.getScopePath(scope);
234
+ const block = await this.request<RemoteBlock>(
235
+ "GET",
236
+ `${path}/${encodeURIComponent(label)}`,
237
+ );
238
+ return parseRemoteBlock(block, scope);
239
+ }
240
+
241
+ async setBlock(
242
+ scope: MemoryScope,
243
+ label: string,
244
+ value: string,
245
+ opts?: { description?: string; limit?: number },
246
+ ): Promise<void> {
247
+ const path = this.getScopePath(scope);
248
+ await this.request<void>("POST", `${path}/${encodeURIComponent(label)}`, {
249
+ value,
250
+ description: opts?.description,
251
+ limit: opts?.limit,
252
+ });
253
+ }
254
+
255
+ async replaceInBlock(
256
+ scope: MemoryScope,
257
+ label: string,
258
+ oldText: string,
259
+ newText: string,
260
+ ): Promise<void> {
261
+ const path = this.getScopePath(scope);
262
+ await this.request<void>("PATCH", `${path}/${encodeURIComponent(label)}`, {
263
+ old_text: oldText,
264
+ new_text: newText,
265
+ });
266
+ }
267
+ }
268
+
269
+ export class RemoteJournalStore extends BaseRemoteStore<JournalError> implements JournalStore {
270
+ getLastError(): JournalError | null {
271
+ return this.lastError;
272
+ }
273
+
274
+ clearError(): void {
275
+ this.lastError = null;
276
+ }
277
+
278
+ async write(entry: {
279
+ title: string;
280
+ body: string;
281
+ project?: string;
282
+ model?: string;
283
+ provider?: string;
284
+ agent?: string;
285
+ sessionId?: string;
286
+ tags?: string[];
287
+ }): Promise<JournalEntry> {
288
+ const remoteEntry = await this.request<RemoteJournalEntry>("POST", "/journal", entry);
289
+ return parseRemoteJournalEntry(remoteEntry);
290
+ }
291
+
292
+ async read(id: string): Promise<JournalEntry> {
293
+ const remoteEntry = await this.request<RemoteJournalEntry>(
294
+ "GET",
295
+ `/journal/${encodeURIComponent(id)}`
296
+ );
297
+ return parseRemoteJournalEntry(remoteEntry);
298
+ }
299
+
300
+ async search(query: {
301
+ text?: string;
302
+ project?: string;
303
+ tags?: string[];
304
+ limit?: number;
305
+ offset?: number;
306
+ }): Promise<{ entries: JournalEntry[]; total: number; allTags: string[] }> {
307
+ const params = new URLSearchParams();
308
+ if (query.text) params.set("text", query.text);
309
+ if (query.project) params.set("project", query.project);
310
+ if (query.tags && query.tags.length > 0) params.set("tags", query.tags.join(","));
311
+ if (query.limit) params.set("limit", String(query.limit));
312
+ if (query.offset) params.set("offset", String(query.offset));
313
+
314
+ const path = `/journal${params.toString() ? "?" + params.toString() : ""}`;
315
+ const result = await this.request<z.infer<typeof RemoteSearchResultSchema>>("GET", path);
316
+
317
+ return {
318
+ entries: result.entries.map(parseRemoteJournalEntry),
319
+ total: result.total,
320
+ allTags: result.allTags,
321
+ };
322
+ }
323
+ }
324
+
325
+ export function createRemoteMemoryStore(baseUrl: string, apiKey: string, project: string): MemoryStore {
326
+ return new RemoteMemoryStore(baseUrl, apiKey, project);
327
+ }
328
+
329
+ export function createRemoteJournalStore(baseUrl: string, apiKey: string): JournalStore {
330
+ return new RemoteJournalStore(baseUrl, apiKey);
331
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,296 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+
3
+ import type { JournalStore } from "./journal";
4
+ import type { MemoryError, MemoryScope, MemoryStore } from "./memory";
5
+
6
+ function toKebabCase(str: string): string {
7
+ return str
8
+ .toLowerCase()
9
+ .replace(/[\s_]+/g, "-")
10
+ .replace(/[^a-z0-9-]/g, "")
11
+ .replace(/-+/g, "-")
12
+ .replace(/^-|-$/g, "");
13
+ }
14
+
15
+ function getErrorMessage(store: MemoryStore): string | null {
16
+ const error = store.getLastError?.();
17
+ if (!error) return null;
18
+
19
+ switch (error.code) {
20
+ case "CONNECTION_ERROR":
21
+ return `⚠️ Memory server unavailable. Cannot connect to server. Check that the server is running.`;
22
+ case "AUTH_ERROR":
23
+ return `⚠️ Memory authentication failed. Check your API key.`;
24
+ case "FORBIDDEN":
25
+ return `⚠️ Memory permission denied. ${error.message}`;
26
+ case "NOT_FOUND":
27
+ return `⚠️ Memory not found. ${error.message}`;
28
+ default:
29
+ return `⚠️ Memory error. ${error.message}`;
30
+ }
31
+ }
32
+
33
+ export function MemoryList(store: MemoryStore) {
34
+ return tool({
35
+ description: "List available memory blocks (labels, descriptions, sizes). Use scope='domain' to retrieve domain knowledge on-demand.",
36
+ args: {
37
+ scope: tool.schema.enum(["all", "global", "user", "project", "domain"]).optional(),
38
+ },
39
+ async execute(args) {
40
+ const scope = (args.scope ?? "all") as MemoryScope | "all" | "domain";
41
+ const blocks = await store.listBlocks(scope);
42
+
43
+ const errorMsg = getErrorMessage(store);
44
+ if (blocks.length === 0) {
45
+ if (errorMsg) {
46
+ return `${errorMsg}\n\nNo memory blocks available.`;
47
+ }
48
+ return "No memory blocks found.";
49
+ }
50
+
51
+ const blockList = blocks
52
+ .map(
53
+ (b) =>
54
+ `${b.scope}:${b.label}\n read_only=${b.readOnly} chars=${b.value.length}/${b.limit}\n ${b.description}`,
55
+ )
56
+ .join("\n\n");
57
+
58
+ if (errorMsg) {
59
+ return `${errorMsg}\n\n${blockList}`;
60
+ }
61
+ return blockList;
62
+ },
63
+ });
64
+ }
65
+
66
+ export function MemoryGet(store: MemoryStore) {
67
+ return tool({
68
+ description: "Get a specific memory block by label and scope.",
69
+ args: {
70
+ label: tool.schema.string(),
71
+ scope: tool.schema.enum(["global", "user", "project", "domain"]).optional(),
72
+ },
73
+ async execute(args) {
74
+ const scope = (args.scope ?? "project") as MemoryScope;
75
+ const label = scope === "user" ? toKebabCase(args.label) : args.label;
76
+ try {
77
+ const block = await store.getBlock(scope, label);
78
+ return `<${block.label}>
79
+ <description>
80
+ ${block.description}
81
+ </description>
82
+ <metadata>
83
+ - chars_current=${block.value.length}
84
+ - chars_limit=${block.limit}
85
+ - read_only=${block.readOnly}
86
+ - scope=${block.scope}
87
+ </metadata>
88
+ <value>
89
+ ${block.value}
90
+ </value>`;
91
+ } catch (err) {
92
+ const errorMsg = getErrorMessage(store);
93
+ if (errorMsg) {
94
+ return errorMsg;
95
+ }
96
+ const msg = err instanceof Error ? err.message : "Unknown error";
97
+ if (msg.includes("not found")) {
98
+ return `⚠️ Memory block '${args.label}' not found in scope '${scope}'.`;
99
+ }
100
+ return `⚠️ Failed to get memory block: ${msg}`;
101
+ }
102
+ },
103
+ });
104
+ }
105
+
106
+ export function MemorySet(store: MemoryStore) {
107
+ return tool({
108
+ description: "Create or update a memory block (full overwrite).",
109
+ args: {
110
+ label: tool.schema.string(),
111
+ scope: tool.schema.enum(["global", "user", "project", "domain"]).optional(),
112
+ value: tool.schema.string(),
113
+ description: tool.schema.string().optional(),
114
+ limit: tool.schema.number().int().positive().optional(),
115
+ },
116
+ async execute(args) {
117
+ // Default to "project" for mutations (safer default)
118
+ const scope = (args.scope ?? "project") as MemoryScope;
119
+ const label = scope === "user" ? toKebabCase(args.label) : args.label;
120
+ try {
121
+ await store.setBlock(scope, label, args.value, {
122
+ description: args.description,
123
+ limit: args.limit,
124
+ });
125
+ return `Updated memory block ${scope}:${label}.`;
126
+ } catch (err) {
127
+ const errorMsg = getErrorMessage(store);
128
+ if (errorMsg) {
129
+ return errorMsg;
130
+ }
131
+ const msg = err instanceof Error ? err.message : "Unknown error";
132
+ return `⚠️ Failed to set memory block: ${msg}`;
133
+ }
134
+ },
135
+ });
136
+ }
137
+
138
+ export function MemoryReplace(store: MemoryStore) {
139
+ return tool({
140
+ description: "Replace a substring within a memory block.",
141
+ args: {
142
+ label: tool.schema.string(),
143
+ scope: tool.schema.enum(["global", "user", "project", "domain"]).optional(),
144
+ oldText: tool.schema.string(),
145
+ newText: tool.schema.string(),
146
+ },
147
+ async execute(args) {
148
+ // Default to "project" for mutations (safer default)
149
+ const scope = (args.scope ?? "project") as MemoryScope;
150
+ const label = scope === "user" ? toKebabCase(args.label) : args.label;
151
+ try {
152
+ await store.replaceInBlock(scope, label, args.oldText, args.newText);
153
+ return `Updated memory block ${scope}:${label}.`;
154
+ } catch (err) {
155
+ const errorMsg = getErrorMessage(store);
156
+ if (errorMsg) {
157
+ return errorMsg;
158
+ }
159
+ const msg = err instanceof Error ? err.message : "Unknown error";
160
+ return `⚠️ Failed to replace in memory block: ${msg}`;
161
+ }
162
+ },
163
+ });
164
+ }
165
+
166
+ export type JournalContext = {
167
+ directory: string;
168
+ model: string;
169
+ provider: string;
170
+ };
171
+
172
+ export function JournalWrite(
173
+ store: JournalStore,
174
+ ctx: JournalContext,
175
+ ) {
176
+ return tool({
177
+ description:
178
+ "Write a new journal entry. Use this to capture insights, technical discoveries, " +
179
+ "design decisions, observations, or reflections. Entries are append-only and cannot be edited. " +
180
+ "Tags are optional comma-separated names, e.g. \"perf, debugging\".",
181
+ args: {
182
+ title: tool.schema.string(),
183
+ body: tool.schema.string(),
184
+ tags: tool.schema.string().optional(),
185
+ },
186
+ async execute(args, toolCtx) {
187
+ const tags = args.tags
188
+ ? args.tags
189
+ .split(",")
190
+ .map((t: string) => t.trim())
191
+ .filter(Boolean)
192
+ : undefined;
193
+
194
+ const entry = await store.write({
195
+ title: args.title,
196
+ body: args.body,
197
+ project: ctx.directory,
198
+ model: ctx.model,
199
+ provider: ctx.provider,
200
+ agent: toolCtx.agent,
201
+ sessionId: toolCtx.sessionID,
202
+ tags,
203
+ });
204
+
205
+ return `Journal entry created: ${entry.id}\n title: ${entry.title}\n created: ${entry.created.toISOString()}`;
206
+ },
207
+ });
208
+ }
209
+
210
+ export function JournalRead(store: JournalStore) {
211
+ return tool({
212
+ description:
213
+ "Read a specific journal entry by its ID. Returns the full entry " +
214
+ "including metadata and body.",
215
+ args: {
216
+ id: tool.schema.string(),
217
+ },
218
+ async execute(args) {
219
+ const entry = await store.read(args.id);
220
+
221
+ const meta = [
222
+ `title: ${entry.title}`,
223
+ `created: ${entry.created.toISOString()}`,
224
+ entry.project ? `project: ${entry.project}` : null,
225
+ entry.model ? `model: ${entry.model}` : null,
226
+ entry.provider ? `provider: ${entry.provider}` : null,
227
+ entry.agent ? `agent: ${entry.agent}` : null,
228
+ entry.sessionId ? `session: ${entry.sessionId}` : null,
229
+ entry.tags.length > 0
230
+ ? `tags: ${entry.tags.join(", ")}`
231
+ : null,
232
+ ]
233
+ .filter(Boolean)
234
+ .join("\n");
235
+
236
+ return `${meta}\n\n${entry.body}`;
237
+ },
238
+ });
239
+ }
240
+
241
+ export function JournalSearch(store: JournalStore) {
242
+ return tool({
243
+ description:
244
+ "Search journal entries using semantic similarity. Returns matching entries " +
245
+ "sorted by relevance. All filters are optional and combined with AND logic. " +
246
+ "Use with no arguments to list recent entries. Use offset to paginate.",
247
+ args: {
248
+ text: tool.schema.string().optional(),
249
+ project: tool.schema.string().optional(),
250
+ tags: tool.schema.string().optional(),
251
+ limit: tool.schema.number().int().positive().optional(),
252
+ offset: tool.schema.number().int().nonnegative().optional(),
253
+ },
254
+ async execute(args) {
255
+ const tags = args.tags
256
+ ? args.tags
257
+ .split(",")
258
+ .map((t: string) => t.trim())
259
+ .filter(Boolean)
260
+ : undefined;
261
+
262
+ const result = await store.search({
263
+ text: args.text,
264
+ project: args.project,
265
+ tags,
266
+ limit: args.limit,
267
+ offset: args.offset,
268
+ });
269
+
270
+ if (result.entries.length === 0) {
271
+ const tagsLine =
272
+ result.allTags.length > 0
273
+ ? `\nTags in use: ${result.allTags.join(", ")}`
274
+ : "";
275
+ return `No journal entries found.${tagsLine}`;
276
+ }
277
+
278
+ const offset = args.offset ?? 0;
279
+ const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
280
+ const tagsLine =
281
+ result.allTags.length > 0
282
+ ? `\nTags in use: ${result.allTags.join(", ")}`
283
+ : "";
284
+
285
+ const lines = result.entries.map((e) => {
286
+ const tagStr =
287
+ e.tags.length > 0
288
+ ? ` [${e.tags.join(", ")}]`
289
+ : "";
290
+ return `${e.id}\n ${e.title}${tagStr}\n ${e.created.toISOString()}`;
291
+ });
292
+
293
+ return `${header}${tagsLine}\n\n${lines.join("\n\n")}`;
294
+ },
295
+ });
296
+ }