@ghilteras/opencode-agent-memory 0.5.1 → 0.5.2
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 +7 -4
- package/package.json +1 -1
- package/src/journal.test.ts +46 -23
- package/src/journal.ts +60 -8
- package/src/plugin-disabled.test.ts +18 -1
- package/src/plugin.test.ts +98 -10
- package/src/plugin.ts +10 -2
- package/src/tools.test.ts +88 -2
- package/src/tools.ts +16 -8
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
35
|
-
"plugin": ["@ghilteras/opencode-agent-memory@0.5.
|
|
35
|
+
"plugin": ["@ghilteras/opencode-agent-memory@0.5.2"]
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
@@ -50,7 +50,10 @@ The journal is **opt-in**. Enable it in `~/.config/opencode/agent-memory.json`:
|
|
|
50
50
|
}
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
The plugin does **not** regenerate `agent-memory.json` if it is deleted; its loss otherwise silently disables the journal, which is why the fault warning exists.
|
|
54
|
+
|
|
55
|
+
- Intentional disable (`"enabled": false`) is silent by design and registers **no tools**.
|
|
56
|
+
- A missing, unreadable, malformed, or schema-invalid config logs `[agent-memory] journal config <resolved path>: <reason>; journal tools will NOT be registered` and registers **no tools**.
|
|
54
57
|
|
|
55
58
|
### Tools
|
|
56
59
|
|
|
@@ -126,9 +129,9 @@ Existing `~/.config/opencode/memory/` and `.opencode/memory/` directories are **
|
|
|
126
129
|
|
|
127
130
|
- **Requires OpenCode v1.0.115+.**
|
|
128
131
|
- **Restart after config changes.** Plugin config changes require a full OpenCode restart to take effect; editing the config file alone is not sufficient.
|
|
129
|
-
- **Journal is opt-in.** You must explicitly enable it in `~/.config/opencode/agent-memory.json`;
|
|
132
|
+
- **Journal is opt-in.** You must explicitly enable it in `~/.config/opencode/agent-memory.json`; intentional disablement is silent, while a missing, unreadable, malformed, or schema-invalid config logs a warning and registers no tools.
|
|
130
133
|
- **Local embeddings, no data leaves the machine.** Semantic search uses a locally cached transformers.js model; no external API calls are made for search or embedding.
|
|
131
|
-
- **Known limitation (0.5.1): guidance-in-descriptions depends on an unverified premise.** 0.5.1 moved journal guidance from the injected system-prompt note into the three journal tool descriptions, so only agents allowed to call those tools should see it. That relies on opencode omitting a permission-denied tool from the definitions sent to the model. This is **unverified**: a `tool.definition` probe on the isolated instance fires before permission filtering, so it cannot confirm what the model actually receives. If the premise does not hold, an agent denied `journal_*` may still read the description text; `deny` still blocks execution, so there is no capability escalation and no data disclosure — the blast radius is the three tools' description strings.
|
|
134
|
+
- **Known limitation (since 0.5.1): guidance-in-descriptions depends on an unverified premise.** 0.5.1 moved journal guidance from the injected system-prompt note into the three journal tool descriptions, so only agents allowed to call those tools should see it. That relies on opencode omitting a permission-denied tool from the definitions sent to the model. This is **unverified**: a `tool.definition` probe on the isolated instance fires before permission filtering, so it cannot confirm what the model actually receives. If the premise does not hold, an agent denied `journal_*` may still read the description text; `deny` still blocks execution, so there is no capability escalation and no data disclosure — the blast radius is the three tools' description strings.
|
|
132
135
|
|
|
133
136
|
## Inspiration
|
|
134
137
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ghilteras/opencode-agent-memory",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Journal-only memory plugin for OpenCode: tagged, searchable, append-only journal with semantic search.",
|
|
5
5
|
"author": "Angelo Pantano <ghilteras@protonmail.com>",
|
|
6
6
|
"license": "MIT",
|
package/src/journal.test.ts
CHANGED
|
@@ -41,27 +41,60 @@ async function mkTmpDir(): Promise<string> {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe("loadConfig", () => {
|
|
44
|
-
test("
|
|
44
|
+
test("classifies a missing file and returns an empty config", async () => {
|
|
45
45
|
const dir = await mkTmpDir();
|
|
46
|
-
const
|
|
47
|
-
expect(
|
|
46
|
+
const loaded = await loadConfig(dir);
|
|
47
|
+
expect(loaded.status).toBe("missing");
|
|
48
|
+
expect(loaded.config).toEqual({});
|
|
48
49
|
});
|
|
49
50
|
|
|
50
|
-
test("
|
|
51
|
+
test("classifies an unreadable file and returns an empty config", async () => {
|
|
52
|
+
const dir = await mkTmpDir();
|
|
53
|
+
await fs.mkdir(path.join(dir, "agent-memory.json"));
|
|
54
|
+
const loaded = await loadConfig(dir);
|
|
55
|
+
expect(loaded.status).toBe("unreadable");
|
|
56
|
+
expect(loaded.config).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("classifies malformed JSON and returns an empty config", async () => {
|
|
60
|
+
const dir = await mkTmpDir();
|
|
61
|
+
await fs.writeFile(path.join(dir, "agent-memory.json"), "not json{{{");
|
|
62
|
+
const loaded = await loadConfig(dir);
|
|
63
|
+
expect(loaded.status).toBe("malformed");
|
|
64
|
+
expect(loaded.config).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("classifies schema-invalid JSON and returns an empty config", async () => {
|
|
51
68
|
const dir = await mkTmpDir();
|
|
52
69
|
await fs.writeFile(
|
|
53
70
|
path.join(dir, "agent-memory.json"),
|
|
54
|
-
JSON.stringify({ journal: { enabled:
|
|
71
|
+
JSON.stringify({ journal: { enabled: "yes" } }),
|
|
55
72
|
);
|
|
56
|
-
const
|
|
57
|
-
expect(
|
|
73
|
+
const loaded = await loadConfig(dir);
|
|
74
|
+
expect(loaded.status).toBe("invalid");
|
|
75
|
+
expect(loaded.config).toEqual({});
|
|
58
76
|
});
|
|
59
77
|
|
|
60
|
-
test("
|
|
78
|
+
test("classifies valid disabled config without changing it", async () => {
|
|
61
79
|
const dir = await mkTmpDir();
|
|
62
|
-
await fs.writeFile(
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
await fs.writeFile(
|
|
81
|
+
path.join(dir, "agent-memory.json"),
|
|
82
|
+
JSON.stringify({ journal: { enabled: false } }),
|
|
83
|
+
);
|
|
84
|
+
const loaded = await loadConfig(dir);
|
|
85
|
+
expect(loaded.status).toBe("ok");
|
|
86
|
+
expect(loaded.config).toEqual({ journal: { enabled: false } });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("classifies valid enabled config without changing it", async () => {
|
|
90
|
+
const dir = await mkTmpDir();
|
|
91
|
+
await fs.writeFile(
|
|
92
|
+
path.join(dir, "agent-memory.json"),
|
|
93
|
+
JSON.stringify({ journal: { enabled: true } }),
|
|
94
|
+
);
|
|
95
|
+
const loaded = await loadConfig(dir);
|
|
96
|
+
expect(loaded.status).toBe("ok");
|
|
97
|
+
expect(loaded.config).toEqual({ journal: { enabled: true } });
|
|
65
98
|
});
|
|
66
99
|
|
|
67
100
|
test("returns custom tags from config", async () => {
|
|
@@ -78,22 +111,12 @@ describe("loadConfig", () => {
|
|
|
78
111
|
},
|
|
79
112
|
}),
|
|
80
113
|
);
|
|
81
|
-
const
|
|
82
|
-
expect(config.journal?.tags).toEqual([
|
|
114
|
+
const loaded = await loadConfig(dir);
|
|
115
|
+
expect(loaded.config.journal?.tags).toEqual([
|
|
83
116
|
{ name: "perf", description: "Performance optimization work" },
|
|
84
117
|
{ name: "debug", description: "Debugging sessions" },
|
|
85
118
|
]);
|
|
86
119
|
});
|
|
87
|
-
|
|
88
|
-
test("returns empty config when schema validation fails", async () => {
|
|
89
|
-
const dir = await mkTmpDir();
|
|
90
|
-
await fs.writeFile(
|
|
91
|
-
path.join(dir, "agent-memory.json"),
|
|
92
|
-
JSON.stringify({ journal: { enabled: "yes" } }),
|
|
93
|
-
);
|
|
94
|
-
const config = await loadConfig(dir);
|
|
95
|
-
expect(config).toEqual({});
|
|
96
|
-
});
|
|
97
120
|
});
|
|
98
121
|
|
|
99
122
|
describe("journal store", () => {
|
package/src/journal.ts
CHANGED
|
@@ -25,19 +25,72 @@ const ConfigSchema = z.looseObject({
|
|
|
25
25
|
|
|
26
26
|
export type AgentMemoryConfig = z.infer<typeof ConfigSchema>;
|
|
27
27
|
|
|
28
|
+
export type ConfigLoadStatus =
|
|
29
|
+
| "ok"
|
|
30
|
+
| "missing"
|
|
31
|
+
| "unreadable"
|
|
32
|
+
| "malformed"
|
|
33
|
+
| "invalid";
|
|
34
|
+
|
|
35
|
+
export type LoadedConfig = {
|
|
36
|
+
config: AgentMemoryConfig;
|
|
37
|
+
status: ConfigLoadStatus;
|
|
38
|
+
configPath: string;
|
|
39
|
+
reason?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
28
42
|
export async function loadConfig(
|
|
29
43
|
configDir?: string,
|
|
30
|
-
): Promise<
|
|
44
|
+
): Promise<LoadedConfig> {
|
|
31
45
|
const dir = configDir ?? path.join(os.homedir(), ".config", "opencode");
|
|
32
|
-
const configPath = path.
|
|
46
|
+
const configPath = path.resolve(dir, "agent-memory.json");
|
|
47
|
+
|
|
48
|
+
let raw: string;
|
|
49
|
+
try {
|
|
50
|
+
raw = await fs.readFile(configPath, "utf-8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const code = error instanceof Error && "code" in error
|
|
53
|
+
? String(error.code)
|
|
54
|
+
: "unknown error";
|
|
55
|
+
if (code === "ENOENT") {
|
|
56
|
+
return {
|
|
57
|
+
config: {},
|
|
58
|
+
status: "missing",
|
|
59
|
+
configPath,
|
|
60
|
+
reason: "file is missing",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
config: {},
|
|
65
|
+
status: "unreadable",
|
|
66
|
+
configPath,
|
|
67
|
+
reason: `read failed (${code})`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let value: unknown;
|
|
33
72
|
try {
|
|
34
|
-
|
|
35
|
-
const parsed = ConfigSchema.safeParse(JSON.parse(raw));
|
|
36
|
-
if (!parsed.success) return {};
|
|
37
|
-
return parsed.data;
|
|
73
|
+
value = JSON.parse(raw);
|
|
38
74
|
} catch {
|
|
39
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
config: {},
|
|
77
|
+
status: "malformed",
|
|
78
|
+
configPath,
|
|
79
|
+
reason: "malformed JSON",
|
|
80
|
+
};
|
|
40
81
|
}
|
|
82
|
+
|
|
83
|
+
const parsed = ConfigSchema.safeParse(value);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
return {
|
|
86
|
+
config: {},
|
|
87
|
+
status: "invalid",
|
|
88
|
+
configPath,
|
|
89
|
+
reason: "schema validation failed",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { config: parsed.data, status: "ok", configPath };
|
|
41
94
|
}
|
|
42
95
|
|
|
43
96
|
export type JournalTag = {
|
|
@@ -451,4 +504,3 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
451
504
|
},
|
|
452
505
|
};
|
|
453
506
|
}
|
|
454
|
-
|
|
@@ -3,7 +3,9 @@ import { describe, expect, mock, test } from "bun:test";
|
|
|
3
3
|
// Mock journal module with journal DISABLED — the control path.
|
|
4
4
|
mock.module("./journal", () => ({
|
|
5
5
|
loadConfig: async () => ({
|
|
6
|
-
journal: { enabled: false },
|
|
6
|
+
config: { journal: { enabled: false } },
|
|
7
|
+
status: "ok",
|
|
8
|
+
configPath: "/tmp/test-plugin-disabled/agent-memory.json",
|
|
7
9
|
}),
|
|
8
10
|
createJournalStore: () => ({}),
|
|
9
11
|
}));
|
|
@@ -50,4 +52,19 @@ describe("plugin disabled path", () => {
|
|
|
50
52
|
expect(plugin["chat.message"]).toBeDefined();
|
|
51
53
|
expect(typeof plugin["chat.message"]).toBe("function");
|
|
52
54
|
});
|
|
55
|
+
|
|
56
|
+
test("does not warn for an intentional disabled configuration", async () => {
|
|
57
|
+
const originalWarn = console.warn;
|
|
58
|
+
let warningCount = 0;
|
|
59
|
+
console.warn = () => {
|
|
60
|
+
warningCount += 1;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await MemoryPlugin(stubInput);
|
|
65
|
+
expect(warningCount).toBe(0);
|
|
66
|
+
} finally {
|
|
67
|
+
console.warn = originalWarn;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
53
70
|
});
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { LoadedConfig } from "./journal";
|
|
3
|
+
|
|
4
|
+
let loadedConfig: LoadedConfig = {
|
|
5
|
+
config: { journal: { enabled: true } },
|
|
6
|
+
status: "ok" as const,
|
|
7
|
+
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
8
|
+
};
|
|
2
9
|
|
|
3
10
|
// Mock journal module so we don't hit the real filesystem for config/journal
|
|
4
11
|
mock.module("./journal", () => ({
|
|
5
|
-
loadConfig: async () =>
|
|
6
|
-
journal: { enabled: true },
|
|
7
|
-
}),
|
|
12
|
+
loadConfig: async () => loadedConfig,
|
|
8
13
|
createJournalStore: () => ({}),
|
|
9
14
|
}));
|
|
10
15
|
|
|
@@ -19,6 +24,49 @@ import type { PluginInput } from "@opencode-ai/plugin";
|
|
|
19
24
|
// Minimal stub: the plugin only reads `directory` from the input.
|
|
20
25
|
const stubInput = { directory: "/tmp/test-plugin-regression" } as unknown as PluginInput;
|
|
21
26
|
|
|
27
|
+
type FaultStatus = Exclude<LoadedConfig["status"], "ok">;
|
|
28
|
+
|
|
29
|
+
const faultCases: Array<{
|
|
30
|
+
status: FaultStatus;
|
|
31
|
+
reason: string;
|
|
32
|
+
config: LoadedConfig["config"];
|
|
33
|
+
}> = [
|
|
34
|
+
{ status: "missing", reason: "file is missing", config: {} },
|
|
35
|
+
{ status: "unreadable", reason: "read failed (EACCES)", config: {} },
|
|
36
|
+
{ status: "malformed", reason: "malformed JSON", config: {} },
|
|
37
|
+
{
|
|
38
|
+
// A non-ok status must win even if the mocked config is truthy.
|
|
39
|
+
status: "invalid",
|
|
40
|
+
reason: "schema validation failed",
|
|
41
|
+
config: { journal: { enabled: true } },
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const okCases: Array<{
|
|
46
|
+
name: string;
|
|
47
|
+
config: LoadedConfig["config"];
|
|
48
|
+
expectedToolCount: number;
|
|
49
|
+
}> = [
|
|
50
|
+
{
|
|
51
|
+
name: "enabled",
|
|
52
|
+
config: { journal: { enabled: true } },
|
|
53
|
+
expectedToolCount: 3,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "disabled",
|
|
57
|
+
config: { journal: { enabled: false } },
|
|
58
|
+
expectedToolCount: 0,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
function resetLoadedConfig(): void {
|
|
63
|
+
loadedConfig = {
|
|
64
|
+
config: { journal: { enabled: true } },
|
|
65
|
+
status: "ok",
|
|
66
|
+
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
22
70
|
describe("plugin structure", () => {
|
|
23
71
|
test("returns chat.message hook", async () => {
|
|
24
72
|
const plugin = await MemoryPlugin(stubInput);
|
|
@@ -31,11 +79,51 @@ describe("plugin structure", () => {
|
|
|
31
79
|
expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
|
|
32
80
|
});
|
|
33
81
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
82
|
+
for (const testCase of faultCases) {
|
|
83
|
+
test(`warns and disables tools for ${testCase.status} config`, async () => {
|
|
84
|
+
loadedConfig = {
|
|
85
|
+
config: testCase.config,
|
|
86
|
+
status: testCase.status,
|
|
87
|
+
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
88
|
+
reason: testCase.reason,
|
|
89
|
+
};
|
|
90
|
+
const originalWarn = console.warn;
|
|
91
|
+
const warnings: unknown[][] = [];
|
|
92
|
+
console.warn = (...args: unknown[]) => warnings.push(args);
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
96
|
+
expect(Object.keys(plugin.tool ?? {})).toHaveLength(0);
|
|
97
|
+
expect(warnings).toHaveLength(1);
|
|
98
|
+
expect(warnings[0]?.[0]).toBe(
|
|
99
|
+
`[agent-memory] journal config /tmp/test-plugin/agent-memory.json: ${testCase.reason}; journal tools will NOT be registered`,
|
|
100
|
+
);
|
|
101
|
+
} finally {
|
|
102
|
+
console.warn = originalWarn;
|
|
103
|
+
resetLoadedConfig();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const testCase of okCases) {
|
|
109
|
+
test(`does not warn for valid ${testCase.name} config`, async () => {
|
|
110
|
+
loadedConfig = {
|
|
111
|
+
config: testCase.config,
|
|
112
|
+
status: "ok",
|
|
113
|
+
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
114
|
+
};
|
|
115
|
+
const originalWarn = console.warn;
|
|
116
|
+
const warnings: unknown[][] = [];
|
|
117
|
+
console.warn = (...args: unknown[]) => warnings.push(args);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
121
|
+
expect(Object.keys(plugin.tool ?? {})).toHaveLength(testCase.expectedToolCount);
|
|
122
|
+
expect(warnings).toHaveLength(0);
|
|
123
|
+
} finally {
|
|
124
|
+
console.warn = originalWarn;
|
|
125
|
+
resetLoadedConfig();
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
41
129
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -14,8 +14,16 @@ import { warmupEmbedder } from "./embeddings";
|
|
|
14
14
|
|
|
15
15
|
export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
16
16
|
// Journal: opt-in via ~/.config/opencode/agent-memory.json
|
|
17
|
-
const
|
|
18
|
-
const
|
|
17
|
+
const loadedConfig = await loadConfig();
|
|
18
|
+
const { config } = loadedConfig;
|
|
19
|
+
|
|
20
|
+
if (loadedConfig.status !== "ok") {
|
|
21
|
+
console.warn(
|
|
22
|
+
`[agent-memory] journal config ${loadedConfig.configPath}: ${loadedConfig.reason ?? loadedConfig.status}; journal tools will NOT be registered`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const journalEnabled = loadedConfig.status === "ok" && config.journal?.enabled === true;
|
|
19
27
|
|
|
20
28
|
// Mutable state updated by chat.message hook
|
|
21
29
|
const journalCtx: JournalContext = {
|
package/src/tools.test.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
|
|
3
3
|
import { JournalRead, JournalSearch, JournalWrite } from "./tools";
|
|
4
4
|
import type { JournalContext } from "./tools";
|
|
5
|
-
import type {
|
|
5
|
+
import type { JournalEntry, JournalStore } from "./journal";
|
|
6
6
|
|
|
7
|
-
// Minimal mock store — tests
|
|
7
|
+
// Minimal mock store — description tests use the default stubs, while search
|
|
8
|
+
// output tests replace search with deterministic fixtures.
|
|
8
9
|
const mockStore: JournalStore = {
|
|
9
10
|
write: async () => {
|
|
10
11
|
throw new Error("not implemented");
|
|
@@ -38,6 +39,31 @@ const READ_DESC = JournalRead(mockStore).description;
|
|
|
38
39
|
const SEARCH_TOOL = JournalSearch(mockStore);
|
|
39
40
|
const SEARCH_DESC = SEARCH_TOOL.description;
|
|
40
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
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
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);
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
describe("journal_write description", () => {
|
|
42
68
|
test("mentions append-only semantics", () => {
|
|
43
69
|
expect(WRITE_DESC_NO_TAGS).toContain("append-only");
|
|
@@ -130,6 +156,66 @@ describe("journal_search description", () => {
|
|
|
130
156
|
});
|
|
131
157
|
});
|
|
132
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");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
133
219
|
// ─── Cross-tool invariants ───────────────────────────────────────────────────
|
|
134
220
|
|
|
135
221
|
describe("cross-tool invariants", () => {
|
package/src/tools.ts
CHANGED
|
@@ -8,6 +8,21 @@ export type JournalContext = {
|
|
|
8
8
|
provider: string;
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
+
const MAX_TAGS_TO_LIST = 20;
|
|
12
|
+
|
|
13
|
+
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
|
+
}
|
|
23
|
+
return `\nTags in use: ${allTags.join(", ")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
export function JournalWrite(
|
|
12
27
|
store: JournalStore,
|
|
13
28
|
ctx: JournalContext,
|
|
@@ -115,20 +130,13 @@ export function JournalSearch(store: JournalStore) {
|
|
|
115
130
|
offset: args.offset,
|
|
116
131
|
});
|
|
117
132
|
|
|
133
|
+
const tagsLine = formatTagsInUse(result.allTags);
|
|
118
134
|
if (result.entries.length === 0) {
|
|
119
|
-
const tagsLine =
|
|
120
|
-
result.allTags.length > 0
|
|
121
|
-
? `\nTags in use: ${result.allTags.join(", ")}`
|
|
122
|
-
: "";
|
|
123
135
|
return `No journal entries found.${tagsLine}`;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
const offset = args.offset ?? 0;
|
|
127
139
|
const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
|
|
128
|
-
const tagsLine =
|
|
129
|
-
result.allTags.length > 0
|
|
130
|
-
? `\nTags in use: ${result.allTags.join(", ")}`
|
|
131
|
-
: "";
|
|
132
140
|
|
|
133
141
|
const lines = result.entries.map((e) => {
|
|
134
142
|
const tagStr =
|