@devtheops/opencode-plugin-mempalace 0.1.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,10 +16,12 @@ An [OpenCode](https://opencode.ai) server plugin that integrates [MemPalace](htt
16
16
 
17
17
  The plugin:
18
18
 
19
- - installs the `mempalace` Python package if it is missing
19
+ - requires an existing `mempalace` installation and logs startup diagnostics if it is missing
20
20
  - registers a local `mempalace` MCP server
21
21
  - injects MemPalace slash commands into OpenCode
22
22
  - injects a bundled `mempalace` skill into OpenCode
23
+ - automatically mines OpenCode session transcripts into MemPalace conversation memory
24
+ - can inject `mempalace wake-up` memory into the system prompt and compaction context
23
25
 
24
26
  ## Installation
25
27
 
@@ -32,6 +34,47 @@ Add the plugin to your OpenCode config:
32
34
  }
33
35
  ```
34
36
 
37
+ You can configure automatic conversation mining with a per-session message threshold:
38
+
39
+ ```json
40
+ {
41
+ "$schema": "https://opencode.ai/config.json",
42
+ "plugin": [["@devtheops/opencode-plugin-mempalace", { "threshold": 30 }]]
43
+ }
44
+ ```
45
+
46
+ Full plugin options:
47
+
48
+ ```json
49
+ {
50
+ "$schema": "https://opencode.ai/config.json",
51
+ "plugin": [["@devtheops/opencode-plugin-mempalace", {
52
+ "threshold": 15,
53
+ "autoMine": true,
54
+ "injectWakeUp": true,
55
+ "injectOnCompaction": true,
56
+ "maxWakeUpChars": 4000,
57
+ "flushOnIdle": true,
58
+ "flushOnExit": true
59
+ }]]
60
+ }
61
+ ```
62
+
63
+ `threshold` rules:
64
+
65
+ - default: `15`
66
+ - `0`: disable threshold-triggered mining and only flush on idle, delete, and compaction
67
+ - invalid or negative values fall back to `15`
68
+
69
+ Other options:
70
+
71
+ - `autoMine`: enable or disable automatic conversation mining entirely
72
+ - `injectWakeUp`: inject `mempalace wake-up` output into the system prompt
73
+ - `injectOnCompaction`: inject `mempalace wake-up` output into compaction context
74
+ - `maxWakeUpChars`: truncate injected wake-up memory to this many characters
75
+ - `flushOnIdle`: flush dirty sessions when OpenCode marks them idle or deleted
76
+ - `flushOnExit`: register graceful process-exit hooks
77
+
35
78
  For local development you can point OpenCode directly at this checkout:
36
79
 
37
80
  ```json
@@ -45,7 +88,7 @@ For local development you can point OpenCode directly at this checkout:
45
88
 
46
89
  - OpenCode
47
90
  - Python 3.9+
48
- - `pip`
91
+ - MemPalace installed already, either as `mempalace` on `PATH` or as a Python module importable by `python3` or `python`
49
92
 
50
93
  ## What It Adds
51
94
 
@@ -66,14 +109,17 @@ If a `mempalace` MCP server is already configured, the plugin leaves it alone.
66
109
 
67
110
  ## Runtime Behavior
68
111
 
69
- When OpenCode loads the plugin, it checks for `python3` or `python` and then verifies whether the `mempalace` package is importable.
70
- If not, it runs:
112
+ 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`.
113
+
114
+ If MemPalace is missing, the plugin does not install it automatically. Instead it logs explicit warnings so MCP startup failures are easier to diagnose.
115
+
116
+ The plugin also exports OpenCode session transcripts through the OpenCode client API and mines them with:
71
117
 
72
118
  ```bash
73
- python3 -m pip install --upgrade mempalace
119
+ mempalace mine <transcript-file> --mode convos
74
120
  ```
75
121
 
76
- If installation fails, the plugin logs a warning and OpenCode continues running.
122
+ Threshold-based mining is configurable through the plugin `threshold` option.
77
123
 
78
124
  ## Development
79
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devtheops/opencode-plugin-mempalace",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "MemPalace plugin for OpenCode. Installs the Python package, registers the MCP server, and injects commands and a bundled skill.",
5
5
  "type": "module",
6
6
  "author": "DEVtheOPS",
@@ -46,5 +46,8 @@
46
46
  "scripts": {
47
47
  "test": "bun test",
48
48
  "typecheck": "tsc --noEmit"
49
- }
49
+ },
50
+ "oc-plugin": [
51
+ "server"
52
+ ]
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 still missing, install it manually:
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 successfully.
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 { Plugin } from "@opencode-ai/plugin";
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 IMPORT_CHECK = "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('mempalace') else 1)";
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
- async function run(cmd: string[]) {
41
- const proc = Bun.spawn({
42
- cmd,
43
- stdout: "pipe",
44
- stderr: "pipe",
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 [stdout, stderr, exitCode] = await Promise.all([
48
- new Response(proc.stdout).text(),
49
- new Response(proc.stderr).text(),
50
- proc.exited,
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
- return { stdout, stderr, exitCode };
54
- }
77
+ async function ensureReady() {
78
+ commandPromise ??= resolveMemPalaceCommand();
79
+ const command = await commandPromise;
80
+ if (!command) return { command: null, ready: false };
55
81
 
56
- async function ensureMemPalaceInstalled(client: Logger) {
57
- const python = Bun.which("python3") ?? Bun.which("python");
58
- if (!python) {
59
- await log(
60
- client,
61
- "warn",
62
- "Could not find python3 or python. MemPalace MCP setup will remain unavailable until Python is installed.",
63
- );
64
- return;
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
- const check = await run([python, "-c", IMPORT_CHECK]);
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
- await log(client, "info", "Installing MemPalace Python package.", { python, package: PACKAGE_NAME });
74
- const install = await run([python, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]);
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
- if (install.exitCode === 0) {
77
- await log(client, "info", "Installed MemPalace Python package.", { python, package: PACKAGE_NAME });
78
- return;
79
- }
100
+ const memory = await wakeUp(status.command, maxWakeUpChars);
101
+ if (!memory) return;
80
102
 
81
- await log(client, "warn", "MemPalace Python package install failed.", {
82
- python,
83
- stderr: install.stderr.trim() || "unknown error",
84
- });
85
- }
103
+ if (kind === "system") output.system?.push(memory);
104
+ if (kind === "context") output.context?.push(memory);
105
+ }
86
106
 
87
- export const server: Plugin = async ({ client }) => {
88
- await ensureMemPalaceInstalled(client);
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
 
@@ -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
+ }