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/harness/tools.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
appendImplementLog,
|
|
29
29
|
saveHarnessDesignSpec,
|
|
30
30
|
saveHarnessDiscover,
|
|
31
|
+
saveHarnessDocsLayerStaging,
|
|
31
32
|
saveHarnessResearchTopic,
|
|
32
33
|
} from "./storage.js";
|
|
33
34
|
import {
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
resolve as resolveQueueEntry,
|
|
37
38
|
markWontfix as markQueueWontfix,
|
|
38
39
|
} from "./anti_slop/queue.js";
|
|
40
|
+
import { validateLayerDocMarkdown } from "./docs/validator.js";
|
|
41
|
+
import { isSafeLayerId } from "./project-paths.js";
|
|
39
42
|
|
|
40
43
|
// ---------------------------------------------------------------------------
|
|
41
44
|
// Helpers
|
|
@@ -410,6 +413,79 @@ export function registerHarnessPipelineTools(platform: Platform): void {
|
|
|
410
413
|
return toolResult({ ok: true, id, details: { state } });
|
|
411
414
|
},
|
|
412
415
|
});
|
|
416
|
+
|
|
417
|
+
// -------- harness_docs_record ----------
|
|
418
|
+
platform.registerTool({
|
|
419
|
+
name: "harness_docs_record",
|
|
420
|
+
label: "Harness Docs Record",
|
|
421
|
+
description: "Record one per-layer agent knowledge document for the docs stage.",
|
|
422
|
+
parameters: {
|
|
423
|
+
type: "object",
|
|
424
|
+
properties: {
|
|
425
|
+
sessionId: SESSION_ID_PROP,
|
|
426
|
+
layerId: {
|
|
427
|
+
type: "string",
|
|
428
|
+
description: "Layer id this doc covers (matches the assigned layer rule).",
|
|
429
|
+
},
|
|
430
|
+
markdown: {
|
|
431
|
+
type: "string",
|
|
432
|
+
description: "Full markdown body, including the provenance marker, frontmatter, and required headings.",
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
required: ["sessionId", "layerId", "markdown"],
|
|
436
|
+
},
|
|
437
|
+
async execute(_id: string, params: unknown, _signal: AbortSignal, _onUpdate: unknown, toolCtx: unknown) {
|
|
438
|
+
const cwdR = readCwd(toolCtx, "harness_docs_record");
|
|
439
|
+
if (!cwdR.ok) return toolResult(cwdR);
|
|
440
|
+
const sidR = readSessionId(params, "harness_docs_record");
|
|
441
|
+
if (!sidR.ok) return toolResult(sidR);
|
|
442
|
+
if (!isRecord(params)) {
|
|
443
|
+
return toolResult({ ok: false, message: "harness_docs_record requires an object payload" });
|
|
444
|
+
}
|
|
445
|
+
const p = params as Record<string, unknown>;
|
|
446
|
+
const layerId = typeof p.layerId === "string" ? p.layerId : "";
|
|
447
|
+
if (!layerId) {
|
|
448
|
+
return toolResult({ ok: false, message: "harness_docs_record requires a non-empty layerId" });
|
|
449
|
+
}
|
|
450
|
+
if (!isSafeLayerId(layerId)) {
|
|
451
|
+
return toolResult({
|
|
452
|
+
ok: false,
|
|
453
|
+
message: `harness_docs_record: layerId ${JSON.stringify(layerId)} is not a safe filename stem (alnum/_/-/non-leading dots, ≤64 chars, no '..' or path separators)`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
const markdown = typeof p.markdown === "string" ? p.markdown : "";
|
|
457
|
+
if (markdown.length === 0) {
|
|
458
|
+
return toolResult({ ok: false, message: "harness_docs_record requires a non-empty markdown body" });
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const expectation = lookupDocsLayerExpectation(sidR.sessionId, layerId);
|
|
462
|
+
if (!expectation) {
|
|
463
|
+
return toolResult({
|
|
464
|
+
ok: false,
|
|
465
|
+
message: `harness_docs_record: no expectation registered for session ${sidR.sessionId} layer ${layerId}. The docs stage is the only valid invocation context.`,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const validation = validateLayerDocMarkdown(markdown, {
|
|
470
|
+
expectedLayerId: layerId,
|
|
471
|
+
expectedSourceHash: expectation.expectedSourceHash,
|
|
472
|
+
maxDocLoc: expectation.maxDocLoc,
|
|
473
|
+
maxAgentContextLoc: expectation.maxAgentContextLoc,
|
|
474
|
+
});
|
|
475
|
+
if (!validation.ok) {
|
|
476
|
+
return toolResult({
|
|
477
|
+
ok: false,
|
|
478
|
+
message: `harness_docs_record rejected: ${validation.errors.join("; ")}`,
|
|
479
|
+
details: { errors: validation.errors },
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const persisted = saveHarnessDocsLayerStaging(platform.paths, cwdR.cwd, sidR.sessionId, layerId, markdown);
|
|
484
|
+
const r = unwrap(persisted, "harness_docs_record");
|
|
485
|
+
if (!r.ok) return toolResult(r);
|
|
486
|
+
return toolResult({ ok: true, path: r.value });
|
|
487
|
+
},
|
|
488
|
+
});
|
|
413
489
|
}
|
|
414
490
|
|
|
415
491
|
/** Names exposed for hook-bridge correlation. */
|
|
@@ -421,6 +497,60 @@ export const HARNESS_PIPELINE_TOOL_NAMES = [
|
|
|
421
497
|
"harness_validate_finding",
|
|
422
498
|
"harness_slop_queue_append",
|
|
423
499
|
"harness_slop_queue_resolve",
|
|
500
|
+
"harness_docs_record",
|
|
424
501
|
] as const;
|
|
425
502
|
|
|
426
503
|
export type HarnessPipelineToolName = (typeof HARNESS_PIPELINE_TOOL_NAMES)[number];
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Docs stage expectations registry.
|
|
507
|
+
//
|
|
508
|
+
// The docs stage registers per-(session, layer) expectations before dispatching each
|
|
509
|
+
// subagent so the `harness_docs_record` handler can validate `layerId`, `sourceHash`,
|
|
510
|
+
// and LOC caps synchronously. Module-scoped Map is intentional: registration and
|
|
511
|
+
// consumption both run in the same process within a single pipeline driver invocation.
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
export interface HarnessDocsLayerExpectation {
|
|
515
|
+
expectedSourceHash: string;
|
|
516
|
+
maxDocLoc: number;
|
|
517
|
+
maxAgentContextLoc: number;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const docsLayerExpectations = new Map<string, HarnessDocsLayerExpectation>();
|
|
521
|
+
|
|
522
|
+
function docsKey(sessionId: string, layerId: string): string {
|
|
523
|
+
return `${sessionId}::${layerId}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Register the expectations the docs tool will validate on the next record call. */
|
|
527
|
+
export function registerDocsLayerExpectation(
|
|
528
|
+
sessionId: string,
|
|
529
|
+
layerId: string,
|
|
530
|
+
expectation: HarnessDocsLayerExpectation,
|
|
531
|
+
): void {
|
|
532
|
+
if (!isSafeLayerId(layerId)) {
|
|
533
|
+
throw new Error(
|
|
534
|
+
`registerDocsLayerExpectation: refusing to register unsafe layerId ${JSON.stringify(layerId)}`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
docsLayerExpectations.set(docsKey(sessionId, layerId), expectation);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Remove a registered expectation. Idempotent. */
|
|
541
|
+
export function clearDocsLayerExpectation(sessionId: string, layerId: string): void {
|
|
542
|
+
docsLayerExpectations.delete(docsKey(sessionId, layerId));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Lookup the registered expectation (returns null when none is registered). */
|
|
546
|
+
export function lookupDocsLayerExpectation(
|
|
547
|
+
sessionId: string,
|
|
548
|
+
layerId: string,
|
|
549
|
+
): HarnessDocsLayerExpectation | null {
|
|
550
|
+
return docsLayerExpectations.get(docsKey(sessionId, layerId)) ?? null;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** Clear every registration (test helper). */
|
|
554
|
+
export function _clearAllDocsLayerExpectationsForTests(): void {
|
|
555
|
+
docsLayerExpectations.clear();
|
|
556
|
+
}
|
package/src/mempalace/bridge.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
1
4
|
import type { ResolvedMempalaceConfig } from "./config.js";
|
|
2
|
-
import type
|
|
5
|
+
import { MEMPALACE_ACTIONS, type MempalaceAction, type MempalaceParams } from "./schema.js";
|
|
3
6
|
import {
|
|
4
7
|
resolveBridgeScriptPath,
|
|
5
8
|
resolveManagedVenvPaths,
|
|
@@ -24,6 +27,11 @@ export type MempalaceBridgeCallResult =
|
|
|
24
27
|
diagnostics: Record<string, unknown>;
|
|
25
28
|
};
|
|
26
29
|
|
|
30
|
+
interface BridgeDispatchResult {
|
|
31
|
+
result: MempalaceBridgeCallResult;
|
|
32
|
+
release: Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export interface MempalaceBridgeRuntimeDeps {
|
|
28
36
|
resolveBridgeScriptPath?: () => BridgePathResult;
|
|
29
37
|
runBridgeRequest?: (options: RunBridgeRequestOptions) => Promise<RunBridgeRequestResult>;
|
|
@@ -54,6 +62,102 @@ function mergeDiagnostics(...parts: Array<Record<string, unknown> | undefined>):
|
|
|
54
62
|
return Object.assign({}, ...parts.filter(Boolean));
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
// Per-action mutation classification. `write` actions are serialized per palace
|
|
66
|
+
// to avoid ChromaDB's sqlite "database is locked" errors; `read` actions run in
|
|
67
|
+
// parallel. The classification is exhaustive (enforced by `satisfies`) so any
|
|
68
|
+
// new action added to MEMPALACE_ACTIONS must be assigned a kind here — no
|
|
69
|
+
// silent default to the read path. `setup` is intercepted before the bridge and
|
|
70
|
+
// never reaches this map; it is still listed for exhaustiveness.
|
|
71
|
+
const ACTION_KIND = {
|
|
72
|
+
// ── reads / queries / listings ──────────────────────────────────────────
|
|
73
|
+
status: "read",
|
|
74
|
+
list_wings: "read",
|
|
75
|
+
list_rooms: "read",
|
|
76
|
+
get_taxonomy: "read",
|
|
77
|
+
search: "read",
|
|
78
|
+
check_duplicate: "read",
|
|
79
|
+
get_aaak_spec: "read",
|
|
80
|
+
get_drawer: "read",
|
|
81
|
+
list_drawers: "read",
|
|
82
|
+
kg_query: "read",
|
|
83
|
+
kg_timeline: "read",
|
|
84
|
+
kg_stats: "read",
|
|
85
|
+
traverse: "read",
|
|
86
|
+
find_tunnels: "read",
|
|
87
|
+
graph_stats: "read",
|
|
88
|
+
list_tunnels: "read",
|
|
89
|
+
follow_tunnels: "read",
|
|
90
|
+
diary_read: "read",
|
|
91
|
+
hook_settings: "read",
|
|
92
|
+
memories_filed_away: "read",
|
|
93
|
+
reconnect: "read",
|
|
94
|
+
version: "read",
|
|
95
|
+
wake_up: "read",
|
|
96
|
+
wake_up_and_search: "read",
|
|
97
|
+
setup: "read", // intercepted upstream; never reaches the bridge mutex
|
|
98
|
+
|
|
99
|
+
// ── writes / mutations ──────────────────────────────────────────────────
|
|
100
|
+
add_drawer: "write",
|
|
101
|
+
update_drawer: "write",
|
|
102
|
+
delete_drawer: "write",
|
|
103
|
+
diary_write: "write",
|
|
104
|
+
kg_add: "write",
|
|
105
|
+
kg_invalidate: "write",
|
|
106
|
+
create_tunnel: "write",
|
|
107
|
+
delete_tunnel: "write",
|
|
108
|
+
// CLI actions that touch the palace on disk: init creates structure,
|
|
109
|
+
// mine writes drawers via the indexer, split rewrites mega-file sources
|
|
110
|
+
// under the palace tree, repair rebuilds the chroma index. All four must
|
|
111
|
+
// serialize against concurrent add_drawer/diary_write writers on the
|
|
112
|
+
// same palace.
|
|
113
|
+
init: "write",
|
|
114
|
+
mine: "write",
|
|
115
|
+
split: "write",
|
|
116
|
+
repair: "write",
|
|
117
|
+
} as const satisfies Record<MempalaceAction, "read" | "write">;
|
|
118
|
+
|
|
119
|
+
// Compile-time check: every MEMPALACE_ACTIONS entry must have a kind.
|
|
120
|
+
// If a new action is added without an ACTION_KIND entry, this errors.
|
|
121
|
+
type _ExhaustiveActionKind = MempalaceAction extends keyof typeof ACTION_KIND ? true : never;
|
|
122
|
+
const _exhaustiveActionKind: _ExhaustiveActionKind = true;
|
|
123
|
+
void _exhaustiveActionKind;
|
|
124
|
+
void MEMPALACE_ACTIONS;
|
|
125
|
+
|
|
126
|
+
function isWriteAction(action: MempalaceAction): boolean {
|
|
127
|
+
return ACTION_KIND[action] === "write";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Subset of write actions where a single retry on transient bridge failures
|
|
131
|
+
// meaningfully improves durability (write-once payloads, low retry cost).
|
|
132
|
+
const RETRY_ON_TRANSIENT = new Set<MempalaceAction>([
|
|
133
|
+
"add_drawer", "diary_write", "kg_add", "kg_invalidate",
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
// Error codes that are transient and safe to retry. `bridge_timeout` is
|
|
137
|
+
// deliberately excluded: when the TS-side timeout fires we SIGKILL the python
|
|
138
|
+
// child, but the OS may not have reaped the writer yet — retrying could race a
|
|
139
|
+
// still-live writer on the same sqlite file. `bridge_process_failed` only
|
|
140
|
+
// fires after `child.on('close')`, so the child is definitely gone by then.
|
|
141
|
+
const TRANSIENT_ERROR_CODES = new Set(["bridge_process_failed"]);
|
|
142
|
+
|
|
143
|
+
// Per-palace write serialization queue. Keyed by the canonicalized palace
|
|
144
|
+
// path so that concurrent OMP workspaces (different palaces) do not block each
|
|
145
|
+
// other, and so callers passing `~/x` vs `/Users/me/x` collide on the same
|
|
146
|
+
// lock (they hit the same sqlite file). Entries are deleted in a settlement
|
|
147
|
+
// handler when they still point at the current tail, keeping the map bounded
|
|
148
|
+
// in long-lived OMP processes.
|
|
149
|
+
const palaceMutex = new Map<string, Promise<void>>();
|
|
150
|
+
const RELEASED = Promise.resolve<void>(undefined);
|
|
151
|
+
|
|
152
|
+
function canonicalPalaceKey(palacePath: string): string {
|
|
153
|
+
const trimmed = palacePath.trim();
|
|
154
|
+
if (trimmed === "~") return homedir();
|
|
155
|
+
const expanded = (trimmed.startsWith("~/") || trimmed.startsWith("~\\"))
|
|
156
|
+
? path.join(homedir(), trimmed.slice(2))
|
|
157
|
+
: trimmed;
|
|
158
|
+
return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(expanded);
|
|
159
|
+
}
|
|
160
|
+
|
|
57
161
|
function resolveToolTimeoutMs(params: MempalaceParams, bridgeTimeoutMs: number): number {
|
|
58
162
|
if (typeof params.timeout !== "number") return bridgeTimeoutMs;
|
|
59
163
|
|
|
@@ -81,55 +185,117 @@ export function createMempalaceBridge(options: CreateMempalaceBridgeOptions): Me
|
|
|
81
185
|
const venv = resolveManagedVenvPaths(options.config.managedVenvPath);
|
|
82
186
|
const timeoutMs = resolveToolTimeoutMs(params, options.config.timeouts.bridgeMs);
|
|
83
187
|
const palacePath = params.palace ?? options.config.palacePath;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
188
|
+
|
|
189
|
+
const invokeOnce = async (): Promise<BridgeDispatchResult> => {
|
|
190
|
+
const runResult = await runBridge({
|
|
191
|
+
pythonPath: venv.python,
|
|
192
|
+
bridgeScriptPath: bridgePath.path,
|
|
193
|
+
timeoutMs,
|
|
194
|
+
request: {
|
|
195
|
+
action: params.action,
|
|
196
|
+
params: { ...params },
|
|
197
|
+
options: {
|
|
198
|
+
cwd: options.cwd,
|
|
199
|
+
palacePath,
|
|
200
|
+
agentName: options.config.defaultAgentName,
|
|
201
|
+
},
|
|
95
202
|
},
|
|
96
|
-
}
|
|
97
|
-
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!runResult.ok) {
|
|
206
|
+
return {
|
|
207
|
+
result: {
|
|
208
|
+
ok: false,
|
|
209
|
+
action: params.action,
|
|
210
|
+
error: withSetupRemediation(runResult.error),
|
|
211
|
+
diagnostics: {
|
|
212
|
+
durationMs: runResult.durationMs,
|
|
213
|
+
stdoutPreview: runResult.stdoutPreview,
|
|
214
|
+
stderrTail: runResult.stderrTail,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
release: runResult.completion ?? RELEASED,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!runResult.response.ok) {
|
|
222
|
+
return {
|
|
223
|
+
result: {
|
|
224
|
+
ok: false,
|
|
225
|
+
action: params.action,
|
|
226
|
+
error: withSetupRemediation(runResult.response.error),
|
|
227
|
+
diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
|
|
228
|
+
durationMs: runResult.durationMs,
|
|
229
|
+
stderr: runResult.stderr,
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
release: RELEASED,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
98
235
|
|
|
99
|
-
if (!runResult.ok) {
|
|
100
236
|
return {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
237
|
+
result: {
|
|
238
|
+
ok: true,
|
|
239
|
+
action: params.action,
|
|
240
|
+
result: runResult.response.result ?? {},
|
|
241
|
+
diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
|
|
242
|
+
durationMs: runResult.durationMs,
|
|
243
|
+
stderr: runResult.stderr,
|
|
244
|
+
}),
|
|
108
245
|
},
|
|
246
|
+
release: RELEASED,
|
|
109
247
|
};
|
|
110
|
-
}
|
|
248
|
+
};
|
|
111
249
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
250
|
+
// dispatch: run with one retry on transient errors for eligible actions.
|
|
251
|
+
const dispatch = async (): Promise<BridgeDispatchResult> => {
|
|
252
|
+
const first = await invokeOnce();
|
|
253
|
+
const withRetries = (r: BridgeDispatchResult, retries: number): BridgeDispatchResult => {
|
|
254
|
+
const diag = { ...r.result.diagnostics, retries };
|
|
255
|
+
return {
|
|
256
|
+
result: r.result.ok
|
|
257
|
+
? { ok: true, action: r.result.action, result: r.result.result, diagnostics: diag }
|
|
258
|
+
: { ok: false, action: r.result.action, error: r.result.error, diagnostics: diag },
|
|
259
|
+
release: r.release,
|
|
260
|
+
};
|
|
121
261
|
};
|
|
262
|
+
if (
|
|
263
|
+
!first.result.ok &&
|
|
264
|
+
RETRY_ON_TRANSIENT.has(params.action) &&
|
|
265
|
+
TRANSIENT_ERROR_CODES.has(first.result.error.code)
|
|
266
|
+
) {
|
|
267
|
+
await first.release;
|
|
268
|
+
await new Promise<void>((res) => setTimeout(res, 150));
|
|
269
|
+
return withRetries(await invokeOnce(), 1);
|
|
270
|
+
}
|
|
271
|
+
return withRetries(first, 0);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Serialize write actions per palace to avoid ChromaDB lock contention.
|
|
275
|
+
if (isWriteAction(params.action)) {
|
|
276
|
+
const mutexKey = canonicalPalaceKey(palacePath);
|
|
277
|
+
const tail = palaceMutex.get(mutexKey) ?? RELEASED;
|
|
278
|
+
const mine = tail.then(dispatch, dispatch);
|
|
279
|
+
// Chaining uses the runtime completion signal, not just the call
|
|
280
|
+
// result. A bridge_timeout can be returned to the caller before the
|
|
281
|
+
// killed Python child has fully exited; next writer must wait until
|
|
282
|
+
// that completion settles so sqlite/Chroma locks are released.
|
|
283
|
+
const settled = mine
|
|
284
|
+
.then(({ release }) => release, () => RELEASED)
|
|
285
|
+
.then(() => {}, () => {});
|
|
286
|
+
palaceMutex.set(mutexKey, settled);
|
|
287
|
+
// GC the entry once the release signal settles, if no later writer has
|
|
288
|
+
// stacked on top. Comparing identity preserves the chain when another
|
|
289
|
+
// writer enqueued between `set` above and settlement.
|
|
290
|
+
settled.then(() => {
|
|
291
|
+
if (palaceMutex.get(mutexKey) === settled) {
|
|
292
|
+
palaceMutex.delete(mutexKey);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return (await mine).result;
|
|
122
296
|
}
|
|
123
297
|
|
|
124
|
-
return
|
|
125
|
-
ok: true,
|
|
126
|
-
action: params.action,
|
|
127
|
-
result: runResult.response.result ?? {},
|
|
128
|
-
diagnostics: mergeDiagnostics(runResult.response.diagnostics, {
|
|
129
|
-
durationMs: runResult.durationMs,
|
|
130
|
-
stderr: runResult.stderr,
|
|
131
|
-
}),
|
|
132
|
-
};
|
|
298
|
+
return (await dispatch()).result;
|
|
133
299
|
},
|
|
134
300
|
};
|
|
135
301
|
}
|
package/src/mempalace/config.ts
CHANGED
|
@@ -26,13 +26,19 @@ function expandUserPath(input: string, cwd: string): string {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function sanitizedWing(value: string): string {
|
|
29
|
+
// MemPalace's own normalize_wing_name canonicalizes wing slugs with
|
|
30
|
+
// underscores (`hyphens` and spaces are folded to `_`). We mirror that
|
|
31
|
+
// here so a project directory like `sij_mono` and a supipowers-resolved
|
|
32
|
+
// wing `sij-mono` collapse to the same slug; otherwise data ends up
|
|
33
|
+
// split across two wings (`sij_mono` vs `sij-mono`) and search/diary
|
|
34
|
+
// writes diverge between the CLI and the hook bridge.
|
|
29
35
|
return value
|
|
30
36
|
.trim()
|
|
31
37
|
.toLowerCase()
|
|
32
|
-
.replace(/[\s
|
|
33
|
-
.replace(/[^a-z0-9_
|
|
34
|
-
.replace(/
|
|
35
|
-
.replace(
|
|
38
|
+
.replace(/[\s/\\-]+/g, "_")
|
|
39
|
+
.replace(/[^a-z0-9_]+/g, "_")
|
|
40
|
+
.replace(/_+/g, "_")
|
|
41
|
+
.replace(/^_+|_+$/g, "");
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
export function normalizeMempalaceWing(value: string): string {
|
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 {
|