@devtheops/opencode-plugin-mempalace 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -14
- package/package.json +30 -27
- package/skills/mempalace/SKILL.md +2 -4
- package/src/config.ts +8 -5
- package/src/index.ts +113 -42
- package/src/mempalace.ts +113 -0
- package/src/mining.ts +158 -0
- package/src/session.ts +77 -0
package/README.md
CHANGED
|
@@ -2,24 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@devtheops/opencode-plugin-mempalace)
|
|
4
4
|
[](https://www.npmjs.com/package/@devtheops/opencode-plugin-mempalace)
|
|
5
|
-
[](https://github.com/DEVtheOPS/opencode-mempalace/stargazers)
|
|
6
|
-
[](https://github.com/DEVtheOPS/opencode-mempalace/actions/workflows/release-please.yml)
|
|
7
|
-
[](https://github.com/DEVtheOPS/opencode-mempalace/blob/main/LICENSE)
|
|
5
|
+
[](https://github.com/DEVtheOPS/opencode-plugin-mempalace/stargazers)
|
|
6
|
+
[](https://github.com/DEVtheOPS/opencode-plugin-mempalace/actions/workflows/release-please.yml)
|
|
7
|
+
[](https://github.com/DEVtheOPS/opencode-plugin-mempalace/blob/main/LICENSE)
|
|
8
8
|
|
|
9
9
|
An [OpenCode](https://opencode.ai) server plugin that integrates [MemPalace](https://github.com/MemPalace/mempalace) without vendoring the MemPalace application code.
|
|
10
10
|
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
- [
|
|
11
|
+
- [opencode-plugin-mempalace](#opencode-plugin-mempalace)
|
|
12
|
+
- [Installation](#installation)
|
|
13
|
+
- [Requirements](#requirements)
|
|
14
|
+
- [What It Adds](#what-it-adds)
|
|
15
|
+
- [Runtime Behavior](#runtime-behavior)
|
|
16
|
+
- [Development](#development)
|
|
16
17
|
|
|
17
18
|
The plugin:
|
|
18
19
|
|
|
19
|
-
-
|
|
20
|
+
- requires an existing `mempalace` installation and logs startup diagnostics if it is missing
|
|
20
21
|
- registers a local `mempalace` MCP server
|
|
21
22
|
- injects MemPalace slash commands into OpenCode
|
|
22
23
|
- injects a bundled `mempalace` skill into OpenCode
|
|
24
|
+
- automatically mines OpenCode session transcripts into MemPalace conversation memory
|
|
25
|
+
- can inject `mempalace wake-up` memory into the system prompt and compaction context
|
|
23
26
|
|
|
24
27
|
## Installation
|
|
25
28
|
|
|
@@ -32,6 +35,47 @@ Add the plugin to your OpenCode config:
|
|
|
32
35
|
}
|
|
33
36
|
```
|
|
34
37
|
|
|
38
|
+
You can configure automatic conversation mining with a per-session message threshold:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"$schema": "https://opencode.ai/config.json",
|
|
43
|
+
"plugin": [["@devtheops/opencode-plugin-mempalace", { "threshold": 30 }]]
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Full plugin options:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"$schema": "https://opencode.ai/config.json",
|
|
52
|
+
"plugin": [["@devtheops/opencode-plugin-mempalace", {
|
|
53
|
+
"threshold": 15,
|
|
54
|
+
"autoMine": true,
|
|
55
|
+
"injectWakeUp": true,
|
|
56
|
+
"injectOnCompaction": true,
|
|
57
|
+
"maxWakeUpChars": 4000,
|
|
58
|
+
"flushOnIdle": true,
|
|
59
|
+
"flushOnExit": true
|
|
60
|
+
}]]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`threshold` rules:
|
|
65
|
+
|
|
66
|
+
- default: `15`
|
|
67
|
+
- `0`: disable threshold-triggered mining and only flush on idle, delete, and compaction
|
|
68
|
+
- invalid or negative values fall back to `15`
|
|
69
|
+
|
|
70
|
+
Other options:
|
|
71
|
+
|
|
72
|
+
- `autoMine`: enable or disable automatic conversation mining entirely
|
|
73
|
+
- `injectWakeUp`: inject `mempalace wake-up` output into the system prompt
|
|
74
|
+
- `injectOnCompaction`: inject `mempalace wake-up` output into compaction context
|
|
75
|
+
- `maxWakeUpChars`: truncate injected wake-up memory to this many characters
|
|
76
|
+
- `flushOnIdle`: flush dirty sessions when OpenCode marks them idle or deleted
|
|
77
|
+
- `flushOnExit`: register graceful process-exit hooks
|
|
78
|
+
|
|
35
79
|
For local development you can point OpenCode directly at this checkout:
|
|
36
80
|
|
|
37
81
|
```json
|
|
@@ -45,7 +89,7 @@ For local development you can point OpenCode directly at this checkout:
|
|
|
45
89
|
|
|
46
90
|
- OpenCode
|
|
47
91
|
- Python 3.9+
|
|
48
|
-
- `
|
|
92
|
+
- MemPalace installed already, either as `mempalace` on `PATH` or as a Python module importable by `python3` or `python`
|
|
49
93
|
|
|
50
94
|
## What It Adds
|
|
51
95
|
|
|
@@ -66,14 +110,17 @@ If a `mempalace` MCP server is already configured, the plugin leaves it alone.
|
|
|
66
110
|
|
|
67
111
|
## Runtime Behavior
|
|
68
112
|
|
|
69
|
-
When OpenCode loads the plugin, it checks for `
|
|
70
|
-
|
|
113
|
+
When OpenCode loads the plugin, it checks for the `mempalace` CLI first, then falls back to verifying whether the `mempalace` package is importable through `python3` or `python`.
|
|
114
|
+
|
|
115
|
+
If MemPalace is missing, the plugin does not install it automatically. Instead it logs explicit warnings so MCP startup failures are easier to diagnose.
|
|
116
|
+
|
|
117
|
+
The plugin also exports OpenCode session transcripts through the OpenCode client API and mines them with:
|
|
71
118
|
|
|
72
119
|
```bash
|
|
73
|
-
|
|
120
|
+
mempalace mine <transcript-file> --mode convos
|
|
74
121
|
```
|
|
75
122
|
|
|
76
|
-
|
|
123
|
+
Threshold-based mining is configurable through the plugin `threshold` option.
|
|
77
124
|
|
|
78
125
|
## Development
|
|
79
126
|
|
package/package.json
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "@devtheops/opencode-plugin-mempalace",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "MemPalace plugin for OpenCode. Installs the Python package, registers the MCP server, and injects commands and a bundled skill.",
|
|
5
|
-
"type": "module",
|
|
6
2
|
"author": "DEVtheOPS",
|
|
7
|
-
"license": "MIT",
|
|
8
|
-
"homepage": "https://github.com/DEVtheOPS/opencode-mempalace#readme",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "https://github.com/DEVtheOPS/opencode-mempalace.git"
|
|
12
|
-
},
|
|
13
3
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/DEVtheOPS/opencode-mempalace/issues"
|
|
4
|
+
"url": "https://github.com/DEVtheOPS/opencode-plugin-mempalace/issues"
|
|
5
|
+
},
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@opencode-ai/plugin": "^1.2.23",
|
|
8
|
+
"@opencode-ai/sdk": "^1.2.23",
|
|
9
|
+
"typescript": "^5.9.3"
|
|
15
10
|
},
|
|
11
|
+
"description": "MemPalace plugin for OpenCode. Installs the Python package, registers the MCP server, and injects commands and a bundled skill.",
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/bun": "latest",
|
|
14
|
+
"@types/node": "^25.6.0"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./server": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src/",
|
|
22
|
+
"skills/"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://github.com/DEVtheOPS/opencode-plugin-mempalace#readme",
|
|
16
25
|
"keywords": [
|
|
17
26
|
"opencode",
|
|
18
27
|
"plugin",
|
|
@@ -21,30 +30,24 @@
|
|
|
21
30
|
"mcp",
|
|
22
31
|
"python"
|
|
23
32
|
],
|
|
33
|
+
"license": "MIT",
|
|
24
34
|
"main": "./src/index.ts",
|
|
25
35
|
"module": "./src/index.ts",
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
},
|
|
30
|
-
"files": [
|
|
31
|
-
"src/",
|
|
32
|
-
"skills/"
|
|
36
|
+
"name": "@devtheops/opencode-plugin-mempalace",
|
|
37
|
+
"oc-plugin": [
|
|
38
|
+
"server"
|
|
33
39
|
],
|
|
34
40
|
"publishConfig": {
|
|
35
41
|
"access": "public"
|
|
36
42
|
},
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"typescript": "^5.9.3"
|
|
41
|
-
},
|
|
42
|
-
"devDependencies": {
|
|
43
|
-
"@types/bun": "latest",
|
|
44
|
-
"@types/node": "^25.6.0"
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "https://github.com/DEVtheOPS/opencode-plugin-mempalace.git"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"test": "bun test",
|
|
48
49
|
"typecheck": "tsc --noEmit"
|
|
49
|
-
}
|
|
50
|
+
},
|
|
51
|
+
"type": "module",
|
|
52
|
+
"version": "1.2.0"
|
|
50
53
|
}
|
|
@@ -11,15 +11,13 @@ MemPalace is a searchable memory system for mined projects and conversations.
|
|
|
11
11
|
|
|
12
12
|
## Prerequisites
|
|
13
13
|
|
|
14
|
-
This plugin tries to install `mempalace` automatically through `pip` when OpenCode loads the plugin.
|
|
15
|
-
|
|
16
14
|
Verify the CLI is available:
|
|
17
15
|
|
|
18
16
|
```bash
|
|
19
17
|
mempalace --version
|
|
20
18
|
```
|
|
21
19
|
|
|
22
|
-
If it is
|
|
20
|
+
If it is missing, install it manually:
|
|
23
21
|
|
|
24
22
|
```bash
|
|
25
23
|
python3 -m pip install --upgrade mempalace
|
|
@@ -52,4 +50,4 @@ After retrieving the instructions, follow them step by step.
|
|
|
52
50
|
## MCP Server
|
|
53
51
|
|
|
54
52
|
This plugin injects a local `mempalace` MCP server into OpenCode config at runtime.
|
|
55
|
-
If the tools are missing, confirm the Python package installed
|
|
53
|
+
If the tools are missing, confirm the `mempalace` CLI or Python package is installed and check the plugin logs for startup diagnostics.
|
package/src/config.ts
CHANGED
|
@@ -42,6 +42,13 @@ function injectCommands(cfg: ConfigWithExtensions) {
|
|
|
42
42
|
"If command arguments are present, treat them as the search query.",
|
|
43
43
|
);
|
|
44
44
|
commands["mempalace-status"] = template("status", "Show MemPalace status, room counts, and health.");
|
|
45
|
+
commands["mempalace-mine-session"] = {
|
|
46
|
+
description: "Export the current OpenCode session and mine it into MemPalace conversation memory.",
|
|
47
|
+
template: [
|
|
48
|
+
"Use the `mempalace_mine_session` tool to export the current OpenCode session and mine it into MemPalace.",
|
|
49
|
+
"If the tool reports that MemPalace is unavailable or the project is not initialized, run `/mempalace-init` first.",
|
|
50
|
+
].join("\n\n"),
|
|
51
|
+
};
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
function injectMcp(cfg: ConfigWithExtensions) {
|
|
@@ -51,11 +58,7 @@ function injectMcp(cfg: ConfigWithExtensions) {
|
|
|
51
58
|
|
|
52
59
|
mcp["mempalace"] = {
|
|
53
60
|
type: "local",
|
|
54
|
-
command: [
|
|
55
|
-
"sh",
|
|
56
|
-
"-lc",
|
|
57
|
-
"if command -v python3 >/dev/null 2>&1; then exec python3 -m mempalace.mcp_server; else exec python -m mempalace.mcp_server; fi",
|
|
58
|
-
],
|
|
61
|
+
command: ["python3", "-m", "mempalace.mcp_server"],
|
|
59
62
|
enabled: true,
|
|
60
63
|
timeout: 10000,
|
|
61
64
|
};
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
|
-
import type
|
|
2
|
+
import { tool, type Plugin } from "@opencode-ai/plugin";
|
|
3
3
|
import type { ConfigWithExtensions } from "./config.ts";
|
|
4
4
|
import { applyMemPalaceConfig } from "./config.ts";
|
|
5
|
+
import { diagnoseMemPalace, isProjectReady, resolveMemPalaceCommand, wakeUp } from "./mempalace.ts";
|
|
6
|
+
import { createSessionMiner, resolveThreshold } from "./mining.ts";
|
|
5
7
|
|
|
6
|
-
const PACKAGE_NAME = "mempalace";
|
|
7
8
|
const SKILLS_DIR = fileURLToPath(new URL("../skills", import.meta.url));
|
|
8
|
-
const
|
|
9
|
+
const DEFAULT_MAX_WAKE_UP_CHARS = 4000;
|
|
9
10
|
|
|
10
11
|
type Logger = {
|
|
11
12
|
app?: {
|
|
@@ -37,60 +38,130 @@ async function log(
|
|
|
37
38
|
});
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
function resolveBoolean(value: unknown, fallback: boolean) {
|
|
42
|
+
return typeof value === "boolean" ? value : fallback;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveMaxWakeUpChars(value: unknown) {
|
|
46
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
47
|
+
if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_MAX_WAKE_UP_CHARS;
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const server: Plugin = async (input, options) => {
|
|
52
|
+
await diagnoseMemPalace(input.client as Logger);
|
|
53
|
+
|
|
54
|
+
const threshold = resolveThreshold(options?.threshold);
|
|
55
|
+
const autoMine = resolveBoolean(options?.autoMine, true);
|
|
56
|
+
const injectWakeUp = resolveBoolean(options?.injectWakeUp, true);
|
|
57
|
+
const injectOnCompaction = resolveBoolean(options?.injectOnCompaction, true);
|
|
58
|
+
const flushOnIdle = resolveBoolean(options?.flushOnIdle, true);
|
|
59
|
+
const flushOnExit = resolveBoolean(options?.flushOnExit, true);
|
|
60
|
+
const maxWakeUpChars = resolveMaxWakeUpChars(options?.maxWakeUpChars);
|
|
61
|
+
|
|
62
|
+
await log(input.client as Logger, "info", "MemPalace plugin options configured.", {
|
|
63
|
+
threshold: String(threshold),
|
|
64
|
+
autoMine: String(autoMine),
|
|
65
|
+
injectWakeUp: String(injectWakeUp),
|
|
66
|
+
injectOnCompaction: String(injectOnCompaction),
|
|
67
|
+
flushOnIdle: String(flushOnIdle),
|
|
68
|
+
flushOnExit: String(flushOnExit),
|
|
69
|
+
maxWakeUpChars: String(maxWakeUpChars),
|
|
45
70
|
});
|
|
46
71
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
]);
|
|
72
|
+
const miner = createSessionMiner(input, threshold);
|
|
73
|
+
let commandPromise: Promise<string[] | null> | undefined;
|
|
74
|
+
let projectReady: boolean | undefined;
|
|
75
|
+
let readinessLogged = false;
|
|
52
76
|
|
|
53
|
-
|
|
54
|
-
|
|
77
|
+
async function ensureReady() {
|
|
78
|
+
commandPromise ??= resolveMemPalaceCommand();
|
|
79
|
+
const command = await commandPromise;
|
|
80
|
+
if (!command) return { command: null, ready: false };
|
|
55
81
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
if (projectReady === undefined) {
|
|
83
|
+
projectReady = await isProjectReady(command);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!projectReady && !readinessLogged) {
|
|
87
|
+
readinessLogged = true;
|
|
88
|
+
await log(input.client as Logger, "warn", "MemPalace is installed but this project does not appear to be initialized. Wake-up injection and automatic mining are disabled until you run /mempalace-init.", {
|
|
89
|
+
directory: input.directory,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
66
92
|
|
|
67
|
-
|
|
68
|
-
if (check.exitCode === 0) {
|
|
69
|
-
await log(client, "debug", "MemPalace Python package already installed.", { python });
|
|
70
|
-
return;
|
|
93
|
+
return { command, ready: Boolean(projectReady) };
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
|
|
74
|
-
|
|
96
|
+
async function injectMemory(kind: "system" | "context", output: { system?: string[]; context?: string[] }) {
|
|
97
|
+
const status = await ensureReady();
|
|
98
|
+
if (!status.command || !status.ready) return;
|
|
75
99
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
100
|
+
const memory = await wakeUp(status.command, maxWakeUpChars);
|
|
101
|
+
if (!memory) return;
|
|
80
102
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
});
|
|
85
|
-
}
|
|
103
|
+
if (kind === "system") output.system?.push(memory);
|
|
104
|
+
if (kind === "context") output.context?.push(memory);
|
|
105
|
+
}
|
|
86
106
|
|
|
87
|
-
|
|
88
|
-
|
|
107
|
+
if (flushOnExit) {
|
|
108
|
+
process.on("SIGINT", () => {
|
|
109
|
+
process.exit(130);
|
|
110
|
+
});
|
|
111
|
+
process.on("SIGTERM", () => {
|
|
112
|
+
process.exit(143);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
89
115
|
|
|
90
116
|
return {
|
|
91
117
|
config: async (cfg) => {
|
|
92
118
|
applyMemPalaceConfig(cfg as ConfigWithExtensions, SKILLS_DIR);
|
|
93
119
|
},
|
|
120
|
+
tool: {
|
|
121
|
+
mempalace_mine_session: tool({
|
|
122
|
+
description: "Export the current OpenCode session transcript and mine it into MemPalace conversation memory.",
|
|
123
|
+
args: {},
|
|
124
|
+
execute: async (_args, context) => {
|
|
125
|
+
await miner.mineNow(context.sessionID, "manual-tool");
|
|
126
|
+
return "Requested MemPalace mining for the current OpenCode session.";
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
"chat.message": async ({ sessionID }) => {
|
|
131
|
+
if (!autoMine) return;
|
|
132
|
+
await miner.noteMessage(sessionID);
|
|
133
|
+
},
|
|
134
|
+
event: async ({ event }) => {
|
|
135
|
+
if (!flushOnIdle || !autoMine) return;
|
|
136
|
+
|
|
137
|
+
const sessionID = "properties" in event
|
|
138
|
+
? (event.properties as { sessionID?: string; info?: { id?: string } })?.sessionID ??
|
|
139
|
+
(event.properties as { info?: { id?: string } })?.info?.id
|
|
140
|
+
: undefined;
|
|
141
|
+
if (!sessionID) return;
|
|
142
|
+
|
|
143
|
+
if (event.type === "session.idle" || event.type === "session.deleted") {
|
|
144
|
+
await miner.flush(sessionID, event.type);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (event.type === "session.status") {
|
|
148
|
+
const status = (event.properties as { status?: { type?: string } })?.status?.type;
|
|
149
|
+
if (status === "idle") {
|
|
150
|
+
await miner.flush(sessionID, "session.status.idle");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
155
|
+
if (!injectWakeUp) return;
|
|
156
|
+
await injectMemory("system", output);
|
|
157
|
+
},
|
|
158
|
+
"experimental.session.compacting": async ({ sessionID }, output) => {
|
|
159
|
+
if (autoMine) {
|
|
160
|
+
await miner.flush(sessionID, "compacting");
|
|
161
|
+
}
|
|
162
|
+
if (!injectOnCompaction) return;
|
|
163
|
+
await injectMemory("context", output);
|
|
164
|
+
},
|
|
94
165
|
};
|
|
95
166
|
};
|
|
96
167
|
|
package/src/mempalace.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
type Logger = {
|
|
2
|
+
app?: {
|
|
3
|
+
log?: (input: {
|
|
4
|
+
body: {
|
|
5
|
+
service: string;
|
|
6
|
+
level: "debug" | "info" | "warn" | "error";
|
|
7
|
+
message: string;
|
|
8
|
+
extra?: Record<string, string>;
|
|
9
|
+
};
|
|
10
|
+
}) => Promise<unknown>;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const IMPORT_CHECK = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('mempalace') else 1)";
|
|
15
|
+
|
|
16
|
+
async function log(
|
|
17
|
+
client: Logger,
|
|
18
|
+
level: "debug" | "info" | "warn" | "error",
|
|
19
|
+
message: string,
|
|
20
|
+
extra?: Record<string, string>,
|
|
21
|
+
) {
|
|
22
|
+
if (!client.app?.log) return;
|
|
23
|
+
await client.app.log({
|
|
24
|
+
body: {
|
|
25
|
+
service: "opencode-plugin-mempalace",
|
|
26
|
+
level,
|
|
27
|
+
message,
|
|
28
|
+
extra,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function run(cmd: string[]) {
|
|
34
|
+
const proc = Bun.spawn({
|
|
35
|
+
cmd,
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
41
|
+
new Response(proc.stdout).text(),
|
|
42
|
+
new Response(proc.stderr).text(),
|
|
43
|
+
proc.exited,
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
return { stdout, stderr, exitCode };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function diagnoseMemPalace(client: Logger) {
|
|
50
|
+
const cli = Bun.which("mempalace");
|
|
51
|
+
const python = Bun.which("python3") ?? Bun.which("python");
|
|
52
|
+
if (cli) {
|
|
53
|
+
await log(client, "info", "MemPalace CLI detected.", { cli });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!python) {
|
|
58
|
+
await log(
|
|
59
|
+
client,
|
|
60
|
+
"warn",
|
|
61
|
+
"MemPalace is required but neither the `mempalace` CLI nor python3/python is available.",
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const check = await run([python, "-c", IMPORT_CHECK]);
|
|
67
|
+
if (check.exitCode === 0) {
|
|
68
|
+
await log(client, "info", "MemPalace Python package detected via Python module lookup.", { python });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await log(client, "warn", "MemPalace is not installed. The MCP server and conversation mining will fail until it is installed manually.", {
|
|
73
|
+
python,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function resolveMemPalaceCommand() {
|
|
78
|
+
if (Bun.which("mempalace")) return ["mempalace"];
|
|
79
|
+
|
|
80
|
+
const python = Bun.which("python3") ?? Bun.which("python");
|
|
81
|
+
if (!python) return null;
|
|
82
|
+
|
|
83
|
+
const check = await run([python, "-c", IMPORT_CHECK]);
|
|
84
|
+
if (check.exitCode !== 0) return null;
|
|
85
|
+
return [python, "-m", "mempalace"];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function isProjectReady(command: string[]) {
|
|
89
|
+
const result = await run([...command, "status"]);
|
|
90
|
+
if (result.exitCode === 0) return true;
|
|
91
|
+
|
|
92
|
+
const output = `${result.stderr}\n${result.stdout}`.toLowerCase();
|
|
93
|
+
if (
|
|
94
|
+
output.includes("mempalace.yaml") ||
|
|
95
|
+
output.includes("not initialized") ||
|
|
96
|
+
output.includes("run mempalace init") ||
|
|
97
|
+
output.includes("no palace")
|
|
98
|
+
) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function wakeUp(command: string[], maxChars: number) {
|
|
106
|
+
const result = await run([...command, "wake-up"]);
|
|
107
|
+
if (result.exitCode !== 0) return null;
|
|
108
|
+
|
|
109
|
+
const text = result.stdout.trim();
|
|
110
|
+
if (!text) return null;
|
|
111
|
+
if (text.length <= maxChars) return text;
|
|
112
|
+
return `${text.slice(0, maxChars)}\n...[Memory Truncated]`;
|
|
113
|
+
}
|
package/src/mining.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import { isProjectReady, resolveMemPalaceCommand, run } from "./mempalace.ts";
|
|
3
|
+
import { exportSessionTranscript } from "./session.ts";
|
|
4
|
+
|
|
5
|
+
type Logger = {
|
|
6
|
+
app?: {
|
|
7
|
+
log?: (input: {
|
|
8
|
+
body: {
|
|
9
|
+
service: string;
|
|
10
|
+
level: "debug" | "info" | "warn" | "error";
|
|
11
|
+
message: string;
|
|
12
|
+
extra?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
}) => Promise<unknown>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type SessionState = {
|
|
19
|
+
dirtyCount: number;
|
|
20
|
+
mining: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULT_THRESHOLD = 15;
|
|
24
|
+
|
|
25
|
+
export function resolveThreshold(value: unknown): number {
|
|
26
|
+
if (value === undefined) return DEFAULT_THRESHOLD;
|
|
27
|
+
|
|
28
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
29
|
+
if (!Number.isInteger(parsed) || parsed < 0) return DEFAULT_THRESHOLD;
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function log(
|
|
34
|
+
client: Logger,
|
|
35
|
+
level: "debug" | "info" | "warn" | "error",
|
|
36
|
+
message: string,
|
|
37
|
+
extra?: Record<string, string>,
|
|
38
|
+
) {
|
|
39
|
+
if (!client.app?.log) return;
|
|
40
|
+
await client.app.log({
|
|
41
|
+
body: {
|
|
42
|
+
service: "opencode-plugin-mempalace",
|
|
43
|
+
level,
|
|
44
|
+
message,
|
|
45
|
+
extra,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createSessionMiner(input: PluginInput, threshold = DEFAULT_THRESHOLD) {
|
|
51
|
+
const state = new Map<string, SessionState>();
|
|
52
|
+
let projectReady: boolean | undefined;
|
|
53
|
+
let projectReadinessLogged = false;
|
|
54
|
+
|
|
55
|
+
function get(sessionID: string): SessionState {
|
|
56
|
+
const existing = state.get(sessionID);
|
|
57
|
+
if (existing) return existing;
|
|
58
|
+
const fresh = { dirtyCount: 0, mining: false };
|
|
59
|
+
state.set(sessionID, fresh);
|
|
60
|
+
return fresh;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function mineSession(sessionID: string, reason: string) {
|
|
64
|
+
const session = get(sessionID);
|
|
65
|
+
if (session.mining || session.dirtyCount === 0) return;
|
|
66
|
+
|
|
67
|
+
session.mining = true;
|
|
68
|
+
try {
|
|
69
|
+
const command = await resolveMemPalaceCommand();
|
|
70
|
+
if (!command) {
|
|
71
|
+
await log(input.client as unknown as Logger, "warn", "MemPalace conversation mining skipped because the CLI is unavailable.", {
|
|
72
|
+
sessionID,
|
|
73
|
+
reason,
|
|
74
|
+
directory: input.directory,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (projectReady === undefined) {
|
|
80
|
+
projectReady = await isProjectReady(command);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!projectReady) {
|
|
84
|
+
if (!projectReadinessLogged) {
|
|
85
|
+
projectReadinessLogged = true;
|
|
86
|
+
await log(input.client as unknown as Logger, "warn", "MemPalace is installed but this project does not appear to be initialized. Skipping automatic conversation mining. Run /mempalace-init first.", {
|
|
87
|
+
directory: input.directory,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const transcript = await exportSessionTranscript(
|
|
94
|
+
input.client as unknown as Parameters<typeof exportSessionTranscript>[0],
|
|
95
|
+
sessionID,
|
|
96
|
+
input.directory,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await log(input.client as unknown as Logger, "info", "Mining OpenCode session transcript into MemPalace.", {
|
|
100
|
+
sessionID,
|
|
101
|
+
reason,
|
|
102
|
+
messages: String(transcript.messageCount),
|
|
103
|
+
transcript: transcript.path,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await run([...command, "mine", transcript.path, "--mode", "convos"]);
|
|
107
|
+
if (result.exitCode !== 0) {
|
|
108
|
+
await log(input.client as unknown as Logger, "warn", "MemPalace conversation mining failed.", {
|
|
109
|
+
sessionID,
|
|
110
|
+
reason,
|
|
111
|
+
stderr: result.stderr.trim() || "unknown error",
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
session.dirtyCount = 0;
|
|
117
|
+
await log(input.client as unknown as Logger, "info", "MemPalace conversation mining completed.", {
|
|
118
|
+
sessionID,
|
|
119
|
+
reason,
|
|
120
|
+
messages: String(transcript.messageCount),
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
await log(input.client as unknown as Logger, "warn", "MemPalace conversation mining threw an error.", {
|
|
124
|
+
sessionID,
|
|
125
|
+
reason,
|
|
126
|
+
error: error instanceof Error ? error.message : String(error),
|
|
127
|
+
});
|
|
128
|
+
} finally {
|
|
129
|
+
session.mining = false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function noteMessage(sessionID: string) {
|
|
134
|
+
const session = get(sessionID);
|
|
135
|
+
session.dirtyCount += 1;
|
|
136
|
+
if (threshold === 0) return;
|
|
137
|
+
if (session.dirtyCount < threshold) return;
|
|
138
|
+
await mineSession(sessionID, "threshold");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function flush(sessionID: string, reason: string) {
|
|
142
|
+
await mineSession(sessionID, reason);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function mineNow(sessionID: string, reason: string) {
|
|
146
|
+
const session = get(sessionID);
|
|
147
|
+
if (session.dirtyCount === 0) {
|
|
148
|
+
session.dirtyCount = 1;
|
|
149
|
+
}
|
|
150
|
+
await mineSession(sessionID, reason);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
noteMessage,
|
|
155
|
+
flush,
|
|
156
|
+
mineNow,
|
|
157
|
+
};
|
|
158
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Part } from "@opencode-ai/sdk";
|
|
5
|
+
|
|
6
|
+
type SessionMessageRecord = {
|
|
7
|
+
info: {
|
|
8
|
+
id: string;
|
|
9
|
+
role: "user" | "assistant";
|
|
10
|
+
time?: {
|
|
11
|
+
created?: number;
|
|
12
|
+
completed?: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
parts: Part[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type ClientLike = {
|
|
19
|
+
session: {
|
|
20
|
+
messages: (input: {
|
|
21
|
+
sessionID: string;
|
|
22
|
+
directory?: string;
|
|
23
|
+
workspace?: string;
|
|
24
|
+
limit?: number;
|
|
25
|
+
before?: string;
|
|
26
|
+
}) => Promise<SessionMessageRecord[]>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function formatPart(part: Part): string | null {
|
|
31
|
+
if ("text" in part && typeof part.text === "string" && part.text.trim()) {
|
|
32
|
+
return part.text.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ("tool" in part && typeof part.tool === "string") {
|
|
36
|
+
return `[tool:${part.tool}]`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ("title" in part && typeof part.title === "string" && part.title.trim()) {
|
|
40
|
+
return `[${part.title.trim()}]`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function formatTimestamp(created?: number, completed?: number): string {
|
|
47
|
+
const value = created ?? completed;
|
|
48
|
+
if (!value) return "unknown-time";
|
|
49
|
+
return new Date(value).toISOString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function exportSessionTranscript(
|
|
53
|
+
client: ClientLike,
|
|
54
|
+
sessionID: string,
|
|
55
|
+
directory: string,
|
|
56
|
+
): Promise<{ path: string; messageCount: number }> {
|
|
57
|
+
const messages = await client.session.messages({
|
|
58
|
+
sessionID,
|
|
59
|
+
directory,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const body = messages
|
|
63
|
+
.map(({ info, parts }) => {
|
|
64
|
+
const renderedParts = parts.map(formatPart).filter((value): value is string => Boolean(value));
|
|
65
|
+
return [
|
|
66
|
+
`## ${info.role.toUpperCase()} ${formatTimestamp(info.time?.created, info.time?.completed)}`,
|
|
67
|
+
renderedParts.join("\n\n") || "[no text content]",
|
|
68
|
+
].join("\n\n");
|
|
69
|
+
})
|
|
70
|
+
.join("\n\n---\n\n");
|
|
71
|
+
|
|
72
|
+
const tempDir = await mkdtemp(join(tmpdir(), "opencode-mempalace-"));
|
|
73
|
+
const path = join(tempDir, `${sessionID}.md`);
|
|
74
|
+
await writeFile(path, body || "[empty session]", "utf8");
|
|
75
|
+
|
|
76
|
+
return { path, messageCount: messages.length };
|
|
77
|
+
}
|