@miniaxolotl/grove-opencode-plugin 0.2.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 grove contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@miniaxolotl/grove-opencode-plugin",
3
+ "version": "0.2.6",
4
+ "type": "module",
5
+ "description": "OpenCode plugin for Grove - captures memories, injects hot context, manages importance tiers",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/miniaxolotl/grove.git"
13
+ },
14
+ "keywords": [
15
+ "opencode",
16
+ "grove",
17
+ "memory",
18
+ "context",
19
+ "plugin"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@opencode-ai/plugin": "~1.14.20",
26
+ "@opencode-ai/sdk": "~1.14.20",
27
+ "effect": "~3.21.2",
28
+ "zod": "~4.1.8",
29
+ "@lib/shared": "0.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@tsconfig/node22": "~22.0.2",
33
+ "@types/node": "~22.13.9",
34
+ "typescript": "~5.8.2"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "dev": "tsc --watch"
39
+ }
40
+ }
@@ -0,0 +1,70 @@
1
+ import type { GrovePluginOptions } from "../index.js";
2
+ import { MCPClient } from "../client/index.js";
3
+
4
+ export type CaptureType = "decision" | "error_resolution" | "activity_log" | "reference";
5
+
6
+ export interface CaptureInput {
7
+ type: CaptureType;
8
+ text: string;
9
+ tags?: string[];
10
+ sessionId: string;
11
+ project?: string;
12
+ importance?: number;
13
+ }
14
+
15
+ const BASE_IMPORTANCE: Record<CaptureType, number> = {
16
+ decision: 0.8,
17
+ error_resolution: 0.75,
18
+ activity_log: 0.5,
19
+ reference: 0.4,
20
+ };
21
+
22
+ function computeImportance(type: CaptureType): number {
23
+ return BASE_IMPORTANCE[type];
24
+ }
25
+
26
+ export function createCaptureHooks(
27
+ directory: string,
28
+ opts: GrovePluginOptions,
29
+ ) {
30
+ const mcp = new MCPClient({ url: opts.mcpUrl ?? "http://localhost:3100/mcp" });
31
+
32
+ async function captureMemory(input: CaptureInput): Promise<void> {
33
+ const importance = input.importance ?? computeImportance(input.type);
34
+
35
+ try {
36
+ await mcp.saveMemory(input.text, {
37
+ source: "opencode",
38
+ project: input.project,
39
+ tags: input.tags ?? [],
40
+ sessionId: input.sessionId,
41
+ importance,
42
+ });
43
+ } catch (err) {
44
+ console.error("[grove] Failed to save memory:", err);
45
+ }
46
+ }
47
+
48
+ async function onToolExecuted(
49
+ input: { tool: string; sessionID: string; args: unknown },
50
+ output: { title: string; output: string },
51
+ ): Promise<void> {
52
+ if (input.tool === "memory_save" || input.tool === "memory_note") {
53
+ return;
54
+ }
55
+
56
+ if (output.title.toLowerCase().includes("error")) {
57
+ await captureMemory({
58
+ type: "error_resolution",
59
+ text: `Tool ${input.tool} error: ${output.output}. User may have resolved it.`,
60
+ sessionId: input.sessionID,
61
+ project: opts.project,
62
+ });
63
+ }
64
+ }
65
+
66
+ return {
67
+ capture: captureMemory,
68
+ onToolExecuted,
69
+ };
70
+ }
@@ -0,0 +1,155 @@
1
+ import type { MemoryMetadata } from "@lib/shared";
2
+
3
+ export interface MCPToolResult {
4
+ content: Array<{ type: string; text: string }>;
5
+ }
6
+
7
+ export interface MemorySearchResult {
8
+ id: string;
9
+ text: string;
10
+ metadata?: MemoryMetadata;
11
+ score: number;
12
+ }
13
+
14
+ export interface MCPClientOptions {
15
+ url: string;
16
+ }
17
+
18
+ interface JSONRPCResponse {
19
+ jsonrpc: "2.0";
20
+ id: string;
21
+ result?: MCPToolResult;
22
+ error?: { code: number; message: string };
23
+ }
24
+
25
+ export class MCPClient {
26
+ private url: string;
27
+
28
+ constructor(options: MCPClientOptions) {
29
+ this.url = options.url;
30
+ }
31
+
32
+ async callTool(
33
+ toolName: string,
34
+ args: Record<string, unknown>,
35
+ ): Promise<MCPToolResult> {
36
+ const response = await fetch(this.url, {
37
+ method: "POST",
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ Accept: "application/json",
41
+ },
42
+ body: JSON.stringify({
43
+ jsonrpc: "2.0",
44
+ method: "tools/call",
45
+ params: {
46
+ name: toolName,
47
+ arguments: args,
48
+ },
49
+ id: crypto.randomUUID(),
50
+ }),
51
+ });
52
+
53
+ if (!response.ok) {
54
+ throw new Error(`MCP call failed: ${response.status} ${response.statusText}`);
55
+ }
56
+
57
+ const data = await response.json() as JSONRPCResponse;
58
+
59
+ if (data.error) {
60
+ throw new Error(`MCP error: ${data.error.message}`);
61
+ }
62
+
63
+ if (!data.result) {
64
+ throw new Error("MCP returned no result");
65
+ }
66
+
67
+ return data.result;
68
+ }
69
+
70
+ async saveMemory(
71
+ information: string,
72
+ metadata?: {
73
+ source?: string;
74
+ project?: string;
75
+ tags?: string[];
76
+ sessionId?: string;
77
+ importance?: number;
78
+ },
79
+ ): Promise<string> {
80
+ const result = await this.callTool("memory_save", {
81
+ information,
82
+ metadata,
83
+ });
84
+
85
+ const text = result.content[0]?.text ?? "";
86
+ const match = text.match(/Saved memory: (.+)/);
87
+ return match ? match[1] : text;
88
+ }
89
+
90
+ async searchMemories(options: {
91
+ query: string;
92
+ project?: string;
93
+ tags?: string[];
94
+ limit?: number;
95
+ }): Promise<MemorySearchResult[]> {
96
+ const result = await this.callTool("memory_search", {
97
+ query: options.query,
98
+ project: options.project,
99
+ tags: options.tags,
100
+ limit: options.limit ?? 10,
101
+ });
102
+
103
+ try {
104
+ return JSON.parse(result.content[0]?.text ?? "[]");
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ async compactMemories(options: {
111
+ project?: string;
112
+ sessionId?: string;
113
+ importanceThreshold?: number;
114
+ }): Promise<number> {
115
+ const result = await this.callTool("memory_compact", {
116
+ project: options.project,
117
+ sessionId: options.sessionId,
118
+ importanceThreshold: options.importanceThreshold ?? 0.6,
119
+ });
120
+
121
+ const text = result.content[0]?.text ?? "";
122
+ const match = text.match(/Compacted (\d+)/);
123
+ return match ? parseInt(match[1], 10) : 0;
124
+ }
125
+
126
+ async pruneMemories(options: {
127
+ threshold: number;
128
+ project?: string;
129
+ }): Promise<number> {
130
+ const result = await this.callTool("memory_prune", {
131
+ threshold: options.threshold,
132
+ project: options.project,
133
+ });
134
+
135
+ const text = result.content[0]?.text ?? "";
136
+ const match = text.match(/Pruned (\d+)/);
137
+ return match ? parseInt(match[1], 10) : 0;
138
+ }
139
+
140
+ async updateMemory(
141
+ id: string,
142
+ updates: {
143
+ text?: string;
144
+ metadata?: {
145
+ source?: string;
146
+ project?: string;
147
+ tags?: string[];
148
+ importance?: number;
149
+ sessionId?: string;
150
+ };
151
+ },
152
+ ): Promise<unknown> {
153
+ return this.callTool("memory_update", { id, ...updates });
154
+ }
155
+ }
@@ -0,0 +1,48 @@
1
+ import type { GrovePluginOptions } from "../index.js";
2
+ import { MCPClient } from "../client/index.js";
3
+
4
+ const HOT_THRESHOLD = 0.8;
5
+
6
+ export function createContextEngine(
7
+ _directory: string,
8
+ opts: GrovePluginOptions,
9
+ ) {
10
+ const mcp = new MCPClient({ url: opts.mcpUrl ?? "http://localhost:3100/mcp" });
11
+
12
+ async function queryHotMemories(): Promise<MemorySearchResult[]> {
13
+ try {
14
+ const memories = await mcp.searchMemories({
15
+ query: "important decision error resolution",
16
+ limit: 10,
17
+ });
18
+
19
+ return memories.filter((m) => m.score >= HOT_THRESHOLD);
20
+ } catch (err) {
21
+ console.error("[grove] Failed to query hot memories:", err);
22
+ return [];
23
+ }
24
+ }
25
+
26
+ async function onSessionCompacting(
27
+ input: { sessionID: string },
28
+ output: { context: string[] },
29
+ ): Promise<void> {
30
+ console.log(
31
+ `[grove] Session ${input.sessionID} compacting, context items: ${output.context?.length ?? "unknown"}`,
32
+ );
33
+
34
+ try {
35
+ const compacted = await mcp.compactMemories({
36
+ sessionId: input.sessionID,
37
+ importanceThreshold: opts.importanceThreshold ?? 0.6,
38
+ });
39
+ console.log(`[grove] Compacted ${compacted} memories for session ${input.sessionID}`);
40
+ } catch (err) {
41
+ console.error("[grove] Failed to compact memories:", err);
42
+ }
43
+ }
44
+
45
+ return {
46
+ onSessionCompacting,
47
+ };
48
+ }
@@ -0,0 +1,61 @@
1
+ import type { GrovePluginOptions } from "../index.js";
2
+ import { MCPClient } from "../client/index.js";
3
+
4
+ const DECAY_RATE = 0.01;
5
+
6
+ export function createImportanceEngine(opts: GrovePluginOptions) {
7
+ const mcp = new MCPClient({ url: opts.mcpUrl ?? "http://localhost:3100/mcp" });
8
+
9
+ function applyDecay(currentImportance: number, daysSinceAccess: number): number {
10
+ const decayed = currentImportance - daysSinceAccess * DECAY_RATE;
11
+ return Math.max(0.1, decayed);
12
+ }
13
+
14
+ async function decayMemories(sessionId?: string): Promise<{ decayed: number }> {
15
+ let decayed = 0;
16
+
17
+ try {
18
+ const memories = await mcp.searchMemories({
19
+ query: "",
20
+ limit: 100,
21
+ project: opts.project,
22
+ });
23
+
24
+ const now = new Date();
25
+
26
+ for (const memory of memories) {
27
+ if (memory.metadata?.importance === undefined || memory.metadata?.importance <= 0.1) {
28
+ continue;
29
+ }
30
+
31
+ const lastAccessed = memory.metadata.lastAccessedAt
32
+ ? new Date(memory.metadata.lastAccessedAt)
33
+ : new Date(memory.metadata.createdAt || now);
34
+
35
+ const daysSinceAccess = Math.floor((now.getTime() - lastAccessed.getTime()) / (1000 * 60 * 60 * 24));
36
+
37
+ if (daysSinceAccess > 0) {
38
+ const newImportance = applyDecay(memory.metadata.importance, daysSinceAccess);
39
+
40
+ try {
41
+ await mcp.updateMemory(memory.id, {
42
+ metadata: { importance: newImportance },
43
+ });
44
+ decayed++;
45
+ } catch {
46
+ // Memory may have been deleted already
47
+ }
48
+ }
49
+ }
50
+ } catch (err) {
51
+ console.error("[grove-plugin] Failed to decay importance:", err);
52
+ }
53
+
54
+ return { decayed };
55
+ }
56
+
57
+ return {
58
+ applyDecay,
59
+ decayMemories,
60
+ };
61
+ }
@@ -0,0 +1,60 @@
1
+ import type { GrovePluginOptions } from "../index.js";
2
+ import { MCPClient } from "../client/index.js";
3
+
4
+ interface StaleMemory {
5
+ id: string;
6
+ text: string;
7
+ metadata: {
8
+ lastAccessedAt?: string;
9
+ importance?: number;
10
+ sessionId?: string;
11
+ };
12
+ }
13
+
14
+ export function createStalenessEngine(opts: GrovePluginOptions) {
15
+ const mcp = new MCPClient({ url: opts.mcpUrl ?? "http://localhost:3100/mcp" });
16
+
17
+ async function findStaleMemories(daysThreshold: number): Promise<StaleMemory[]> {
18
+ const staleMemories: StaleMemory[] = [];
19
+ const cutoffDate = new Date();
20
+ cutoffDate.setDate(cutoffDate.getDate() - daysThreshold);
21
+
22
+ try {
23
+ const memories = await mcp.searchMemories({
24
+ query: "",
25
+ limit: 100,
26
+ });
27
+
28
+ for (const memory of memories) {
29
+ const lastAccessedAt = memory.metadata?.lastAccessedAt;
30
+ if (lastAccessedAt) {
31
+ const lastAccessed = new Date(lastAccessedAt);
32
+ if (lastAccessed < cutoffDate) {
33
+ staleMemories.push(memory as StaleMemory);
34
+ }
35
+ }
36
+ }
37
+ } catch (err) {
38
+ console.error("[grove-plugin] Failed to find stale memories:", err);
39
+ }
40
+
41
+ return staleMemories;
42
+ }
43
+
44
+ async function onStalenessCheck(): Promise<{ staleCount: number; message: string }> {
45
+ const staleMemories = await findStaleMemories(opts.stalenessDays ?? 7);
46
+
47
+ if (staleMemories.length === 0) {
48
+ return { staleCount: 0, message: "No stale memories found" };
49
+ }
50
+
51
+ const message = `Found ${staleMemories.length} stale memories not accessed in ${opts.stalenessDays ?? 7}+ days. Consider running memory_prune to remove them.`;
52
+
53
+ return { staleCount: staleMemories.length, message };
54
+ }
55
+
56
+ return {
57
+ findStaleMemories,
58
+ onStalenessCheck,
59
+ };
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { tool } from "@opencode-ai/plugin/tool";
3
+ import { z } from "zod";
4
+ import { createCaptureHooks } from "./capture/hooks.js";
5
+ import { createContextEngine } from "./engine/context.js";
6
+ import { createStalenessEngine } from "./engine/staleness.js";
7
+ import { createImportanceEngine } from "./engine/importance.js";
8
+ import { MCPClient } from "./client/index.js";
9
+
10
+ export interface GrovePluginOptions {
11
+ mcpUrl?: string;
12
+ project?: string;
13
+ importanceThreshold?: number;
14
+ stalenessDays?: number;
15
+ }
16
+
17
+ export const grovePlugin: Plugin = async (ctx) => {
18
+ const directory = ctx.directory || ".";
19
+
20
+ const mcpUrl = process.env.GROVE_MCP_URL || "http://localhost:3100/mcp";
21
+ const project = process.env.GROVE_PROJECT;
22
+
23
+ const opts: GrovePluginOptions = {
24
+ mcpUrl,
25
+ project,
26
+ importanceThreshold: 0.6,
27
+ stalenessDays: 7,
28
+ };
29
+
30
+ const capture = createCaptureHooks(directory, opts);
31
+ const contextEngine = createContextEngine(directory, opts);
32
+ const stalenessEngine = createStalenessEngine(opts);
33
+ const importanceEngine = createImportanceEngine(opts);
34
+ const mcp = new MCPClient({ url: mcpUrl });
35
+
36
+ return {
37
+ tool: {
38
+ grove_save_memory: tool({
39
+ description: "Save a memory to Grove semantic storage",
40
+ args: {
41
+ information: z.string().describe("The memory content to save"),
42
+ tags: z.array(z.string()).optional().describe("Tags for the memory"),
43
+ },
44
+ execute: async (args, context) => {
45
+ await capture.capture({
46
+ type: "activity_log",
47
+ text: args.information,
48
+ tags: args.tags,
49
+ sessionId: context.sessionID,
50
+ project: opts.project,
51
+ });
52
+ return `Saved memory for session ${context.sessionID}`;
53
+ },
54
+ }),
55
+ grove_check_staleness: tool({
56
+ description: "Check for stale memories that haven't been accessed in a while",
57
+ args: {},
58
+ execute: async () => {
59
+ const result = await stalenessEngine.onStalenessCheck();
60
+ return result.message;
61
+ },
62
+ }),
63
+ grove_compact_session: tool({
64
+ description: "Compact memories for a specific session",
65
+ args: {
66
+ sessionId: z.string().describe("Session ID to compact"),
67
+ },
68
+ execute: async (args) => {
69
+ const compacted = await mcp.compactMemories({
70
+ sessionId: args.sessionId,
71
+ importanceThreshold: opts.importanceThreshold,
72
+ });
73
+ return `Compacted ${compacted} memories`;
74
+ },
75
+ }),
76
+ },
77
+ async "tool.execute.after"(input: { tool: string; sessionID: string; args: unknown }, output: { title: string; output: string }) {
78
+ await capture.onToolExecuted(input, output);
79
+ },
80
+ async "experimental.session.compacting"(input: { sessionID: string }, output: { context: string[]; prompt?: string }) {
81
+ await contextEngine.onSessionCompacting(input, output);
82
+ await importanceEngine.decayMemories(input.sessionID);
83
+
84
+ const staleResult = await stalenessEngine.onStalenessCheck();
85
+ if (staleResult.staleCount > 0) {
86
+ output.context.push(`[grove] ${staleResult.message}`);
87
+ }
88
+ },
89
+ };
90
+ };
91
+
92
+ export default grovePlugin;
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@tsconfig/node22/tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"]
9
+ }