supipowers 2.0.2 → 2.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 +5 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +8 -133
- package/src/commands/optimize-context.ts +153 -16
- package/src/commands/runbook.ts +511 -0
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- package/src/context/rule-renderer.ts +274 -2
- package/src/context/runbook-extension-template.ts +193 -0
- package/src/context/startup-check.ts +197 -2
- package/src/context/startup-optimizer.ts +133 -10
- package/src/context-mode/knowledge/store.ts +381 -43
- package/src/context-mode/tools.ts +41 -3
- package/src/deps/registry.ts +1 -12
- package/src/fix-pr/assessment.ts +1 -0
- package/src/fix-pr/prompt-builder.ts +1 -0
- package/src/git/commit.ts +76 -18
- package/src/harness/command.ts +201 -12
- package/src/harness/default-agents/docs.md +39 -0
- package/src/harness/docs/config.ts +29 -0
- package/src/harness/docs/glob-match.ts +27 -0
- package/src/harness/docs/index-renderer.ts +82 -0
- package/src/harness/docs/provenance.ts +125 -0
- package/src/harness/docs/regen-decision.ts +167 -0
- package/src/harness/docs/representative-files.ts +175 -0
- package/src/harness/docs/source-hash.ts +106 -0
- package/src/harness/docs/validator.ts +233 -0
- package/src/harness/git-verification.ts +515 -0
- package/src/harness/git-verify-qa.ts +406 -0
- package/src/harness/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +37 -13
- package/src/harness/pr-comment/baseline.ts +105 -0
- package/src/harness/pr-comment/ci-env.ts +120 -0
- package/src/harness/pr-comment/gh-poster.ts +227 -0
- package/src/harness/pr-comment/handler.ts +198 -0
- package/src/harness/pr-comment/render.ts +297 -0
- package/src/harness/pr-comment/status.ts +95 -0
- package/src/harness/pr-comment/types.ts +73 -0
- package/src/harness/pr-comment/workflow-summary.ts +47 -0
- package/src/harness/project-paths.ts +95 -0
- package/src/harness/stages/design.ts +1 -0
- package/src/harness/stages/discover.ts +1 -13
- package/src/harness/stages/docs.ts +708 -0
- package/src/harness/stages/implement-apply.ts +934 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +478 -0
- package/src/harness/storage.ts +142 -0
- package/src/harness/tools.ts +130 -0
- package/src/mempalace/bridge.ts +207 -41
- package/src/mempalace/config.ts +10 -4
- package/src/mempalace/format.ts +122 -6
- package/src/mempalace/hooks.ts +204 -56
- package/src/mempalace/installer-helper.ts +18 -4
- package/src/mempalace/python/mempalace_bridge.py +128 -3
- package/src/mempalace/runtime.ts +53 -16
- package/src/mempalace/schema.ts +151 -30
- package/src/mempalace/session-summary.ts +5 -0
- package/src/mempalace/tool.ts +17 -4
- package/src/mempalace/upstream-limits.ts +69 -0
- package/src/planning/approval-flow.ts +25 -2
- package/src/planning/planning-ask-tool.ts +34 -4
- package/src/planning/system-prompt.ts +1 -1
- package/src/tool-catalog/active-tool-controller.ts +0 -22
- package/src/tool-catalog/active-tool-planner.ts +0 -26
- package/src/tool-catalog/tool-groups.ts +1 -9
- package/src/types.ts +127 -8
- package/src/ui-design/session.ts +114 -8
- package/src/utils/executable.ts +10 -1
- package/src/workspace/state-paths.ts +1 -1
- package/src/commands/mcp.ts +0 -814
- package/src/mcp/activation.ts +0 -77
- package/src/mcp/config.ts +0 -223
- package/src/mcp/docs.ts +0 -154
- package/src/mcp/gateway.ts +0 -103
- package/src/mcp/lifecycle.ts +0 -79
- package/src/mcp/manager-tool.ts +0 -104
- package/src/mcp/mcpc.ts +0 -113
- package/src/mcp/registry.ts +0 -98
- package/src/mcp/triggers.ts +0 -62
- package/src/mcp/types.ts +0 -95
package/src/mempalace/hooks.ts
CHANGED
|
@@ -3,17 +3,73 @@ import { normalizeSystemPromptBlocks } from "../platform/system-prompt.js";
|
|
|
3
3
|
import type { SupipowersConfig } from "../types.js";
|
|
4
4
|
import { createMempalaceBridge, type MempalaceBridgeFacade } from "./bridge.js";
|
|
5
5
|
import { resolveDefaultWing, resolveMempalaceConfig, type ResolvedMempalaceConfig } from "./config.js";
|
|
6
|
+
import { resolveInstalledBridgeScriptPath } from "./runtime.js";
|
|
6
7
|
import { getEventStore as getContextEventStore, getSessionId as getContextSessionId } from "../context-mode/hooks.js";
|
|
7
8
|
import { buildCompactionCheckpoint, buildShutdownDiary } from "./session-summary.js";
|
|
9
|
+
import { snapshotMempalaceInstall } from "./installer-helper.js";
|
|
8
10
|
|
|
9
11
|
export interface MempalaceHooksDeps {
|
|
10
12
|
createBridge?: (config: ResolvedMempalaceConfig, cwd: string) => MempalaceBridgeFacade;
|
|
11
13
|
getEventStore?: () => Parameters<typeof buildCompactionCheckpoint>[0]["eventStore"];
|
|
12
14
|
getSessionId?: () => string;
|
|
13
15
|
now?: () => string;
|
|
16
|
+
snapshotInstall?: (paths: Platform["paths"], cwd: string, config: SupipowersConfig) => { ready: boolean };
|
|
14
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
/** Maximum number of (sessionId × wing × palace) entries to keep in memory. */
|
|
20
|
+
const HOOK_CACHE_LRU_CAP = 64;
|
|
21
|
+
|
|
22
|
+
/** Insertion-ordered bounded LRU. Drops the least-recently-used entry on overflow. */
|
|
23
|
+
class BoundedLRU<K, V> {
|
|
24
|
+
private readonly inner = new Map<K, V>();
|
|
25
|
+
|
|
26
|
+
constructor(private readonly cap: number) {}
|
|
27
|
+
|
|
28
|
+
get(key: K): V | undefined {
|
|
29
|
+
if (!this.inner.has(key)) return undefined;
|
|
30
|
+
const value = this.inner.get(key) as V;
|
|
31
|
+
this.inner.delete(key);
|
|
32
|
+
this.inner.set(key, value);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
set(key: K, value: V): void {
|
|
37
|
+
if (this.inner.has(key)) {
|
|
38
|
+
this.inner.delete(key);
|
|
39
|
+
} else if (this.inner.size >= this.cap) {
|
|
40
|
+
const oldest = this.inner.keys().next().value;
|
|
41
|
+
if (oldest !== undefined) this.inner.delete(oldest);
|
|
42
|
+
}
|
|
43
|
+
this.inner.set(key, value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
delete(key: K): boolean {
|
|
47
|
+
return this.inner.delete(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
clear(): void {
|
|
51
|
+
this.inner.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
keys(): IterableIterator<K> {
|
|
55
|
+
return this.inner.keys();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function warnHookStateFallback(platform: Platform, message: string): void {
|
|
60
|
+
const logger = (platform as { logger?: { warn?: (message: string) => void } }).logger;
|
|
61
|
+
if (typeof logger?.warn === "function") {
|
|
62
|
+
logger.warn(message);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.warn(message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hookTimeoutSeconds(timeoutMs: number): number {
|
|
69
|
+
return Math.max(1, Math.floor(timeoutMs / 1000));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const wakeUpCache = new BoundedLRU<string, string>(HOOK_CACHE_LRU_CAP);
|
|
17
73
|
|
|
18
74
|
/**
|
|
19
75
|
* Per-session turn counter for wake-up cadence gating. The full wake-up block
|
|
@@ -21,7 +77,7 @@ const wakeUpCache = new Map<string, string>();
|
|
|
21
77
|
* `mempalace.budgets.wakeUpInjectionEvery`); other turns get a one-line
|
|
22
78
|
* refresher. Cleared on session_start / session_switch.
|
|
23
79
|
*/
|
|
24
|
-
const turnCounters = new
|
|
80
|
+
const turnCounters = new BoundedLRU<string, number>(HOOK_CACHE_LRU_CAP);
|
|
25
81
|
|
|
26
82
|
/** Test-only: reset cadence state between cases. */
|
|
27
83
|
export function _resetMempalaceHookState(): void {
|
|
@@ -91,6 +147,15 @@ function setupGuidanceBlock(resolved: ResolvedMempalaceConfig, wing: string): st
|
|
|
91
147
|
].join("\n");
|
|
92
148
|
}
|
|
93
149
|
|
|
150
|
+
function wakeFailureBlock(resolved: ResolvedMempalaceConfig, wing: string, error: string): string {
|
|
151
|
+
return [
|
|
152
|
+
"# MemPalace memory",
|
|
153
|
+
`- palace: ${resolved.palacePath}`,
|
|
154
|
+
`- default wing: ${wing}`,
|
|
155
|
+
`- Wake-up failed: ${error}`,
|
|
156
|
+
].join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
94
159
|
function wakeUpBlock(resolved: ResolvedMempalaceConfig, wing: string, text: string): string {
|
|
95
160
|
const excerpt = truncateByTokenBudget(text, resolved.budgets.wakeUpTokens);
|
|
96
161
|
const lines = [
|
|
@@ -139,19 +204,33 @@ function extractUserPrompt(event: unknown): string {
|
|
|
139
204
|
/** Minimum prompt length below which we skip auto-search (saves a bridge call). */
|
|
140
205
|
const AUTO_SEARCH_MIN_PROMPT_CHARS = 15;
|
|
141
206
|
|
|
142
|
-
/** Cap on the search query length. Long prompts are truncated to the first N chars. */
|
|
143
|
-
const AUTO_SEARCH_QUERY_MAX_CHARS = 500;
|
|
144
|
-
|
|
145
207
|
/**
|
|
146
|
-
* Returns `true` when `prompt`
|
|
147
|
-
*
|
|
208
|
+
* Returns `true` when `prompt` warrants a MemPalace auto-search.
|
|
209
|
+
* Rules applied in order:
|
|
210
|
+
* 1. Too-short or trivial filler word → skip (saves the bridge round-trip).
|
|
211
|
+
* 2. Contains "?" or starts with a question word → search.
|
|
212
|
+
* 3. Contains a memory/recall signal → search.
|
|
213
|
+
* 4. Starts with a clearly imperative verb and has no search signal → skip.
|
|
214
|
+
* 5. Ambiguous → search (preserves recall on uncertain prompts).
|
|
148
215
|
*/
|
|
149
|
-
function
|
|
216
|
+
function shouldAutoSearchPrompt(prompt: string): boolean {
|
|
150
217
|
const normalized = prompt.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
151
|
-
|
|
152
|
-
|
|
218
|
+
// Rule 1: trivial length or obvious filler.
|
|
219
|
+
if (normalized.length < AUTO_SEARCH_MIN_PROMPT_CHARS) return false;
|
|
153
220
|
const TRIVIAL = new Set(["yes", "no", "ok", "okay", "thanks", "thank you", "great", "cool", "go", "continue", "proceed"]);
|
|
154
|
-
|
|
221
|
+
if (TRIVIAL.has(normalized)) return false;
|
|
222
|
+
// Rule 2: explicit question signals → search.
|
|
223
|
+
if (prompt.includes("?")) return true;
|
|
224
|
+
const QUESTION_PREFIXES = ["what", "why", "when", "who", "where", "how", "which", "do", "does", "is", "are", "can", "should"];
|
|
225
|
+
if (QUESTION_PREFIXES.some(p => normalized.startsWith(p + " ") || normalized === p)) return true;
|
|
226
|
+
// Rule 3: memory/recall signal words → search.
|
|
227
|
+
const RECALL_SIGNALS = ["remember", "recall", "decided", "decision", "chose", "last time", "previously", "earlier", "before"];
|
|
228
|
+
if (RECALL_SIGNALS.some(s => normalized.includes(s))) return true;
|
|
229
|
+
// Rule 4: clearly imperative verb at start, no search signal above → skip.
|
|
230
|
+
const IMPERATIVE_PREFIXES = ["fix", "add", "remove", "delete", "run", "update", "refactor", "rename", "move", "write", "create", "make", "implement", "build"];
|
|
231
|
+
if (IMPERATIVE_PREFIXES.some(p => normalized.startsWith(p + " ") || normalized === p)) return false;
|
|
232
|
+
// Rule 5: ambiguous → search.
|
|
233
|
+
return true;
|
|
155
234
|
}
|
|
156
235
|
|
|
157
236
|
interface SearchHit {
|
|
@@ -169,13 +248,13 @@ function pickHits(result: unknown): SearchHit[] {
|
|
|
169
248
|
}
|
|
170
249
|
|
|
171
250
|
/** Score-gated relevance check so we don't inject low-quality matches as noise. */
|
|
172
|
-
function isRelevantHit(hit: SearchHit): boolean {
|
|
251
|
+
function isRelevantHit(hit: SearchHit, similarityFloor: number, bm25Floor: number): boolean {
|
|
173
252
|
const sim = typeof hit.similarity === "number" ? hit.similarity : null;
|
|
174
253
|
const bm25 = typeof hit.bm25_score === "number" ? hit.bm25_score : null;
|
|
175
|
-
// Either signal must clear
|
|
254
|
+
// Either signal must clear the configured floor. similarity is ~1.0 for
|
|
176
255
|
// perfect, ~0.5 for "kinda related"; bm25 is unbounded but >0.3 is meaningful.
|
|
177
|
-
if (sim !== null && sim >=
|
|
178
|
-
if (bm25 !== null && bm25 >=
|
|
256
|
+
if (sim !== null && sim >= similarityFloor) return true;
|
|
257
|
+
if (bm25 !== null && bm25 >= bm25Floor) return true;
|
|
179
258
|
return false;
|
|
180
259
|
}
|
|
181
260
|
|
|
@@ -210,12 +289,38 @@ export function registerMempalaceHooks(
|
|
|
210
289
|
): void {
|
|
211
290
|
if (!config.mempalace.enabled) return;
|
|
212
291
|
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
292
|
+
const snapshotInstall = deps.snapshotInstall ?? snapshotMempalaceInstall;
|
|
293
|
+
const isInstallReady = (cwd: string): boolean => snapshotInstall(platform.paths, cwd, config).ready;
|
|
294
|
+
|
|
295
|
+
const bridgeRuntime = {
|
|
296
|
+
resolveBridgeScriptPath: () => resolveInstalledBridgeScriptPath(platform.paths),
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const clearSessionState = (sessionId: string): void => {
|
|
300
|
+
for (const key of [...wakeUpCache.keys()]) {
|
|
301
|
+
if (key.startsWith(`${sessionId}|`)) wakeUpCache.delete(key);
|
|
302
|
+
}
|
|
303
|
+
for (const key of [...turnCounters.keys()]) {
|
|
304
|
+
if (key.startsWith(`${sessionId}|`)) turnCounters.delete(key);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const clearForSession = (event: unknown): void => {
|
|
309
|
+
const source = typeof event === "object" && event !== null ? event as { sessionId?: unknown; previousSessionId?: unknown } : null;
|
|
310
|
+
const sessionId = typeof source?.sessionId === "string" && source.sessionId.length > 0 ? source.sessionId : null;
|
|
311
|
+
const previousSessionId =
|
|
312
|
+
typeof source?.previousSessionId === "string" && source.previousSessionId.length > 0 ? source.previousSessionId : null;
|
|
313
|
+
if (sessionId === null && previousSessionId === null) {
|
|
314
|
+
warnHookStateFallback(platform, "[mempalace hooks] session event missing sessionId — clearing all hook state");
|
|
315
|
+
wakeUpCache.clear();
|
|
316
|
+
turnCounters.clear();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (sessionId !== null) clearSessionState(sessionId);
|
|
320
|
+
if (previousSessionId !== null) clearSessionState(previousSessionId);
|
|
216
321
|
};
|
|
217
|
-
platform.on("session_start",
|
|
218
|
-
platform.on("session_switch",
|
|
322
|
+
platform.on("session_start", clearForSession);
|
|
323
|
+
platform.on("session_switch", clearForSession);
|
|
219
324
|
|
|
220
325
|
platform.on("before_agent_start", async (event: unknown, ctx: unknown) => {
|
|
221
326
|
const wakeUpEnabled = config.mempalace.hooks.wakeUp;
|
|
@@ -234,11 +339,14 @@ export function registerMempalaceHooks(
|
|
|
234
339
|
const sessionId = sessionIdFrom(event, ctx);
|
|
235
340
|
const cacheKey = `${sessionId}|${wing}|${resolved.palacePath}`;
|
|
236
341
|
const basePrompt = currentSystemPromptBlocks(event, ctx);
|
|
342
|
+
if (!isInstallReady(cwd)) {
|
|
343
|
+
return appendPrompt(basePrompt, setupGuidanceBlock(resolved, wing));
|
|
344
|
+
}
|
|
237
345
|
const userPrompt = extractUserPrompt(event);
|
|
238
346
|
|
|
239
347
|
const bridge = deps.createBridge
|
|
240
348
|
? deps.createBridge(resolved, cwd)
|
|
241
|
-
: createMempalaceBridge({ cwd, config: resolved });
|
|
349
|
+
: createMempalaceBridge({ cwd, config: resolved, runtime: bridgeRuntime });
|
|
242
350
|
|
|
243
351
|
// Cadence gating: wake-up dump on turn 1 and every Nth turn; refresher
|
|
244
352
|
// otherwise. Saves ~750 tokens/turn average for a default cadence of 10.
|
|
@@ -248,42 +356,78 @@ export function registerMempalaceHooks(
|
|
|
248
356
|
turnCounters.set(turnKey, turnCount);
|
|
249
357
|
const isFullInjectionTurn = turnCount === 1 || turnCount % cadence === 0;
|
|
250
358
|
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
359
|
+
// On cadence-gated turns: one bridge.execute call handles both wake and search
|
|
360
|
+
// (wake_up_and_search). On non-injection turns: refresher string + optional
|
|
361
|
+
// separate search call. Either path issues at most one bridge call per turn.
|
|
362
|
+
const timeoutSeconds = hookTimeoutSeconds(resolved.timeouts.hookMs); /* seconds; bridge multiplies by 1000 */
|
|
363
|
+
const wantsSearch = autoSearchEnabled && shouldAutoSearchPrompt(userPrompt);
|
|
364
|
+
// Let upstream MemPalace extract the salient question/tail from long prompts.
|
|
365
|
+
const query = wantsSearch ? userPrompt : undefined;
|
|
366
|
+
|
|
367
|
+
let wakeBlock: string;
|
|
368
|
+
let searchBlock: string | null = null;
|
|
369
|
+
|
|
370
|
+
if (!isFullInjectionTurn) {
|
|
371
|
+
// Non-injection turns: lightweight refresher, no wake bridge call needed.
|
|
372
|
+
wakeBlock = wakeUpRefresher(resolved, wing);
|
|
373
|
+
if (wantsSearch) {
|
|
374
|
+
try {
|
|
375
|
+
const result = await bridge.execute({ action: "search", query: query!, wing, limit: 3, timeout: timeoutSeconds });
|
|
376
|
+
if (result.ok) {
|
|
377
|
+
const { autoSearchSimilarityFloor, autoSearchBm25Floor } = resolved.budgets;
|
|
378
|
+
const hits = pickHits(result.result).filter(hit => isRelevantHit(hit, autoSearchSimilarityFloor, autoSearchBm25Floor));
|
|
379
|
+
searchBlock = autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
// Auto-search is best-effort. A failure here must never block the turn.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
255
386
|
const cached = wakeUpCache.get(cacheKey);
|
|
256
|
-
if (cached)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
387
|
+
if (cached) {
|
|
388
|
+
// Wake block is already cached — only issue a search call if warranted.
|
|
389
|
+
wakeBlock = cached;
|
|
390
|
+
if (wantsSearch) {
|
|
391
|
+
try {
|
|
392
|
+
const result = await bridge.execute({ action: "search", query: query!, wing, limit: 3, timeout: timeoutSeconds });
|
|
393
|
+
if (result.ok) {
|
|
394
|
+
const { autoSearchSimilarityFloor, autoSearchBm25Floor } = resolved.budgets;
|
|
395
|
+
const hits = pickHits(result.result).filter(hit => isRelevantHit(hit, autoSearchSimilarityFloor, autoSearchBm25Floor));
|
|
396
|
+
searchBlock = autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
|
|
397
|
+
}
|
|
398
|
+
} catch {
|
|
399
|
+
// Auto-search is best-effort. A failure here must never block the turn.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} else {
|
|
403
|
+
// Cache miss: batch wake + search into one bridge call.
|
|
404
|
+
const batchResult = await bridge.execute({
|
|
405
|
+
action: "wake_up_and_search",
|
|
273
406
|
wing,
|
|
274
|
-
|
|
275
|
-
|
|
407
|
+
timeout: timeoutSeconds,
|
|
408
|
+
...(query !== undefined ? { query, limit: 3 } : {}),
|
|
276
409
|
});
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
410
|
+
const composite = batchResult.ok ? (batchResult.result as Record<string, unknown>) : null;
|
|
411
|
+
const compositeWake = composite !== null ? (composite.wake as Record<string, unknown> | null | undefined) : undefined;
|
|
412
|
+
const wakeError = composite !== null && typeof composite.wake_error === "string" ? composite.wake_error : "wake_up failed";
|
|
413
|
+
const block = compositeWake != null
|
|
414
|
+
? wakeUpBlock(resolved, wing, wakeText(compositeWake))
|
|
415
|
+
: batchResult.ok
|
|
416
|
+
? wakeFailureBlock(resolved, wing, wakeError)
|
|
417
|
+
: setupGuidanceBlock(resolved, wing);
|
|
418
|
+
wakeUpCache.set(cacheKey, block);
|
|
419
|
+
wakeBlock = block;
|
|
420
|
+
|
|
421
|
+
// Extract search hits from the composite result (only if search was requested).
|
|
422
|
+
const compositeSearch = composite !== null ? composite.search : undefined;
|
|
423
|
+
if (compositeSearch != null && autoSearchEnabled) {
|
|
424
|
+
const { autoSearchSimilarityFloor, autoSearchBm25Floor } = resolved.budgets;
|
|
425
|
+
const hits = pickHits(compositeSearch).filter(hit => isRelevantHit(hit, autoSearchSimilarityFloor, autoSearchBm25Floor));
|
|
426
|
+
searchBlock = autoSearchBlock(hits, resolved.budgets.autoSearchTokens);
|
|
427
|
+
}
|
|
283
428
|
}
|
|
284
|
-
}
|
|
429
|
+
}
|
|
285
430
|
|
|
286
|
-
const [wakeBlock, searchBlock] = await Promise.all([wakePromise, searchPromise]);
|
|
287
431
|
const combined = searchBlock ? `${wakeBlock}\n${searchBlock}` : wakeBlock;
|
|
288
432
|
return appendPrompt(basePrompt, combined);
|
|
289
433
|
});
|
|
@@ -292,6 +436,7 @@ export function registerMempalaceHooks(
|
|
|
292
436
|
platform.on("session_before_compact", async (_event: unknown, ctx: unknown) => {
|
|
293
437
|
try {
|
|
294
438
|
const cwd = contextCwd(ctx);
|
|
439
|
+
if (!isInstallReady(cwd)) return undefined;
|
|
295
440
|
const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
|
|
296
441
|
let wing: string;
|
|
297
442
|
try {
|
|
@@ -311,7 +456,7 @@ export function registerMempalaceHooks(
|
|
|
311
456
|
});
|
|
312
457
|
const bridge = deps.createBridge
|
|
313
458
|
? deps.createBridge(resolved, cwd)
|
|
314
|
-
: createMempalaceBridge({ cwd, config: resolved });
|
|
459
|
+
: createMempalaceBridge({ cwd, config: resolved, runtime: bridgeRuntime });
|
|
315
460
|
await bridge.execute({
|
|
316
461
|
action: "add_drawer",
|
|
317
462
|
wing: checkpoint.metadata.wing,
|
|
@@ -319,7 +464,7 @@ export function registerMempalaceHooks(
|
|
|
319
464
|
content: checkpoint.content,
|
|
320
465
|
added_by: checkpoint.metadata.added_by,
|
|
321
466
|
source_file: checkpoint.metadata.source_file,
|
|
322
|
-
timeout: resolved.timeouts.hookMs,
|
|
467
|
+
timeout: hookTimeoutSeconds(resolved.timeouts.hookMs), /* seconds; bridge multiplies by 1000 */
|
|
323
468
|
});
|
|
324
469
|
} catch {
|
|
325
470
|
// Compaction must never be cancelled by MemPalace checkpoint failures.
|
|
@@ -332,6 +477,7 @@ export function registerMempalaceHooks(
|
|
|
332
477
|
platform.on("session_shutdown", async (_event: unknown, ctx: unknown) => {
|
|
333
478
|
try {
|
|
334
479
|
const cwd = contextCwd(ctx);
|
|
480
|
+
if (!isInstallReady(cwd)) return undefined;
|
|
335
481
|
const resolved = resolveMempalaceConfig(config, cwd, platform.paths);
|
|
336
482
|
let wing: string;
|
|
337
483
|
try {
|
|
@@ -351,7 +497,7 @@ export function registerMempalaceHooks(
|
|
|
351
497
|
});
|
|
352
498
|
const bridge = deps.createBridge
|
|
353
499
|
? deps.createBridge(resolved, cwd)
|
|
354
|
-
: createMempalaceBridge({ cwd, config: resolved });
|
|
500
|
+
: createMempalaceBridge({ cwd, config: resolved, runtime: bridgeRuntime });
|
|
355
501
|
await bridge.execute({
|
|
356
502
|
action: "diary_write",
|
|
357
503
|
agent_name: diary.metadata.agent_name,
|
|
@@ -359,7 +505,7 @@ export function registerMempalaceHooks(
|
|
|
359
505
|
topic: diary.metadata.topic,
|
|
360
506
|
entry: diary.entry,
|
|
361
507
|
source_file: diary.metadata.source_file,
|
|
362
|
-
timeout: resolved.timeouts.hookMs,
|
|
508
|
+
timeout: hookTimeoutSeconds(resolved.timeouts.hookMs), /* seconds; bridge multiplies by 1000 */
|
|
363
509
|
});
|
|
364
510
|
} catch {
|
|
365
511
|
// Shutdown must never be delayed or failed by MemPalace diary writes.
|
|
@@ -367,4 +513,6 @@ export function registerMempalaceHooks(
|
|
|
367
513
|
return undefined;
|
|
368
514
|
});
|
|
369
515
|
}
|
|
516
|
+
|
|
517
|
+
platform.on("session_shutdown", clearForSession);
|
|
370
518
|
}
|
|
@@ -6,7 +6,7 @@ import type { SupipowersConfig } from "../types.js";
|
|
|
6
6
|
import { createMempalaceBridge, type MempalaceBridgeFacade } from "./bridge.js";
|
|
7
7
|
import { resolveDefaultWing, resolveMempalaceConfig } from "./config.js";
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
resolveInstalledBridgeScriptPath,
|
|
10
10
|
resolveManagedVenvPaths,
|
|
11
11
|
setupMempalaceRuntime,
|
|
12
12
|
type ProcessRunner,
|
|
@@ -44,7 +44,7 @@ export function snapshotMempalaceInstall(
|
|
|
44
44
|
const managedBinDir = paths.global("bin");
|
|
45
45
|
const uvBinary = process.platform === "win32" ? "uv.exe" : "uv";
|
|
46
46
|
const uvPath = path.join(managedBinDir, uvBinary);
|
|
47
|
-
const bridge =
|
|
47
|
+
const bridge = resolveInstalledBridgeScriptPath(paths);
|
|
48
48
|
|
|
49
49
|
const uvInstalled = existsSync(uvPath);
|
|
50
50
|
const venvInstalled = existsSync(venv.python);
|
|
@@ -85,7 +85,7 @@ export async function runMempalaceSetup(
|
|
|
85
85
|
): Promise<SetupMempalaceRuntimeResult> {
|
|
86
86
|
const config = options.config ?? DEFAULT_CONFIG;
|
|
87
87
|
const resolved = resolveMempalaceConfig(config, options.cwd, options.paths);
|
|
88
|
-
const bridge =
|
|
88
|
+
const bridge = resolveInstalledBridgeScriptPath(options.paths);
|
|
89
89
|
if (!bridge.ok) {
|
|
90
90
|
return { ok: false, error: bridge.error };
|
|
91
91
|
}
|
|
@@ -109,6 +109,16 @@ export interface MempalaceInitState {
|
|
|
109
109
|
function isWingPresent(result: unknown, wing: string): boolean {
|
|
110
110
|
if (!result || typeof result !== "object") return false;
|
|
111
111
|
const record = result as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
// tool_list_wings returns `{ wings: { <name>: <count>, ... } }`. The
|
|
114
|
+
// dict shape is the canonical one from mempalace.mcp_server. Older
|
|
115
|
+
// / partial responses may carry array shapes (items/results), so we
|
|
116
|
+
// accept both rather than coupling tightly.
|
|
117
|
+
const wings = record.wings;
|
|
118
|
+
if (wings && typeof wings === "object" && !Array.isArray(wings)) {
|
|
119
|
+
if (Object.prototype.hasOwnProperty.call(wings, wing)) return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
const candidates = [record.wings, record.items, record.results];
|
|
113
123
|
for (const list of candidates) {
|
|
114
124
|
if (!Array.isArray(list)) continue;
|
|
@@ -145,7 +155,11 @@ export async function checkMempalaceProjectInitialized(options: {
|
|
|
145
155
|
} catch {
|
|
146
156
|
wing = "project";
|
|
147
157
|
}
|
|
148
|
-
const bridge = options.bridge ?? createMempalaceBridge({
|
|
158
|
+
const bridge = options.bridge ?? createMempalaceBridge({
|
|
159
|
+
cwd: options.cwd,
|
|
160
|
+
config: resolved,
|
|
161
|
+
runtime: { resolveBridgeScriptPath: () => resolveInstalledBridgeScriptPath(options.paths) },
|
|
162
|
+
});
|
|
149
163
|
const result = await bridge.execute({ action: "list_wings" });
|
|
150
164
|
if (!result.ok) {
|
|
151
165
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""One-shot JSON bridge for the native supipowers MemPalace tool.
|
|
3
3
|
|
|
4
|
-
Action mapping (auditable API surface)
|
|
4
|
+
Action mapping (auditable API surface):
|
|
5
5
|
- version: stdlib importlib.metadata only; does not import MemPalace runtime modules.
|
|
6
6
|
- 29 MCP-equivalent actions dispatch to ``mempalace.mcp_server.tool_<action>``
|
|
7
7
|
(with the ``traverse → tool_traverse_graph`` rename). The mcp_server
|
|
@@ -129,6 +129,8 @@ def _handle_version(params: Dict[str, Any], options: Dict[str, Any]) -> Dict[str
|
|
|
129
129
|
|
|
130
130
|
def _apply_palace_path(options: Dict[str, Any]) -> None:
|
|
131
131
|
"""Set MEMPALACE_PALACE_PATH so MemPalace's MempalaceConfig picks it up."""
|
|
132
|
+
# NOTE: mutates os.environ — safe only because we run in a fresh process per
|
|
133
|
+
# call. Any future daemon/persistent-process refactor MUST re-evaluate this.
|
|
132
134
|
palace = options.get("palacePath") or options.get("palace")
|
|
133
135
|
if palace and isinstance(palace, str) and palace:
|
|
134
136
|
os.environ["MEMPALACE_PALACE_PATH"] = os.path.expanduser(palace)
|
|
@@ -203,6 +205,42 @@ def _rename(mapping: Dict[str, str]) -> Callable[[Dict[str, Any]], Dict[str, Any
|
|
|
203
205
|
return extract
|
|
204
206
|
|
|
205
207
|
|
|
208
|
+
|
|
209
|
+
# ── diary_write: source_file embedding ───────────────────────────────────
|
|
210
|
+
#
|
|
211
|
+
# tool_diary_write(agent_name, entry, topic, wing) does not accept source_file.
|
|
212
|
+
# When a source_file is provided, embed it as a deterministic first-line prefix
|
|
213
|
+
# "[source: <source_file>]" in the entry text so that diary_read content
|
|
214
|
+
# carries the linkage. This is the Option-2 convention documented in
|
|
215
|
+
# session-summary.ts: the caller supplies source_file in params; the bridge
|
|
216
|
+
# embeds it; the round-trip proves it via content inspection.
|
|
217
|
+
DIARY_SOURCE_PREFIX = "[source: "
|
|
218
|
+
|
|
219
|
+
# Upstream sanitize_content default in mempalace.config (3.3.5). Mirrored in
|
|
220
|
+
# src/mempalace/upstream-limits.ts (MEMPALACE_MAX_CONTENT_LENGTH). Bump both
|
|
221
|
+
# when upgrading.
|
|
222
|
+
_MAX_CONTENT_LENGTH = 100_000
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _diary_write_extractor(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
226
|
+
kwargs = _select("agent_name", "topic", "wing")(params)
|
|
227
|
+
entry: str = params.get("entry") or ""
|
|
228
|
+
source_file = params.get("source_file")
|
|
229
|
+
if source_file and isinstance(source_file, str):
|
|
230
|
+
# MemPalace's tool_diary_write calls sanitize_content(entry), which
|
|
231
|
+
# raises ValueError if len(entry) > MAX_CONTENT_LENGTH. Adding the
|
|
232
|
+
# deterministic `[source: ...]\n` prefix can push a TS-valid entry
|
|
233
|
+
# past that limit and turn a previously-valid request into a domain
|
|
234
|
+
# failure. Reserve the prefix budget here by clipping the entry tail
|
|
235
|
+
# so the prefixed payload always fits.
|
|
236
|
+
prefix = f"{DIARY_SOURCE_PREFIX}{source_file}]\n"
|
|
237
|
+
budget = max(0, _MAX_CONTENT_LENGTH - len(prefix))
|
|
238
|
+
if len(entry) > budget:
|
|
239
|
+
entry = entry[:budget]
|
|
240
|
+
entry = f"{prefix}{entry}"
|
|
241
|
+
kwargs["entry"] = entry
|
|
242
|
+
return kwargs
|
|
243
|
+
|
|
206
244
|
# ── MCP-equivalent action dispatch ───────────────────────────────────────
|
|
207
245
|
#
|
|
208
246
|
# Each entry maps our action -> (function name in mempalace.mcp_server,
|
|
@@ -224,7 +262,7 @@ MCP_TOOL_DISPATCH: Dict[str, "tuple[str, Callable[[Dict[str, Any]], Dict[str, An
|
|
|
224
262
|
"delete_drawer": ("tool_delete_drawer", _select("drawer_id")),
|
|
225
263
|
# Knowledge graph: MemPalace uses `entity` for the subject in queries.
|
|
226
264
|
"kg_query": ("tool_kg_query", _rename({"subject": "entity", "as_of": "as_of", "direction": "direction"})),
|
|
227
|
-
"kg_add": ("tool_kg_add", _select("subject", "predicate", "object", "valid_from", "
|
|
265
|
+
"kg_add": ("tool_kg_add", _select("subject", "predicate", "object", "valid_from", "valid_to", "source_file", "source_drawer_id")),
|
|
228
266
|
"kg_invalidate": ("tool_kg_invalidate", _select("subject", "predicate", "object", "ended")),
|
|
229
267
|
"kg_timeline": ("tool_kg_timeline", _rename({"subject": "entity"})),
|
|
230
268
|
"kg_stats": ("tool_kg_stats", lambda p: {}),
|
|
@@ -239,7 +277,9 @@ MCP_TOOL_DISPATCH: Dict[str, "tuple[str, Callable[[Dict[str, Any]], Dict[str, An
|
|
|
239
277
|
"delete_tunnel": ("tool_delete_tunnel", _select("tunnel_id")),
|
|
240
278
|
# follow_tunnels: MemPalace uses wing/room (not source_wing/source_room).
|
|
241
279
|
"follow_tunnels": ("tool_follow_tunnels", _rename({"source_wing": "wing", "source_room": "room"})),
|
|
242
|
-
|
|
280
|
+
# diary_write: source_file is embedded as a prefix in the entry text by
|
|
281
|
+
# _diary_write_extractor — tool_diary_write does not accept source_file natively.
|
|
282
|
+
"diary_write": ("tool_diary_write", _diary_write_extractor),
|
|
243
283
|
"diary_read": ("tool_diary_read", _select("agent_name", "wing")),
|
|
244
284
|
"hook_settings": ("tool_hook_settings", lambda p: {}),
|
|
245
285
|
"memories_filed_away": ("tool_memories_filed_away", lambda p: {}),
|
|
@@ -287,6 +327,84 @@ def _handle_wake_up(params: Dict[str, Any], options: Dict[str, Any]) -> Dict[str
|
|
|
287
327
|
return _ok({"text": text}, options)
|
|
288
328
|
|
|
289
329
|
|
|
330
|
+
|
|
331
|
+
# ── wake_up_and_search: composite action (saves one python process spawn) ─
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _handle_wake_up_and_search(params: Dict[str, Any], options: Dict[str, Any]) -> Dict[str, Any]:
|
|
335
|
+
"""Batch wake_up + optional search into a single python process.
|
|
336
|
+
|
|
337
|
+
Returns {"wake": {"text": ...}, "search": <tool_search payload or null>}.
|
|
338
|
+
Either half may fail independently; the other half is still returned.
|
|
339
|
+
"""
|
|
340
|
+
_apply_palace_path(options)
|
|
341
|
+
palace = options.get("palacePath")
|
|
342
|
+
|
|
343
|
+
# ── Wake half ─────────────────────────────────────────────────────────────
|
|
344
|
+
wake_result: Any = None
|
|
345
|
+
wake_error: "str | None" = None
|
|
346
|
+
try:
|
|
347
|
+
layers = _import_or_raise("mempalace.layers")
|
|
348
|
+
stack = layers.MemoryStack(palace_path=palace) if palace else layers.MemoryStack()
|
|
349
|
+
wake_kwargs: Dict[str, Any] = {}
|
|
350
|
+
if params.get("wing"):
|
|
351
|
+
wake_kwargs["wing"] = params["wing"]
|
|
352
|
+
text = _wrap_runtime_errors("MemoryStack.wake_up", lambda: stack.wake_up(**wake_kwargs))
|
|
353
|
+
wake_result = {"text": text}
|
|
354
|
+
except BridgeDomainError as exc:
|
|
355
|
+
wake_error = f"{exc.code}: {exc.message}"
|
|
356
|
+
except Exception as exc: # pragma: no cover — defensive isolation
|
|
357
|
+
wake_error = str(exc)
|
|
358
|
+
|
|
359
|
+
# ── Search half (skipped when query is absent or empty) ───────────────────
|
|
360
|
+
search_result: Any = None
|
|
361
|
+
search_error: "str | None" = None
|
|
362
|
+
query = params.get("query")
|
|
363
|
+
if query and isinstance(query, str) and query.strip():
|
|
364
|
+
try:
|
|
365
|
+
module = _import_or_raise("mempalace.mcp_server")
|
|
366
|
+
tool_search = getattr(module, "tool_search", None)
|
|
367
|
+
if not callable(tool_search):
|
|
368
|
+
raise BridgeDomainError(
|
|
369
|
+
"mempalace_missing",
|
|
370
|
+
"mempalace.mcp_server.tool_search is not callable.",
|
|
371
|
+
"Upgrade the managed MemPalace runtime via `/supi:memory setup`.",
|
|
372
|
+
)
|
|
373
|
+
search_kwargs: Dict[str, Any] = {"query": query}
|
|
374
|
+
if params.get("wing"):
|
|
375
|
+
search_kwargs["wing"] = params["wing"]
|
|
376
|
+
if params.get("room"):
|
|
377
|
+
search_kwargs["room"] = params["room"]
|
|
378
|
+
if isinstance(params.get("limit"), int):
|
|
379
|
+
search_kwargs["limit"] = params["limit"]
|
|
380
|
+
raw = _wrap_runtime_errors(
|
|
381
|
+
"mempalace.mcp_server.tool_search",
|
|
382
|
+
lambda: tool_search(**search_kwargs),
|
|
383
|
+
)
|
|
384
|
+
normalized = _to_jsonable(raw)
|
|
385
|
+
if isinstance(normalized, dict) and normalized.get("ok") is False and isinstance(normalized.get("error"), dict):
|
|
386
|
+
err = normalized["error"]
|
|
387
|
+
code = err.get("code") if isinstance(err.get("code"), str) else "mempalace_runtime_error"
|
|
388
|
+
message = err.get("message") if isinstance(err.get("message"), str) else json.dumps(err, sort_keys=True)
|
|
389
|
+
search_error = f"{code}: {message}"
|
|
390
|
+
else:
|
|
391
|
+
search_result = normalized
|
|
392
|
+
except BridgeDomainError as exc:
|
|
393
|
+
# Surface as partial error so the caller can distinguish a real
|
|
394
|
+
# search failure from "no query / no hits". Mirrors the wake half.
|
|
395
|
+
search_error = f"{exc.code}: {exc.message}"
|
|
396
|
+
except Exception as exc: # pragma: no cover — defensive isolation
|
|
397
|
+
# Same contract: never let one half kill the other, but the caller
|
|
398
|
+
# gets a string they can render or log.
|
|
399
|
+
search_error = str(exc)
|
|
400
|
+
|
|
401
|
+
payload: Dict[str, Any] = {"wake": wake_result, "search": search_result}
|
|
402
|
+
if wake_error is not None:
|
|
403
|
+
payload["wake_error"] = wake_error
|
|
404
|
+
if search_error is not None:
|
|
405
|
+
payload["search_error"] = search_error
|
|
406
|
+
return _ok(payload, options)
|
|
407
|
+
|
|
290
408
|
# ── Native CLI args builders ──────────────────────────────────────────────
|
|
291
409
|
|
|
292
410
|
|
|
@@ -307,6 +425,8 @@ def _make_cli_args_mine(params: Dict[str, Any]) -> "list[str]":
|
|
|
307
425
|
args.append("--include-ignored")
|
|
308
426
|
if params.get("no_gitignore"):
|
|
309
427
|
args.append("--no-gitignore")
|
|
428
|
+
if params.get("dry_run"):
|
|
429
|
+
args.append("--dry-run")
|
|
310
430
|
if params.get("extract"):
|
|
311
431
|
args.append("--extract")
|
|
312
432
|
return args
|
|
@@ -331,6 +451,10 @@ def _make_cli_args_repair(params: Dict[str, Any]) -> "list[str]":
|
|
|
331
451
|
args.append("--yes")
|
|
332
452
|
if params.get("mode"):
|
|
333
453
|
args.extend(["--mode", str(params["mode"])])
|
|
454
|
+
if params.get("source"):
|
|
455
|
+
args.extend(["--source", str(params["source"])])
|
|
456
|
+
if params.get("archive_existing"):
|
|
457
|
+
args.append("--archive-existing")
|
|
334
458
|
if params.get("dry_run"):
|
|
335
459
|
args.append("--dry-run")
|
|
336
460
|
return args
|
|
@@ -396,6 +520,7 @@ def _make_cli_handler(action: str) -> Callable[[Dict[str, Any], Dict[str, Any]],
|
|
|
396
520
|
DISPATCH: Dict[str, Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]]] = {
|
|
397
521
|
"version": _handle_version,
|
|
398
522
|
"wake_up": _handle_wake_up,
|
|
523
|
+
"wake_up_and_search": _handle_wake_up_and_search,
|
|
399
524
|
}
|
|
400
525
|
for _action in MCP_TOOL_DISPATCH:
|
|
401
526
|
DISPATCH[_action] = _make_mcp_handler(_action)
|