supipowers 2.0.1 → 2.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 +10 -6
- package/package.json +4 -2
- package/skills/harness/SKILL.md +1 -0
- package/src/bootstrap.ts +5 -133
- package/src/commands/clear.ts +6 -6
- package/src/commands/release.ts +3 -1
- package/src/commands/update.ts +1 -1
- package/src/config/defaults.ts +5 -5
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +2 -6
- package/src/context/analyzer.ts +104 -35
- 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 +103 -6
- 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/hooks/layer-context-inject.ts +35 -1
- package/src/harness/hooks/register.ts +24 -3
- package/src/harness/pipeline.ts +20 -5
- 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 +877 -0
- package/src/harness/stages/implement.ts +64 -51
- package/src/harness/stages/plan.ts +25 -16
- package/src/harness/stages/validate.ts +370 -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 +55 -18
- 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 +87 -8
- package/src/ui-design/session.ts +114 -10
- 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/format.ts
CHANGED
|
@@ -28,6 +28,17 @@ function asArray(value: unknown): RecordValue[] {
|
|
|
28
28
|
return Array.isArray(value) ? value.map(asRecord) : [];
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function asCountMap(value: unknown): Array<[string, number]> {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
33
|
+
const out: Array<[string, number]> = [];
|
|
34
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
35
|
+
const count = typeof raw === "number" && Number.isFinite(raw) ? raw : Number(raw);
|
|
36
|
+
out.push([key, Number.isFinite(count) ? count : 0]);
|
|
37
|
+
}
|
|
38
|
+
out.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
function stringValue(value: unknown): string {
|
|
32
43
|
if (value === null || value === undefined) return "";
|
|
33
44
|
if (typeof value === "string") return value;
|
|
@@ -35,6 +46,10 @@ function stringValue(value: unknown): string {
|
|
|
35
46
|
return JSON.stringify(value);
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
50
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
function truncateText(text: string, maxChars: number, guidance: string): string {
|
|
39
54
|
if (text.length <= maxChars) return text;
|
|
40
55
|
if (maxChars <= guidance.length + 1) {
|
|
@@ -49,11 +64,29 @@ function formatSimilarity(value: unknown): string {
|
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
function formatSearch(result: RecordValue, budgets: ResultBudgets): string {
|
|
52
|
-
const results = asArray(result.results
|
|
67
|
+
const results = asArray(result.results);
|
|
53
68
|
const query = stringValue(result.query) || "(unspecified query)";
|
|
54
69
|
const count = typeof result.count === "number" ? result.count : results.length;
|
|
55
70
|
const lines = [`MemPalace search`, `Search results for ${query} (${count})`];
|
|
56
71
|
|
|
72
|
+
if (result.index_recovered) {
|
|
73
|
+
lines.push("Index recovered: retried after transient index lookup failure.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Surface filter context so agents can tell "empty palace" from "over-filtered".
|
|
77
|
+
const filters = asRecord(result.filters);
|
|
78
|
+
const filterParts = (["wing", "room"] as string[]).flatMap(k => {
|
|
79
|
+
const v = stringValue(filters[k]);
|
|
80
|
+
return v ? [`${k}=${v}`] : [];
|
|
81
|
+
});
|
|
82
|
+
if (filterParts.length > 0) {
|
|
83
|
+
lines.push(`Filters applied: ${filterParts.join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
const totalBeforeFilter = finiteNumber(result.total_before_filter);
|
|
86
|
+
if (totalBeforeFilter !== undefined && totalBeforeFilter > count) {
|
|
87
|
+
lines.push(`Filtered out ${totalBeforeFilter - count} hit(s) by wing/room scope.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
57
90
|
for (const [index, item] of results.entries()) {
|
|
58
91
|
const id = stringValue(item.id ?? item.drawer_id) || `#${index + 1}`;
|
|
59
92
|
const wing = stringValue(item.wing);
|
|
@@ -64,12 +97,17 @@ function formatSearch(result: RecordValue, budgets: ResultBudgets): string {
|
|
|
64
97
|
if (excerpt) lines.push(` ${excerpt}`);
|
|
65
98
|
}
|
|
66
99
|
|
|
100
|
+
|
|
67
101
|
return truncateText(lines.join("\n"), budgets.searchResultChars, TRUNCATED_SEARCH_GUIDANCE);
|
|
68
102
|
}
|
|
69
103
|
|
|
70
104
|
function formatDrawerList(result: RecordValue, budgets: ResultBudgets): string {
|
|
71
|
-
const drawers = asArray(result.drawers ?? result.results
|
|
72
|
-
const
|
|
105
|
+
const drawers = asArray(result.drawers ?? result.results);
|
|
106
|
+
const total = finiteNumber(result.total);
|
|
107
|
+
const header = total === undefined
|
|
108
|
+
? `Drawers (${drawers.length})`
|
|
109
|
+
: `Drawers (${drawers.length} shown, ${total} total)`;
|
|
110
|
+
const lines = [header];
|
|
73
111
|
|
|
74
112
|
for (const drawer of drawers) {
|
|
75
113
|
const id = stringValue(drawer.id ?? drawer.drawer_id) || "unknown";
|
|
@@ -81,6 +119,29 @@ function formatDrawerList(result: RecordValue, budgets: ResultBudgets): string {
|
|
|
81
119
|
return truncateText(lines.join("\n"), budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
|
|
82
120
|
}
|
|
83
121
|
|
|
122
|
+
function formatWingList(result: RecordValue, budgets: ResultBudgets): string {
|
|
123
|
+
const wings = asCountMap(result.wings);
|
|
124
|
+
const total = wings.reduce((acc, [, n]) => acc + n, 0);
|
|
125
|
+
const lines = [`Wings (${wings.length}${total ? `, ${total} drawers`: ""})`];
|
|
126
|
+
for (const [name, count] of wings) {
|
|
127
|
+
lines.push(`- ${name} (${count})`);
|
|
128
|
+
}
|
|
129
|
+
if (result.partial) lines.push("(partial result — palace returned an error mid-scan)");
|
|
130
|
+
return truncateText(lines.join("\n"), budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function formatRoomList(result: RecordValue, budgets: ResultBudgets): string {
|
|
134
|
+
const wing = stringValue(result.wing) || "all";
|
|
135
|
+
const rooms = asCountMap(result.rooms);
|
|
136
|
+
const total = rooms.reduce((acc, [, n]) => acc + n, 0);
|
|
137
|
+
const lines = [`Rooms in ${wing} (${rooms.length}${total ? `, ${total} drawers` : ""})`];
|
|
138
|
+
for (const [name, count] of rooms) {
|
|
139
|
+
lines.push(`- ${name} (${count})`);
|
|
140
|
+
}
|
|
141
|
+
if (result.partial) lines.push("(partial result — palace returned an error mid-scan)");
|
|
142
|
+
return truncateText(lines.join("\n"), budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
|
|
143
|
+
}
|
|
144
|
+
|
|
84
145
|
function formatDiary(result: RecordValue, budgets: ResultBudgets): string {
|
|
85
146
|
const entries = asArray(result.entries ?? result.results ?? result.items);
|
|
86
147
|
const lines = [`Diary entries (${entries.length})`];
|
|
@@ -96,13 +157,23 @@ function formatDiary(result: RecordValue, budgets: ResultBudgets): string {
|
|
|
96
157
|
}
|
|
97
158
|
|
|
98
159
|
function formatStatus(result: RecordValue): string {
|
|
99
|
-
const wings = Array.isArray(result.wings) ? result.wings.length : result.wingCount ?? result.wings_count ?? "unknown";
|
|
100
160
|
const lines = ["MemPalace status"];
|
|
101
161
|
const palacePath = stringValue(result.palacePath ?? result.palace_path ?? result.palace);
|
|
102
162
|
if (palacePath) lines.push(`palace: ${palacePath}`);
|
|
103
163
|
if ("ready" in result) lines.push(`ready: ${String(result.ready)}`);
|
|
104
164
|
if ("version" in result) lines.push(`version: ${stringValue(result.version)}`);
|
|
105
|
-
|
|
165
|
+
|
|
166
|
+
const wingsCount = Array.isArray(result.wings)
|
|
167
|
+
? result.wings.length
|
|
168
|
+
: result.wings && typeof result.wings === "object"
|
|
169
|
+
? Object.keys(result.wings as Record<string, unknown>).length
|
|
170
|
+
: (typeof result.wingCount === "number" ? result.wingCount : undefined)
|
|
171
|
+
?? (typeof result.wings_count === "number" ? result.wings_count : undefined);
|
|
172
|
+
lines.push(`wings: ${wingsCount === undefined ? "unknown" : String(wingsCount)}`);
|
|
173
|
+
|
|
174
|
+
if (typeof result.total_drawers === "number") lines.push(`drawers: ${result.total_drawers}`);
|
|
175
|
+
else if (typeof result.totalDrawers === "number") lines.push(`drawers: ${result.totalDrawers}`);
|
|
176
|
+
|
|
106
177
|
return lines.join("\n");
|
|
107
178
|
}
|
|
108
179
|
|
|
@@ -112,6 +183,45 @@ function formatGeneric(action: MempalaceAction, result: RecordValue, budgets: Re
|
|
|
112
183
|
return truncateText(`MemPalace ${action} result\n${JSON.stringify(result, null, 2)}`, budgets.listResultChars, TRUNCATED_LIST_GUIDANCE);
|
|
113
184
|
}
|
|
114
185
|
|
|
186
|
+
function formatWake(wake: RecordValue, budgets: ResultBudgets): string {
|
|
187
|
+
const text = stringValue(wake.text);
|
|
188
|
+
if (!text) return "MemPalace wake: (no text returned)";
|
|
189
|
+
// Wake payloads are roughly the size of a search result block (L0/L1
|
|
190
|
+
// condensation), so reuse the search budget rather than introducing a new
|
|
191
|
+
// dimension. truncateText preserves trailing guidance so the agent can ask
|
|
192
|
+
// for more if it was clipped.
|
|
193
|
+
return truncateText(`MemPalace wake\n${text}`, budgets.searchResultChars, TRUNCATED_SEARCH_GUIDANCE);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatWakeUpAndSearch(result: RecordValue, budgets: ResultBudgets): string {
|
|
197
|
+
const wake = result.wake;
|
|
198
|
+
const search = result.search;
|
|
199
|
+
const parts: string[] = [];
|
|
200
|
+
|
|
201
|
+
// Wake half: null means wake failed — emit a one-line notice so the operator
|
|
202
|
+
// can see something went wrong without poisoning the turn with empty output.
|
|
203
|
+
// Wake payloads from python are `{ text: <L0+L1 markdown> }` — render that
|
|
204
|
+
// text directly. Passing through formatSearch() would drop it entirely
|
|
205
|
+
// because formatSearch only reads `query`/`results`.
|
|
206
|
+
if (wake === null || wake === undefined) {
|
|
207
|
+
const notice = typeof result.wake_error === "string" ? result.wake_error : "wake_up failed";
|
|
208
|
+
parts.push(`MemPalace wake: ${notice}`);
|
|
209
|
+
} else {
|
|
210
|
+
parts.push(formatWake(asRecord(wake), budgets));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Search half: omit when null AND no error reported. If python attached a
|
|
214
|
+
// search_error (composite call failed mid-way), surface it as a one-liner so
|
|
215
|
+
// the caller can distinguish "no query / no hits" from "search blew up".
|
|
216
|
+
if (search !== null && search !== undefined) {
|
|
217
|
+
parts.push(formatSearch(asRecord(search), budgets));
|
|
218
|
+
} else if (typeof result.search_error === "string") {
|
|
219
|
+
parts.push(`MemPalace search: ${result.search_error}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return truncateText(parts.join("\n\n"), budgets.searchResultChars, TRUNCATED_SEARCH_GUIDANCE);
|
|
223
|
+
}
|
|
224
|
+
|
|
115
225
|
export function formatMempalaceResult(
|
|
116
226
|
action: MempalaceAction,
|
|
117
227
|
result: unknown,
|
|
@@ -124,8 +234,14 @@ export function formatMempalaceResult(
|
|
|
124
234
|
text = formatStatus(record);
|
|
125
235
|
} else if (action === "search" || action === "wake_up") {
|
|
126
236
|
text = formatSearch(record, budgets);
|
|
127
|
-
} else if (action === "
|
|
237
|
+
} else if (action === "wake_up_and_search") {
|
|
238
|
+
text = formatWakeUpAndSearch(record, budgets);
|
|
239
|
+
} else if (action === "list_drawers") {
|
|
128
240
|
text = formatDrawerList(record, budgets);
|
|
241
|
+
} else if (action === "list_wings") {
|
|
242
|
+
text = formatWingList(record, budgets);
|
|
243
|
+
} else if (action === "list_rooms") {
|
|
244
|
+
text = formatRoomList(record, budgets);
|
|
129
245
|
} else if (action === "diary_read") {
|
|
130
246
|
text = formatDiary(record, budgets);
|
|
131
247
|
} else {
|
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 {
|