@ghilteras/opencode-agent-memory 0.5.0 → 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 +9 -5
- package/package.json +1 -1
- package/src/journal.test.ts +46 -23
- package/src/journal.ts +60 -26
- package/src/plugin-disabled.test.ts +70 -0
- package/src/plugin.test.ts +100 -37
- package/src/plugin.ts +11 -16
- package/src/tools.test.ts +253 -0
- package/src/tools.ts +30 -13
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Think of it as a searchable sidecar to `AGENTS.md`. OpenCode supports [rules](ht
|
|
|
19
19
|
- **Append-only journal** - Entries survive across sessions and context compaction
|
|
20
20
|
- **Local semantic search** - Find entries by meaning, not just keywords
|
|
21
21
|
- **Metadata on every entry** - Project, model, provider, agent, session, timestamp, tags
|
|
22
|
-
- **
|
|
22
|
+
- **Guidance in tool descriptions** - Journal usage guidance lives in the descriptions of the journal tools, so an agent sees it only when it is allowed to call them
|
|
23
23
|
- **No data leaves the machine** - Embeddings run locally
|
|
24
24
|
|
|
25
25
|
## Requirements
|
|
@@ -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
|
|
|
@@ -101,7 +104,7 @@ Tags are free-form strings - the agent can use any tag, not just the suggested o
|
|
|
101
104
|
}
|
|
102
105
|
```
|
|
103
106
|
|
|
104
|
-
Suggested
|
|
107
|
+
Suggested tag **names** (not their descriptions) appear in the `journal_write` tool description as guidance.
|
|
105
108
|
|
|
106
109
|
### cacheDir
|
|
107
110
|
|
|
@@ -126,8 +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.
|
|
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.
|
|
131
135
|
|
|
132
136
|
## Inspiration
|
|
133
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;
|
|
33
49
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
|
72
|
+
try {
|
|
73
|
+
value = JSON.parse(raw);
|
|
38
74
|
} catch {
|
|
39
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
config: {},
|
|
77
|
+
status: "malformed",
|
|
78
|
+
configPath,
|
|
79
|
+
reason: "malformed JSON",
|
|
80
|
+
};
|
|
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
|
+
};
|
|
40
91
|
}
|
|
92
|
+
|
|
93
|
+
return { config: parsed.data, status: "ok", configPath };
|
|
41
94
|
}
|
|
42
95
|
|
|
43
96
|
export type JournalTag = {
|
|
@@ -451,22 +504,3 @@ export function createJournalStore(configDir?: string, cacheDir?: string): Journ
|
|
|
451
504
|
},
|
|
452
505
|
};
|
|
453
506
|
}
|
|
454
|
-
|
|
455
|
-
export function buildJournalSystemNote(
|
|
456
|
-
tags?: readonly JournalTag[],
|
|
457
|
-
): string {
|
|
458
|
-
const tagSection =
|
|
459
|
-
tags && tags.length > 0
|
|
460
|
-
? `\n\nSuggested tags:\n${tags.map((t) => `- ${t.name}: ${t.description}`).join("\n")}`
|
|
461
|
-
: "";
|
|
462
|
-
|
|
463
|
-
return `<journal_instructions>
|
|
464
|
-
You have access to a private journal. Use it to record thoughts, discoveries, and decisions as you work.
|
|
465
|
-
Tags are free-form strings — use them to classify entries however makes sense.${tagSection}
|
|
466
|
-
|
|
467
|
-
Before starting complex tasks, search the journal for relevant past context.
|
|
468
|
-
Journal entries are append-only: you write new entries but never edit old ones.
|
|
469
|
-
Use journal_search to find past entries semantically, and journal_read to read a specific entry.
|
|
470
|
-
The journal is global across all projects but each entry records which project it was written from.
|
|
471
|
-
</journal_instructions>`;
|
|
472
|
-
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock journal module with journal DISABLED — the control path.
|
|
4
|
+
mock.module("./journal", () => ({
|
|
5
|
+
loadConfig: async () => ({
|
|
6
|
+
config: { journal: { enabled: false } },
|
|
7
|
+
status: "ok",
|
|
8
|
+
configPath: "/tmp/test-plugin-disabled/agent-memory.json",
|
|
9
|
+
}),
|
|
10
|
+
createJournalStore: () => ({}),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock embeddings to avoid model download
|
|
14
|
+
mock.module("./embeddings", () => ({
|
|
15
|
+
warmupEmbedder: async () => {},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { MemoryPlugin } from "./plugin";
|
|
19
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
20
|
+
|
|
21
|
+
const stubInput = { directory: "/tmp/test-plugin-disabled" } as unknown as PluginInput;
|
|
22
|
+
|
|
23
|
+
describe("plugin disabled path", () => {
|
|
24
|
+
test("registers zero tools when journal is disabled", async () => {
|
|
25
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
26
|
+
// tool should be undefined or an empty object
|
|
27
|
+
if (plugin.tool) {
|
|
28
|
+
expect(Object.keys(plugin.tool)).toHaveLength(0);
|
|
29
|
+
} else {
|
|
30
|
+
expect(plugin.tool).toBeUndefined();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("produces no guidance text when disabled", async () => {
|
|
35
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
36
|
+
// There should be no system transform hook injecting guidance
|
|
37
|
+
expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
|
|
38
|
+
// Tool definitions (if any exist as empty object) must not contain
|
|
39
|
+
// any description with journal guidance keywords
|
|
40
|
+
if (plugin.tool) {
|
|
41
|
+
for (const def of Object.values(plugin.tool)) {
|
|
42
|
+
const desc = (def as { description?: string }).description ?? "";
|
|
43
|
+
expect(desc).not.toMatch(/append-only|journal|search.*entry/i);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("chat.message hook is still present when disabled", async () => {
|
|
49
|
+
const plugin = await MemoryPlugin(stubInput);
|
|
50
|
+
// The chat.message hook updates model/provider context — it should
|
|
51
|
+
// still be present even when journal is disabled.
|
|
52
|
+
expect(plugin["chat.message"]).toBeDefined();
|
|
53
|
+
expect(typeof plugin["chat.message"]).toBe("function");
|
|
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
|
+
});
|
|
70
|
+
});
|
package/src/plugin.test.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import type { LoadedConfig } from "./journal";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
let loadedConfig: LoadedConfig = {
|
|
5
|
+
config: { journal: { enabled: true } },
|
|
6
|
+
status: "ok" as const,
|
|
7
|
+
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
8
|
+
};
|
|
7
9
|
|
|
10
|
+
// Mock journal module so we don't hit the real filesystem for config/journal
|
|
8
11
|
mock.module("./journal", () => ({
|
|
9
|
-
loadConfig: async () =>
|
|
10
|
-
journal: { enabled: true },
|
|
11
|
-
}),
|
|
12
|
+
loadConfig: async () => loadedConfig,
|
|
12
13
|
createJournalStore: () => ({}),
|
|
13
|
-
buildJournalSystemNote: () => JOURNAL_NOTE,
|
|
14
14
|
}));
|
|
15
15
|
|
|
16
16
|
// Mock embeddings to avoid model download
|
|
@@ -24,43 +24,106 @@ import type { PluginInput } from "@opencode-ai/plugin";
|
|
|
24
24
|
// Minimal stub: the plugin only reads `directory` from the input.
|
|
25
25
|
const stubInput = { directory: "/tmp/test-plugin-regression" } as unknown as PluginInput;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
test("journal system note is injected when enabled and no blocks present", async () => {
|
|
29
|
-
const plugin = await MemoryPlugin(stubInput);
|
|
27
|
+
type FaultStatus = Exclude<LoadedConfig["status"], "ok">;
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
];
|
|
33
44
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
];
|
|
38
61
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
62
|
+
function resetLoadedConfig(): void {
|
|
63
|
+
loadedConfig = {
|
|
64
|
+
config: { journal: { enabled: true } },
|
|
65
|
+
status: "ok",
|
|
66
|
+
configPath: "/tmp/test-plugin/agent-memory.json",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
45
69
|
|
|
46
|
-
|
|
70
|
+
describe("plugin structure", () => {
|
|
71
|
+
test("returns chat.message hook", async () => {
|
|
47
72
|
const plugin = await MemoryPlugin(stubInput);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const output = { system: [] as string[] };
|
|
51
|
-
await (transform as Function)({}, output);
|
|
52
|
-
|
|
53
|
-
const count = output.system.filter((s) => s === JOURNAL_NOTE).length;
|
|
54
|
-
expect(count).toBe(1);
|
|
73
|
+
expect(plugin["chat.message"]).toBeDefined();
|
|
74
|
+
expect(typeof plugin["chat.message"]).toBe("function");
|
|
55
75
|
});
|
|
56
76
|
|
|
57
|
-
test("
|
|
77
|
+
test("does NOT return experimental.chat.system.transform hook", async () => {
|
|
58
78
|
const plugin = await MemoryPlugin(stubInput);
|
|
79
|
+
expect(plugin["experimental.chat.system.transform"]).toBeUndefined();
|
|
80
|
+
});
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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);
|
|
63
93
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|
|
66
129
|
});
|
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
buildJournalSystemNote,
|
|
5
4
|
createJournalStore,
|
|
6
5
|
loadConfig,
|
|
7
6
|
} from "./journal";
|
|
@@ -15,8 +14,16 @@ import { warmupEmbedder } from "./embeddings";
|
|
|
15
14
|
|
|
16
15
|
export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
17
16
|
// Journal: opt-in via ~/.config/opencode/agent-memory.json
|
|
18
|
-
const
|
|
19
|
-
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;
|
|
20
27
|
|
|
21
28
|
// Mutable state updated by chat.message hook
|
|
22
29
|
const journalCtx: JournalContext = {
|
|
@@ -26,7 +33,6 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
26
33
|
};
|
|
27
34
|
|
|
28
35
|
let journalTools: Record<string, ToolDefinition> = {};
|
|
29
|
-
let journalSystemNote = "";
|
|
30
36
|
|
|
31
37
|
if (journalEnabled) {
|
|
32
38
|
const journalStore = createJournalStore(undefined, config.cacheDir);
|
|
@@ -34,11 +40,10 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
34
40
|
// first-init-wins singleton caches to the correct directory.
|
|
35
41
|
void warmupEmbedder(config.cacheDir).catch(() => {});
|
|
36
42
|
journalTools = {
|
|
37
|
-
journal_write: JournalWrite(journalStore, journalCtx),
|
|
43
|
+
journal_write: JournalWrite(journalStore, journalCtx, config.journal?.tags),
|
|
38
44
|
journal_read: JournalRead(journalStore),
|
|
39
45
|
journal_search: JournalSearch(journalStore),
|
|
40
46
|
};
|
|
41
|
-
journalSystemNote = buildJournalSystemNote(config.journal?.tags);
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
return {
|
|
@@ -49,16 +54,6 @@ export const MemoryPlugin: Plugin = async ({ directory }) => {
|
|
|
49
54
|
}
|
|
50
55
|
},
|
|
51
56
|
|
|
52
|
-
"experimental.chat.system.transform": async (_input, output) => {
|
|
53
|
-
// Inject journal instructions unconditionally when enabled.
|
|
54
|
-
// Previous versions returned early when no memory blocks were present,
|
|
55
|
-
// which also skipped the journal note — a regression-class bug that
|
|
56
|
-
// silently disabled the journal entirely once blocks were removed.
|
|
57
|
-
if (journalSystemNote) {
|
|
58
|
-
output.system.push(journalSystemNote);
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
|
|
62
57
|
tool: {
|
|
63
58
|
...journalTools,
|
|
64
59
|
},
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { JournalRead, JournalSearch, JournalWrite } from "./tools";
|
|
4
|
+
import type { JournalContext } from "./tools";
|
|
5
|
+
import type { JournalEntry, JournalStore } from "./journal";
|
|
6
|
+
|
|
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
|
+
};
|
|
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
|
+
|
|
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");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
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
|
+
}
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
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);
|
|
245
|
+
});
|
|
246
|
+
|
|
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);
|
|
252
|
+
});
|
|
253
|
+
});
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
2
|
|
|
3
|
-
import type { JournalStore } from "./journal";
|
|
3
|
+
import type { JournalStore, JournalTag } from "./journal";
|
|
4
4
|
|
|
5
5
|
export type JournalContext = {
|
|
6
6
|
directory: string;
|
|
@@ -8,15 +8,37 @@ 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,
|
|
29
|
+
tags?: readonly JournalTag[],
|
|
14
30
|
) {
|
|
31
|
+
const suggestedTags =
|
|
32
|
+
tags && tags.length > 0
|
|
33
|
+
? ` Suggested tags: ${tags.map((t) => t.name).join(", ")}.`
|
|
34
|
+
: "";
|
|
15
35
|
return tool({
|
|
16
36
|
description:
|
|
17
|
-
"Write a new journal entry. Use this to capture insights, technical discoveries, " +
|
|
18
|
-
"design decisions, observations, or reflections.
|
|
19
|
-
"
|
|
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,
|
|
20
42
|
args: {
|
|
21
43
|
title: tool.schema.string(),
|
|
22
44
|
body: tool.schema.string(),
|
|
@@ -82,7 +104,9 @@ export function JournalSearch(store: JournalStore) {
|
|
|
82
104
|
description:
|
|
83
105
|
"Search journal entries using semantic similarity. Returns matching entries " +
|
|
84
106
|
"sorted by relevance. All filters are optional and combined with AND logic. " +
|
|
85
|
-
"Use with no arguments to list recent entries. Use offset to paginate."
|
|
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.",
|
|
86
110
|
args: {
|
|
87
111
|
text: tool.schema.string().optional(),
|
|
88
112
|
project: tool.schema.string().optional(),
|
|
@@ -106,20 +130,13 @@ export function JournalSearch(store: JournalStore) {
|
|
|
106
130
|
offset: args.offset,
|
|
107
131
|
});
|
|
108
132
|
|
|
133
|
+
const tagsLine = formatTagsInUse(result.allTags);
|
|
109
134
|
if (result.entries.length === 0) {
|
|
110
|
-
const tagsLine =
|
|
111
|
-
result.allTags.length > 0
|
|
112
|
-
? `\nTags in use: ${result.allTags.join(", ")}`
|
|
113
|
-
: "";
|
|
114
135
|
return `No journal entries found.${tagsLine}`;
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
const offset = args.offset ?? 0;
|
|
118
139
|
const header = `Found ${result.total} entries (showing ${offset + 1}–${offset + result.entries.length}):`;
|
|
119
|
-
const tagsLine =
|
|
120
|
-
result.allTags.length > 0
|
|
121
|
-
? `\nTags in use: ${result.allTags.join(", ")}`
|
|
122
|
-
: "";
|
|
123
140
|
|
|
124
141
|
const lines = result.entries.map((e) => {
|
|
125
142
|
const tagStr =
|