@rvboris/opencode-mempalace 0.1.0 → 0.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.
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env python3
2
2
  import json
3
3
  import sys
4
+ import tempfile
5
+ from contextlib import redirect_stdout
6
+ from io import StringIO
7
+ from pathlib import Path
4
8
 
9
+ from mempalace.config import MempalaceConfig
10
+ from mempalace.convo_miner import mine_convos
5
11
  from mempalace.mcp_server import (
6
12
  tool_add_drawer,
7
13
  tool_diary_write,
@@ -10,43 +16,69 @@ from mempalace.mcp_server import (
10
16
  )
11
17
 
12
18
 
13
- def main() -> int:
14
- payload = json.load(sys.stdin)
15
-
16
- mode = payload["mode"]
17
- if mode == "search":
18
- result = tool_search(
19
- query=payload["query"],
20
- limit=payload.get("limit", 5),
21
- wing=payload.get("wing"),
22
- room=payload.get("room"),
23
- )
24
- elif mode == "save":
25
- result = tool_add_drawer(
26
- wing=payload["wing"],
27
- room=payload["room"],
28
- content=payload["content"],
29
- added_by=payload.get("added_by", "opencode"),
30
- )
31
- elif mode == "kg_add":
32
- result = tool_kg_add(
33
- subject=payload["subject"],
34
- predicate=payload["predicate"],
35
- object=payload["object"],
36
- valid_from=payload.get("valid_from", ""),
37
- source_closet=payload.get("source_closet", ""),
38
- )
39
- elif mode == "diary_write":
40
- result = tool_diary_write(
41
- agent_name=payload["agent_name"],
42
- entry=payload["entry"],
43
- topic=payload.get("topic", "autosave"),
44
- )
45
- else:
46
- result = {"success": False, "error": f"Unknown mode: {mode}"}
47
-
19
+ def write_result(result: dict) -> None:
48
20
  output = json.dumps(result, ensure_ascii=False)
49
21
  sys.stdout.buffer.write(output.encode("utf-8", errors="replace"))
22
+
23
+
24
+ def main() -> int:
25
+ try:
26
+ payload = json.load(sys.stdin)
27
+
28
+ mode = payload["mode"]
29
+ if mode == "search":
30
+ result = tool_search(
31
+ query=payload["query"],
32
+ limit=payload.get("limit", 5),
33
+ wing=payload.get("wing"),
34
+ room=payload.get("room"),
35
+ )
36
+ elif mode == "save":
37
+ result = tool_add_drawer(
38
+ wing=payload["wing"],
39
+ room=payload["room"],
40
+ content=payload["content"],
41
+ added_by=payload.get("added_by", "opencode"),
42
+ )
43
+ elif mode == "kg_add":
44
+ result = tool_kg_add(
45
+ subject=payload["subject"],
46
+ predicate=payload["predicate"],
47
+ object=payload["object"],
48
+ valid_from=payload.get("valid_from", ""),
49
+ source_closet=payload.get("source_closet", ""),
50
+ )
51
+ elif mode == "diary_write":
52
+ result = tool_diary_write(
53
+ agent_name=payload["agent_name"],
54
+ entry=payload["entry"],
55
+ topic=payload.get("topic", "autosave"),
56
+ )
57
+ elif mode == "mine_messages":
58
+ palace_path = payload.get("palace_path") or MempalaceConfig().palace_path
59
+ extract_mode = payload.get("extract_mode", "general")
60
+ with tempfile.TemporaryDirectory(prefix="mempalace-autosave-") as tmpdir:
61
+ transcript_path = Path(tmpdir) / "session.txt"
62
+ transcript_path.write_text(payload["transcript"], encoding="utf-8")
63
+ with redirect_stdout(StringIO()):
64
+ mine_convos(
65
+ convo_dir=tmpdir,
66
+ palace_path=palace_path,
67
+ wing=payload.get("wing"),
68
+ agent=payload.get("agent", "opencode"),
69
+ extract_mode=extract_mode,
70
+ )
71
+ result = {
72
+ "success": True,
73
+ "mode": "mine_messages",
74
+ "wing": payload.get("wing"),
75
+ }
76
+ else:
77
+ result = {"success": False, "error": f"Unknown mode: {mode}"}
78
+ except Exception as error:
79
+ result = {"success": False, "error": str(error)}
80
+
81
+ write_result(result)
50
82
  return 0
51
83
 
52
84
 
@@ -1,13 +1,7 @@
1
- type PluginContext = {
2
- client: any;
3
- project: any;
4
- directory: string;
5
- worktree: string;
6
- $: any;
7
- };
8
- export declare const eventHooks: (ctx: PluginContext) => {
1
+ import { type EventHookContext, type SessionEvent } from "../lib/types";
2
+ export declare const eventHooks: (ctx: EventHookContext) => {
9
3
  event: ({ event }: {
10
- event: any;
4
+ event: SessionEvent;
11
5
  }) => Promise<void>;
12
6
  "experimental.session.compacting": (input: {
13
7
  sessionID?: string;
@@ -16,4 +10,3 @@ export declare const eventHooks: (ctx: PluginContext) => {
16
10
  prompt?: string;
17
11
  }) => Promise<void>;
18
12
  };
19
- export {};
@@ -1,7 +1,28 @@
1
- import { AutosaveReason, AutosaveStatus, buildTranscriptDigest, buildUserDigest, extractLastUserMessage, finalizeAutosave, getSessionState, markKeywordSavePending, markFailed, markPending, markRetrievalPending, shouldScheduleAutosave, } from "../lib/autosave";
1
+ import { AutosaveReason, AutosaveStatus, buildMessageSnapshot, getMessageSnapshot, getSessionState, markAutosaveComplete, markKeywordSavePending, markFailed, markRetrievalPending, setMessageSnapshot, shouldScheduleAutosave, } from "../lib/autosave";
2
+ import { executeAdapter } from "../lib/adapter";
2
3
  import { loadConfig } from "../lib/config";
4
+ import { COMPACTION_CONTEXT_MESSAGE, DEFAULT_AGENT_NAME, LOG_MESSAGES } from "../lib/constants";
5
+ import { getProjectName, loadSessionMessages } from "../lib/opencode";
6
+ import { redactSecrets } from "../lib/privacy";
7
+ import { getProjectScope } from "../lib/scope";
3
8
  import { writeLog } from "../lib/log";
9
+ import { SESSION_EVENT_TYPES } from "../lib/types";
4
10
  const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11
+ const TRACKED_EVENT_TYPES = new Set(SESSION_EVENT_TYPES);
12
+ const AUTOSAVE_TRIGGER_EVENT_TYPES = new Set([
13
+ "session.idle",
14
+ "session.compacted",
15
+ "session.deleted",
16
+ "session.error",
17
+ ]);
18
+ const isTrackedEventType = (value) => TRACKED_EVENT_TYPES.has(value);
19
+ const isAutosaveTriggerEventType = (value) => {
20
+ return AUTOSAVE_TRIGGER_EVENT_TYPES.has(value);
21
+ };
22
+ const getSessionId = (event) => {
23
+ const properties = event.properties;
24
+ return properties?.sessionID ?? properties?.info?.id;
25
+ };
5
26
  const toReason = (eventType) => {
6
27
  if (eventType === "session.compacted")
7
28
  return AutosaveReason.Compacted;
@@ -9,55 +30,42 @@ const toReason = (eventType) => {
9
30
  return AutosaveReason.Error;
10
31
  return AutosaveReason.Idle;
11
32
  };
12
- const loadMessages = async (ctx, sessionId) => {
13
- const response = await ctx.client.session.messages({ path: { id: sessionId } });
14
- return response?.data ?? response ?? [];
15
- };
16
33
  export const eventHooks = (ctx) => {
17
34
  return {
18
35
  event: async ({ event }) => {
19
36
  try {
20
- if (!["session.idle", "session.compacted", "session.error", "session.updated", "message.updated"].includes(event?.type))
37
+ if (!isTrackedEventType(event.type))
21
38
  return;
22
- const sessionId = event?.properties?.sessionID;
39
+ const sessionId = getSessionId(event);
23
40
  if (!sessionId) {
24
- await writeLog("WARN", "autosave event missing sessionID", { eventType: event?.type });
41
+ await writeLog("WARN", LOG_MESSAGES.autosaveEventMissingSessionId, { eventType: event.type });
25
42
  return;
26
43
  }
27
44
  const config = await loadConfig();
28
- if (event?.type === "session.updated" || event?.type === "message.updated") {
29
- const messages = await loadMessages(ctx, sessionId);
30
- const userDigest = buildUserDigest(messages);
45
+ if (event.type === "session.updated" || event.type === "message.updated") {
46
+ const snapshot = buildMessageSnapshot(await loadSessionMessages(ctx.client, sessionId));
47
+ setMessageSnapshot(sessionId, snapshot);
31
48
  if (config.retrievalEnabled) {
32
- markRetrievalPending(sessionId, userDigest);
49
+ markRetrievalPending(sessionId, snapshot.userDigest);
33
50
  }
34
- const lastUserMessage = extractLastUserMessage(messages);
51
+ const lastUserMessage = snapshot.lastUserMessage;
35
52
  if (lastUserMessage && config.keywordSaveEnabled && config.keywordPatterns.length) {
36
53
  const keywordPattern = new RegExp(`\\b(${config.keywordPatterns.map(escapeRegex).join("|")})\\b`, "i");
37
54
  if (config.keywordSaveEnabled && keywordPattern.test(lastUserMessage)) {
38
55
  markKeywordSavePending(sessionId);
39
- await writeLog("INFO", "keyword-triggered autosave hint detected", { sessionId });
56
+ await writeLog("INFO", LOG_MESSAGES.keywordTriggeredAutosaveHintDetected, { sessionId });
40
57
  }
41
58
  }
42
59
  }
43
- if (!["session.idle", "session.compacted", "session.error"].includes(event?.type))
60
+ if (!isAutosaveTriggerEventType(event.type))
44
61
  return;
45
- await writeLog("INFO", "autosave trigger received", {
46
- eventType: event?.type,
62
+ await writeLog("INFO", LOG_MESSAGES.autosaveTriggerReceived, {
63
+ eventType: event.type,
47
64
  sessionId,
48
65
  });
49
- const state = getSessionState(sessionId);
50
- if (state.status === AutosaveStatus.Running && event?.type === "session.idle") {
51
- const finalized = finalizeAutosave(sessionId);
52
- await writeLog("INFO", "finalized autosave turn", {
53
- sessionId,
54
- status: finalized.status,
55
- toolCalls: finalized.successfulToolCalls.length,
56
- });
57
- }
58
- if (event?.type === "session.error") {
66
+ if (event.type === "session.error") {
59
67
  const failed = markFailed(sessionId);
60
- await writeLog("ERROR", "autosave failed on session error", {
68
+ await writeLog("ERROR", LOG_MESSAGES.autosaveFailedOnSessionError, {
61
69
  sessionId,
62
70
  retryCount: failed.retryCount,
63
71
  });
@@ -65,45 +73,70 @@ export const eventHooks = (ctx) => {
65
73
  }
66
74
  if (!config.autosaveEnabled)
67
75
  return;
68
- const messages = await loadMessages(ctx, sessionId);
69
- const userDigest = buildUserDigest(messages);
70
- const transcriptDigest = buildTranscriptDigest(messages);
76
+ const cachedSnapshot = getMessageSnapshot(sessionId);
77
+ const snapshot = cachedSnapshot ?? buildMessageSnapshot(await loadSessionMessages(ctx.client, sessionId));
78
+ if (!cachedSnapshot) {
79
+ setMessageSnapshot(sessionId, snapshot);
80
+ }
81
+ const { transcript, transcriptDigest, userDigest } = snapshot;
71
82
  if (!shouldScheduleAutosave(sessionId, userDigest, transcriptDigest)) {
72
- await writeLog("INFO", "skipping autosave state", {
83
+ await writeLog("INFO", LOG_MESSAGES.skippingAutosaveState, {
73
84
  sessionId,
74
- reason: toReason(event?.type),
85
+ reason: toReason(event.type),
75
86
  userDigest,
76
87
  transcriptDigest,
77
88
  status: getSessionState(sessionId).status,
78
89
  });
79
90
  return;
80
91
  }
81
- markPending(sessionId, toReason(event?.type), userDigest, transcriptDigest);
82
- await writeLog("INFO", "marked autosave pending", {
92
+ const redactedTranscript = redactSecrets(transcript);
93
+ if (!redactedTranscript.trim()) {
94
+ markAutosaveComplete(sessionId, userDigest, transcriptDigest, AutosaveStatus.Noop);
95
+ await writeLog("INFO", LOG_MESSAGES.autosaveSkippedEmptyTranscript, { sessionId });
96
+ return;
97
+ }
98
+ const wing = getProjectScope(getProjectName(ctx.project), config.projectWingPrefix).wing;
99
+ const result = await executeAdapter(ctx.$, {
100
+ mode: "mine_messages",
101
+ transcript: redactedTranscript,
102
+ wing,
103
+ extract_mode: config.autoMineExtractMode,
104
+ agent: DEFAULT_AGENT_NAME,
105
+ });
106
+ if (result?.success === false) {
107
+ const failed = markFailed(sessionId);
108
+ await writeLog("ERROR", LOG_MESSAGES.autosaveMiningFailed, {
109
+ sessionId,
110
+ retryCount: failed.retryCount,
111
+ result,
112
+ });
113
+ return;
114
+ }
115
+ const completed = markAutosaveComplete(sessionId, userDigest, transcriptDigest, AutosaveStatus.Saved);
116
+ await writeLog("INFO", LOG_MESSAGES.autosaveMinedSessionContext, {
83
117
  sessionId,
84
- reason: toReason(event?.type),
118
+ reason: toReason(event.type),
85
119
  userDigest,
86
120
  transcriptDigest,
121
+ status: completed.status,
122
+ wing,
87
123
  });
88
124
  }
89
125
  catch (error) {
90
- await writeLog("ERROR", "event hook failed", {
126
+ await writeLog("ERROR", LOG_MESSAGES.eventHookFailed, {
91
127
  error: error instanceof Error ? error.message : String(error),
92
128
  });
93
129
  }
94
130
  },
95
131
  "experimental.session.compacting": async (input, output) => {
96
132
  if (!input.sessionID) {
97
- await writeLog("WARN", "compaction hook missing sessionID", {});
133
+ await writeLog("WARN", LOG_MESSAGES.compactionHookMissingSessionId, {});
98
134
  return;
99
135
  }
100
- const state = getSessionState(input.sessionID);
101
- if (state.status !== AutosaveStatus.Pending)
102
- return;
103
- output.context.push("MemPalace autosave is pending. Before answering after compaction, persist durable facts, decisions, preferences, outcomes, and diary notes using MemPalace MCP tools. Do not dump the full transcript.");
104
- await writeLog("INFO", "injected compaction autosave context", {
136
+ output.context.push(COMPACTION_CONTEXT_MESSAGE);
137
+ await writeLog("INFO", LOG_MESSAGES.injectedCompactionAutosaveContext, {
105
138
  sessionId: input.sessionID,
106
- reason: state.pendingReason,
139
+ reason: AutosaveReason.Compacted,
107
140
  });
108
141
  },
109
142
  };
@@ -1,10 +1,8 @@
1
- type PluginContext = {
2
- client: any;
3
- project: any;
4
- };
5
- export declare const systemHooks: (ctx: PluginContext) => {
6
- "experimental.chat.system.transform": (_input: {}, output: {
1
+ import type { SystemHookContext } from "../lib/types";
2
+ export declare const systemHooks: (ctx: SystemHookContext) => {
3
+ "experimental.chat.system.transform": (input: {
4
+ sessionID?: string;
5
+ }, output: {
7
6
  system: string[];
8
7
  }) => Promise<void>;
9
8
  };
10
- export {};
@@ -1,22 +1,27 @@
1
- import { AutosaveStatus, clearKeywordSavePending, extractLastUserMessage, getCurrentTurnSessionId, getSessionState, markRetrievalInjected, startAutosave, } from "../lib/autosave";
1
+ import { buildMessageSnapshot, clearKeywordSavePending, getMessageSnapshot, getSessionState, markRetrievalInjected, setMessageSnapshot, } from "../lib/autosave";
2
2
  import { loadConfig } from "../lib/config";
3
- import { buildAutosaveInstruction, buildKeywordSaveInstruction, buildRetrievalInstruction } from "../lib/context";
3
+ import { LOG_MESSAGES } from "../lib/constants";
4
+ import { buildKeywordSaveInstruction, buildRetrievalInstruction } from "../lib/context";
4
5
  import { writeLog } from "../lib/log";
6
+ import { getProjectName, loadSessionMessages } from "../lib/opencode";
5
7
  export const systemHooks = (ctx) => {
6
8
  return {
7
- "experimental.chat.system.transform": async (_input, output) => {
9
+ "experimental.chat.system.transform": async (input, output) => {
8
10
  try {
9
- const sessionId = getCurrentTurnSessionId();
11
+ const sessionId = input.sessionID;
10
12
  if (!sessionId)
11
13
  return;
12
14
  const config = await loadConfig();
13
15
  const state = getSessionState(sessionId);
14
- const response = await ctx.client.session.messages({ path: { id: sessionId } });
15
- const messages = response?.data ?? response ?? [];
16
- const lastUserMessage = extractLastUserMessage(messages);
16
+ const cachedSnapshot = getMessageSnapshot(sessionId);
17
+ const snapshot = cachedSnapshot ?? buildMessageSnapshot(await loadSessionMessages(ctx.client, sessionId));
18
+ if (!cachedSnapshot) {
19
+ setMessageSnapshot(sessionId, snapshot);
20
+ }
21
+ const lastUserMessage = snapshot.lastUserMessage;
17
22
  if (config.retrievalEnabled && state.retrievalPending && lastUserMessage) {
18
23
  output.system.push(buildRetrievalInstruction({
19
- projectName: ctx.project?.name,
24
+ projectName: getProjectName(ctx.project),
20
25
  projectWingPrefix: config.projectWingPrefix,
21
26
  userWingPrefix: config.userWingPrefix,
22
27
  maxInjectedItems: config.maxInjectedItems,
@@ -24,24 +29,16 @@ export const systemHooks = (ctx) => {
24
29
  lastUserMessage,
25
30
  }));
26
31
  markRetrievalInjected(sessionId);
27
- await writeLog("INFO", "injected retrieval instruction", { sessionId });
32
+ await writeLog("INFO", LOG_MESSAGES.injectedRetrievalInstruction, { sessionId });
28
33
  }
29
34
  if (state.keywordSavePending) {
30
35
  output.system.push(buildKeywordSaveInstruction());
31
36
  clearKeywordSavePending(sessionId);
32
- await writeLog("INFO", "injected keyword save instruction", { sessionId });
37
+ await writeLog("INFO", LOG_MESSAGES.injectedKeywordSaveInstruction, { sessionId });
33
38
  }
34
- if (state.status !== AutosaveStatus.Pending || !state.pendingReason)
35
- return;
36
- output.system.push(buildAutosaveInstruction(state.pendingReason));
37
- startAutosave(sessionId);
38
- await writeLog("INFO", "injected hidden autosave instruction", {
39
- sessionId,
40
- reason: state.pendingReason,
41
- });
42
39
  }
43
40
  catch (error) {
44
- await writeLog("ERROR", "system transform hook failed", {
41
+ await writeLog("ERROR", LOG_MESSAGES.systemTransformHookFailed, {
45
42
  error: error instanceof Error ? error.message : String(error),
46
43
  });
47
44
  }
@@ -3,15 +3,7 @@ export declare const toolHooks: () => {
3
3
  tool: string;
4
4
  sessionID?: string;
5
5
  callID?: string;
6
- }, output: {
7
- args: any;
8
- }) => Promise<void>;
9
- "tool.execute.after": (input: {
10
- tool: string;
11
- sessionID?: string;
12
- callID?: string;
13
- }, output: {
14
- output: string;
15
- metadata: any;
6
+ }, _output: {
7
+ args: Record<string, unknown>;
16
8
  }) => Promise<void>;
17
9
  };
@@ -1,60 +1,15 @@
1
+ import { LOG_MESSAGES, TOOL_ERROR_MESSAGES } from "../lib/constants";
1
2
  import { writeLog } from "../lib/log";
2
- import { AutosaveStatus, getSessionState, markMutationToolCall, recordSuccessfulTool, shouldCountSuccessfulTool } from "../lib/autosave";
3
3
  import { isDirectMempalaceMutationTool } from "../lib/enforcement";
4
- const isMempalaceTool = (tool) => tool === "mempalace_memory";
5
- const isSuccessfulToolOutput = (output) => {
6
- const text = `${output.output || ""} ${JSON.stringify(output.metadata || {})}`.toLowerCase();
7
- return !text.includes("\"success\":false") && !text.includes("error");
8
- };
9
- const getMetadataSessionId = (metadata) => {
10
- return metadata?.sessionID || metadata?.sessionId || metadata?.session?.id;
11
- };
12
4
  export const toolHooks = () => {
13
5
  return {
14
- "tool.execute.before": async (input, output) => {
6
+ "tool.execute.before": async (input, _output) => {
15
7
  if (isDirectMempalaceMutationTool(input.tool)) {
16
- await writeLog("WARN", "blocked direct mempalace mutation tool", {
8
+ await writeLog("WARN", LOG_MESSAGES.blockedDirectMempalaceMutationTool, {
17
9
  tool: input.tool,
18
10
  sessionId: input.sessionID,
19
11
  });
20
- throw new Error("Use mempalace_memory instead of direct MemPalace mutation tools");
21
- }
22
- if (input.tool !== "mempalace_memory" || !input.sessionID)
23
- return;
24
- const mode = output.args?.mode;
25
- if (mode === "save" || mode === "kg_add" || mode === "diary_write") {
26
- markMutationToolCall(input.sessionID, input.callID);
27
- }
28
- },
29
- "tool.execute.after": async (input, output) => {
30
- try {
31
- if (!input.sessionID) {
32
- await writeLog("WARN", "tool.execute.after missing sessionID", { tool: input.tool });
33
- return;
34
- }
35
- if (!isMempalaceTool(input.tool))
36
- return;
37
- const state = getSessionState(input.sessionID);
38
- if (state.status !== AutosaveStatus.Running)
39
- return;
40
- const metadataSessionId = getMetadataSessionId(output.metadata);
41
- if (metadataSessionId && metadataSessionId !== input.sessionID)
42
- return;
43
- if (!isSuccessfulToolOutput(output))
44
- return;
45
- if (!shouldCountSuccessfulTool(input.sessionID, input.tool, input.callID))
46
- return;
47
- recordSuccessfulTool(input.sessionID, input.tool, input.callID);
48
- await writeLog("INFO", "observed mempalace tool during autosave", {
49
- sessionId: input.sessionID,
50
- tool: input.tool,
51
- count: getSessionState(input.sessionID).successfulToolCalls.length,
52
- });
53
- }
54
- catch (error) {
55
- await writeLog("ERROR", "tool.execute.after hook failed", {
56
- error: error instanceof Error ? error.message : String(error),
57
- });
12
+ throw new Error(TOOL_ERROR_MESSAGES.directMutationBlocked);
58
13
  }
59
14
  },
60
15
  };
@@ -1,4 +1,3 @@
1
- import { chatParamHooks } from "./hooks/chat-params";
2
1
  import { eventHooks } from "./hooks/event";
3
2
  import { systemHooks } from "./hooks/system";
4
3
  import { toolHooks } from "./hooks/tool";
@@ -7,7 +6,6 @@ import { mempalaceMemoryTool } from "./tools/mempalace-memory";
7
6
  export const MempalaceAutosavePlugin = async (ctx) => {
8
7
  setLogger(ctx.client);
9
8
  return {
10
- ...chatParamHooks(),
11
9
  ...eventHooks(ctx),
12
10
  ...systemHooks(ctx),
13
11
  ...toolHooks(),
@@ -1 +1,2 @@
1
- export declare const executeAdapter: (_shell: any, payload: Record<string, unknown>, retries?: number) => Promise<any>;
1
+ import type { AdapterRequest, AdapterResponse } from "./types";
2
+ export declare const executeAdapter: (_shell: unknown, payload: AdapterRequest, retries?: number) => Promise<AdapterResponse>;
@@ -1,35 +1,68 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { DEFAULT_ADAPTER_TIMEOUT_MS, ENV_KEYS, TOOL_ERROR_MESSAGES } from "./constants";
4
5
  const getAdapterPath = () => {
5
6
  const here = path.dirname(fileURLToPath(import.meta.url));
6
7
  return path.resolve(here, "..", "..", "bridge", "mempalace_adapter.py");
7
8
  };
8
- const getPythonCommand = () => process.env.MEMPALACE_ADAPTER_PYTHON || "python";
9
+ const getPythonCommand = () => process.env[ENV_KEYS.adapterPython] || "python";
10
+ const getAdapterTimeoutMs = () => {
11
+ const raw = process.env[ENV_KEYS.adapterTimeoutMs];
12
+ if (!raw)
13
+ return DEFAULT_ADAPTER_TIMEOUT_MS;
14
+ const parsed = Number(raw);
15
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ADAPTER_TIMEOUT_MS;
16
+ };
17
+ const isAdapterResponse = (value) => {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ };
9
20
  export const executeAdapter = async (_shell, payload, retries = 3) => {
10
21
  let lastError;
22
+ const timeoutMs = getAdapterTimeoutMs();
11
23
  for (let attempt = 0; attempt <= retries; attempt += 1) {
12
24
  try {
13
25
  const text = await new Promise((resolve, reject) => {
14
26
  const child = spawn(getPythonCommand(), [getAdapterPath()], {
15
27
  stdio: ["pipe", "pipe", "pipe"],
16
28
  });
29
+ let settled = false;
17
30
  const stdout = [];
18
31
  const stderr = [];
32
+ const timeoutId = setTimeout(() => {
33
+ child.kill();
34
+ if (settled)
35
+ return;
36
+ settled = true;
37
+ reject(new Error(`${TOOL_ERROR_MESSAGES.adapterTimedOut} after ${timeoutMs}ms`));
38
+ }, timeoutMs);
39
+ const finish = (handler) => {
40
+ if (settled)
41
+ return;
42
+ settled = true;
43
+ clearTimeout(timeoutId);
44
+ handler();
45
+ };
19
46
  child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
20
47
  child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
21
- child.on("error", reject);
48
+ child.on("error", (error) => finish(() => reject(error)));
22
49
  child.on("close", (code) => {
23
- if (code === 0) {
24
- resolve(Buffer.concat(stdout).toString("utf8"));
25
- return;
26
- }
27
- reject(new Error(Buffer.concat(stderr).toString("utf8") || `Adapter exited with code ${code}`));
50
+ finish(() => {
51
+ if (code === 0) {
52
+ resolve(Buffer.concat(stdout).toString("utf8"));
53
+ return;
54
+ }
55
+ reject(new Error(Buffer.concat(stderr).toString("utf8") || `Adapter exited with code ${code}`));
56
+ });
28
57
  });
29
58
  child.stdin.write(JSON.stringify(payload), "utf8");
30
59
  child.stdin.end();
31
60
  });
32
- return JSON.parse(text);
61
+ const parsed = JSON.parse(text);
62
+ if (!isAdapterResponse(parsed)) {
63
+ throw new Error(TOOL_ERROR_MESSAGES.invalidAdapterPayload);
64
+ }
65
+ return parsed;
33
66
  }
34
67
  catch (error) {
35
68
  lastError = error;