@bli-cockpit/cli 0.2.119 → 0.2.121
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/dist/commands/analyze.js +74 -54
- package/dist/commands/brief-rewrite.js +164 -101
- package/dist/commands/brief.js +38 -13
- package/dist/commands/correct.js +38 -21
- package/dist/commands/docs.js +13 -10
- package/dist/commands/editor.js +59 -30
- package/dist/commands/install-receipts.js +106 -91
- package/dist/commands/local-args-tower-admin.js +4 -0
- package/dist/commands/local-args-tower-cal.js +28 -3
- package/dist/commands/local-args-tower-chat.js +55 -28
- package/dist/commands/local-args-tower-docs-msg.js +39 -8
- package/dist/commands/local-args-tower-mail.js +27 -1
- package/dist/commands/local-args-tower-models.js +14 -17
- package/dist/commands/local-args-tower-work.js +37 -6
- package/dist/commands/local-help-commands.js +2 -1
- package/dist/commands/mcp-stdio-probe.js +92 -73
- package/dist/commands/memory-hook-performance.js +135 -101
- package/dist/commands/memory-install-claude.js +15 -14
- package/dist/commands/memory-install-codex.js +10 -6
- package/dist/commands/memory-install-config.js +5 -4
- package/dist/commands/memory-install-contract.js +56 -10
- package/dist/commands/memory-install-report.js +16 -11
- package/dist/commands/memory-install-skills.js +11 -11
- package/dist/commands/memory-log.js +22 -5
- package/dist/commands/msg.js +11 -5
- package/dist/commands/onboard-setup.js +16 -1
- package/dist/commands/ops-sections.js +89 -0
- package/dist/commands/ops.js +117 -120
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout.js +90 -68
- package/dist/commands/session-sync-failures.js +19 -13
- package/dist/commands/session-sync-record.js +53 -52
- package/dist/commands/session-sync-upload.js +15 -11
- package/dist/commands/sessions.js +61 -51
- package/dist/commands/slack.js +90 -61
- package/dist/commands/status.js +53 -41
- package/dist/commands/workbook.js +23 -20
- package/package.json +2 -2
|
@@ -15,130 +15,163 @@ export async function readMemoryHookPerformance(options) {
|
|
|
15
15
|
const directory = memoryHookSamplesDirectory(options.homeDir);
|
|
16
16
|
const start = now.getTime() - 24 * HOUR_MS;
|
|
17
17
|
const reasons = new Set();
|
|
18
|
-
const
|
|
19
|
-
schema_version: "memory-hook-performance.v1",
|
|
20
|
-
window_start: new Date(start).toISOString(),
|
|
21
|
-
window_end: now.toISOString(),
|
|
22
|
-
sampling_since: null,
|
|
23
|
-
samples: 0,
|
|
24
|
-
outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
|
|
25
|
-
duration_by_cache: {
|
|
26
|
-
hit: blankHistogram(), miss: blankHistogram(), shared: blankHistogram(), unknown: blankHistogram(),
|
|
27
|
-
},
|
|
28
|
-
producer_versions: [],
|
|
29
|
-
invalid_samples: 0,
|
|
30
|
-
incomplete_samples: 0,
|
|
31
|
-
unreadable_samples: 0,
|
|
32
|
-
capped: false,
|
|
33
|
-
reasons: [],
|
|
34
|
-
};
|
|
35
|
-
const versions = new Map();
|
|
36
|
-
const started = performance.now();
|
|
37
|
-
let filesRead = 0;
|
|
18
|
+
const state = createSamplingState(now, start);
|
|
38
19
|
try {
|
|
39
20
|
const lease = await renewLease(directory, now, reasons);
|
|
40
|
-
result.sampling_since = lease.continuous_since;
|
|
21
|
+
state.result.sampling_since = lease.continuous_since;
|
|
41
22
|
if (Date.parse(lease.continuous_since) > start)
|
|
42
23
|
reasons.add("partial_window");
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
24
|
+
await collectHourlySamples(directory, now, start, options, reasons, state);
|
|
25
|
+
await pruneExpiredHours(directory, now, reasons);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
reasons.add("read_failed");
|
|
29
|
+
}
|
|
30
|
+
return finishSampling(state, reasons);
|
|
31
|
+
}
|
|
32
|
+
function createSamplingState(now, start) {
|
|
33
|
+
return {
|
|
34
|
+
result: {
|
|
35
|
+
schema_version: "memory-hook-performance.v1",
|
|
36
|
+
window_start: new Date(start).toISOString(),
|
|
37
|
+
window_end: now.toISOString(),
|
|
38
|
+
sampling_since: null,
|
|
39
|
+
samples: 0,
|
|
40
|
+
outcomes: { printed: 0, empty: 0, timeouts: 0, failed: 0, skipped: 0 },
|
|
41
|
+
duration_by_cache: {
|
|
42
|
+
hit: blankHistogram(),
|
|
43
|
+
miss: blankHistogram(),
|
|
44
|
+
shared: blankHistogram(),
|
|
45
|
+
unknown: blankHistogram(),
|
|
46
|
+
},
|
|
47
|
+
producer_versions: [],
|
|
48
|
+
invalid_samples: 0,
|
|
49
|
+
incomplete_samples: 0,
|
|
50
|
+
unreadable_samples: 0,
|
|
51
|
+
capped: false,
|
|
52
|
+
reasons: [],
|
|
53
|
+
},
|
|
54
|
+
versions: new Map(),
|
|
55
|
+
started: performance.now(),
|
|
56
|
+
filesRead: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function collectHourlySamples(directory, now, start, options, reasons, state) {
|
|
60
|
+
// Exactly the 25 hour directories touching this rolling 24-hour window.
|
|
61
|
+
// Timestamp filtering below trims both partial boundary hours.
|
|
62
|
+
hours: for (let offset = 0; offset <= 24; offset += 1) {
|
|
63
|
+
const hour = new Date(now.getTime() - offset * HOUR_MS).toISOString().slice(0, 13);
|
|
64
|
+
const entries = await openHourDirectory(directory, hour, reasons);
|
|
65
|
+
if (!entries)
|
|
66
|
+
continue;
|
|
67
|
+
for await (const entry of entries) {
|
|
68
|
+
if (!entry.isFile() || !SAMPLE_FILE.test(entry.name))
|
|
54
69
|
continue;
|
|
70
|
+
if (hasReachedReadLimit(options, state)) {
|
|
71
|
+
state.result.capped = true;
|
|
72
|
+
reasons.add("read_limit");
|
|
73
|
+
break hours;
|
|
55
74
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
performance.now() - started >= (options.readBudgetMs ?? READ_BUDGET_MS)) {
|
|
61
|
-
result.capped = true;
|
|
62
|
-
reasons.add("read_limit");
|
|
63
|
-
break hours;
|
|
64
|
-
}
|
|
65
|
-
filesRead += 1;
|
|
66
|
-
const file = path.join(directory, hour, entry.name);
|
|
67
|
-
let raw;
|
|
68
|
-
try {
|
|
69
|
-
const stat = await fs.stat(file);
|
|
70
|
-
if (stat.size > MAX_SAMPLE_BYTES) {
|
|
71
|
-
result.invalid_samples += 1;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
raw = await fs.readFile(file, "utf8");
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
result.unreadable_samples += 1;
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (!raw.endsWith("\n")) {
|
|
81
|
-
// Could be a live writer or one killed by the host. Leave it for the
|
|
82
|
-
// next tick, and keep the incomplete observation out of every rate.
|
|
83
|
-
result.incomplete_samples += 1;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
let parsed;
|
|
87
|
-
try {
|
|
88
|
-
parsed = JSON.parse(raw);
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
result.invalid_samples += 1;
|
|
92
|
-
continue;
|
|
93
|
-
}
|
|
94
|
-
const sample = MemoryHookSampleSchema.safeParse(parsed);
|
|
95
|
-
if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
|
|
96
|
-
result.invalid_samples += 1;
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
const at = Date.parse(sample.data.recorded_at);
|
|
100
|
-
if (at < start || at > now.getTime())
|
|
101
|
-
continue;
|
|
102
|
-
const version = sample.data.producer_version;
|
|
103
|
-
if (!versions.has(version) && versions.size >= 16) {
|
|
104
|
-
result.capped = true;
|
|
105
|
-
reasons.add("read_limit");
|
|
106
|
-
break hours;
|
|
107
|
-
}
|
|
108
|
-
result.samples += 1;
|
|
109
|
-
result.outcomes[sample.data.outcome] += 1;
|
|
110
|
-
addDuration(result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
|
|
111
|
-
versions.set(version, (versions.get(version) ?? 0) + 1);
|
|
75
|
+
state.filesRead += 1;
|
|
76
|
+
const shouldContinue = await readSample(path.join(directory, hour, entry.name), hour, now, start, reasons, state);
|
|
77
|
+
if (!shouldContinue) {
|
|
78
|
+
break hours;
|
|
112
79
|
}
|
|
113
80
|
}
|
|
114
|
-
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function openHourDirectory(directory, hour, reasons) {
|
|
84
|
+
try {
|
|
85
|
+
return await fs.opendir(path.join(directory, hour));
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== "ENOENT")
|
|
89
|
+
reasons.add("read_failed");
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function hasReachedReadLimit(options, state) {
|
|
94
|
+
return state.filesRead >= (options.maxFiles ?? HOOK_SAMPLE_READ_LIMIT) ||
|
|
95
|
+
performance.now() - state.started >= (options.readBudgetMs ?? READ_BUDGET_MS);
|
|
96
|
+
}
|
|
97
|
+
async function readSample(file, hour, now, start, reasons, state) {
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
const stat = await fs.stat(file);
|
|
101
|
+
if (stat.size > MAX_SAMPLE_BYTES) {
|
|
102
|
+
state.result.invalid_samples += 1;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
raw = await fs.readFile(file, "utf8");
|
|
115
106
|
}
|
|
116
107
|
catch {
|
|
117
|
-
|
|
108
|
+
state.result.unreadable_samples += 1;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (!raw.endsWith("\n")) {
|
|
112
|
+
// Could be a live writer or one killed by the host. Leave it for the
|
|
113
|
+
// next tick, and keep the incomplete observation out of every rate.
|
|
114
|
+
state.result.incomplete_samples += 1;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return recordSample(raw, hour, now, start, reasons, state);
|
|
118
|
+
}
|
|
119
|
+
function recordSample(raw, hour, now, start, reasons, state) {
|
|
120
|
+
let parsed;
|
|
121
|
+
try {
|
|
122
|
+
parsed = JSON.parse(raw);
|
|
118
123
|
}
|
|
119
|
-
|
|
124
|
+
catch {
|
|
125
|
+
state.result.invalid_samples += 1;
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
const sample = MemoryHookSampleSchema.safeParse(parsed);
|
|
129
|
+
if (!sample.success || sample.data.recorded_at.slice(0, 13) !== hour) {
|
|
130
|
+
state.result.invalid_samples += 1;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const at = Date.parse(sample.data.recorded_at);
|
|
134
|
+
if (at < start || at > now.getTime())
|
|
135
|
+
return true;
|
|
136
|
+
const version = sample.data.producer_version;
|
|
137
|
+
if (!state.versions.has(version) && state.versions.size >= 16) {
|
|
138
|
+
state.result.capped = true;
|
|
139
|
+
reasons.add("read_limit");
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
state.result.samples += 1;
|
|
143
|
+
state.result.outcomes[sample.data.outcome] += 1;
|
|
144
|
+
addDuration(state.result.duration_by_cache[sample.data.embed_cache], sample.data.elapsed_ms);
|
|
145
|
+
state.versions.set(version, (state.versions.get(version) ?? 0) + 1);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
function finishSampling(state, reasons) {
|
|
149
|
+
if (!state.result.samples)
|
|
120
150
|
reasons.add("no_samples");
|
|
121
|
-
if (result.invalid_samples)
|
|
151
|
+
if (state.result.invalid_samples)
|
|
122
152
|
reasons.add("invalid_samples");
|
|
123
|
-
if (result.incomplete_samples)
|
|
153
|
+
if (state.result.incomplete_samples)
|
|
124
154
|
reasons.add("incomplete_samples");
|
|
125
|
-
if (result.unreadable_samples)
|
|
155
|
+
if (state.result.unreadable_samples)
|
|
126
156
|
reasons.add("unreadable_samples");
|
|
127
|
-
result.producer_versions = [...versions].sort(([a], [b]) => a.localeCompare(b))
|
|
157
|
+
state.result.producer_versions = [...state.versions].sort(([a], [b]) => a.localeCompare(b))
|
|
128
158
|
.map(([version, samples]) => ({ version, samples }));
|
|
129
|
-
result.reasons = [...reasons];
|
|
130
|
-
return result;
|
|
159
|
+
state.result.reasons = [...reasons];
|
|
160
|
+
return state.result;
|
|
131
161
|
}
|
|
132
162
|
async function renewLease(directory, now, reasons) {
|
|
133
163
|
let continuousSince = now.toISOString();
|
|
134
164
|
try {
|
|
135
165
|
const previous = MemoryHookSamplingLeaseSchema.safeParse(JSON.parse(await fs.readFile(path.join(directory, "lease.json"), "utf8")));
|
|
136
|
-
if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime())
|
|
166
|
+
if (!previous.success || Date.parse(previous.data.continuous_since) > now.getTime()) {
|
|
137
167
|
reasons.add("lease_invalid");
|
|
138
|
-
|
|
168
|
+
}
|
|
169
|
+
else if (Date.parse(previous.data.expires_at) <= now.getTime()) {
|
|
139
170
|
reasons.add("lease_expired");
|
|
140
|
-
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
141
173
|
continuousSince = previous.data.continuous_since;
|
|
174
|
+
}
|
|
142
175
|
}
|
|
143
176
|
catch (error) {
|
|
144
177
|
reasons.add(error.code === "ENOENT" ? "lease_absent" : "lease_invalid");
|
|
@@ -160,8 +193,9 @@ async function pruneExpiredHours(directory, now, reasons) {
|
|
|
160
193
|
const entries = await fs.opendir(directory);
|
|
161
194
|
let pruned = 0;
|
|
162
195
|
for await (const entry of entries) {
|
|
163
|
-
if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest)
|
|
196
|
+
if (!entry.isDirectory() || !HOUR_DIRECTORY.test(entry.name) || entry.name >= oldest) {
|
|
164
197
|
continue;
|
|
198
|
+
}
|
|
165
199
|
// Cleanup belongs to the collector and cannot delay a prompt. A bounded
|
|
166
200
|
// number of expired hours is enough to catch up over successive ticks.
|
|
167
201
|
await fs.rm(path.join(directory, entry.name), { recursive: true, force: true });
|
|
@@ -84,21 +84,11 @@ export async function inspectClaudeMemoryIntegration(options) {
|
|
|
84
84
|
* `matches` pair runs differs, both already parameters here.
|
|
85
85
|
*/
|
|
86
86
|
export async function applyJsonTarget(input) {
|
|
87
|
+
const prepared = await readJsonTargetRoot(input);
|
|
88
|
+
if ("target" in prepared)
|
|
89
|
+
return prepared;
|
|
87
90
|
const { file, options } = input;
|
|
88
|
-
const raw =
|
|
89
|
-
let root;
|
|
90
|
-
if (raw === null || !raw.trim()) {
|
|
91
|
-
root = {};
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
94
|
-
const parsed = parseJsonRecord(raw);
|
|
95
|
-
if (!parsed) {
|
|
96
|
-
// Refuse rather than replace. A `~/.claude.json` that will not parse
|
|
97
|
-
// still holds the person's project history and their other servers.
|
|
98
|
-
return failure(input.target, file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
|
|
99
|
-
}
|
|
100
|
-
root = parsed;
|
|
101
|
-
}
|
|
91
|
+
const { raw, root } = prepared;
|
|
102
92
|
if (input.matches(root)) {
|
|
103
93
|
return { target: input.target, status: "already", reason: "already_current", path: file };
|
|
104
94
|
}
|
|
@@ -126,6 +116,17 @@ export async function applyJsonTarget(input) {
|
|
|
126
116
|
}
|
|
127
117
|
return { target: input.target, status: "installed", reason: "wrote_entry", path: file };
|
|
128
118
|
}
|
|
119
|
+
async function readJsonTargetRoot(input) {
|
|
120
|
+
const raw = await input.options.io.readText(input.file);
|
|
121
|
+
if (raw === null || !raw.trim())
|
|
122
|
+
return { raw, root: {} };
|
|
123
|
+
const root = parseJsonRecord(raw);
|
|
124
|
+
if (root)
|
|
125
|
+
return { raw, root };
|
|
126
|
+
// Refuse rather than replace. A `~/.claude.json` that will not parse
|
|
127
|
+
// still holds the person's project history and their other servers.
|
|
128
|
+
return failure(input.target, input.file, "config_unreadable", "the file is not valid JSON; fix it and re-run");
|
|
129
|
+
}
|
|
129
130
|
/** Exported for `tower-mcp-claude.ts` (BLI-3706) — see `applyJsonTarget`'s note. */
|
|
130
131
|
export async function inspectJsonTarget(input) {
|
|
131
132
|
const raw = await input.io.readText(input.file);
|
|
@@ -123,12 +123,7 @@ async function applyCodexMcpTable(options) {
|
|
|
123
123
|
async function applyCodexSkills(options) {
|
|
124
124
|
const directory = codexSkillDirectory(options.homeDir);
|
|
125
125
|
const files = memoryCodexSkillFiles();
|
|
126
|
-
const stale =
|
|
127
|
-
for (const [relative, contents] of Object.entries(files)) {
|
|
128
|
-
const stored = await options.io.readText(path.join(directory, relative));
|
|
129
|
-
if (stored !== contents)
|
|
130
|
-
stale.push(relative);
|
|
131
|
-
}
|
|
126
|
+
const stale = await findStaleCodexSkillFiles(directory, files, options.io);
|
|
132
127
|
if (stale.length === 0) {
|
|
133
128
|
return {
|
|
134
129
|
target: "codex_skills",
|
|
@@ -181,6 +176,15 @@ async function applyCodexSkills(options) {
|
|
|
181
176
|
detail: `${stale.length} skill file(s) written`,
|
|
182
177
|
};
|
|
183
178
|
}
|
|
179
|
+
async function findStaleCodexSkillFiles(directory, files, io) {
|
|
180
|
+
const stale = [];
|
|
181
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
182
|
+
const stored = await io.readText(path.join(directory, relative));
|
|
183
|
+
if (stored !== contents)
|
|
184
|
+
stale.push(relative);
|
|
185
|
+
}
|
|
186
|
+
return stale;
|
|
187
|
+
}
|
|
184
188
|
export function renderMemoryTable(config) {
|
|
185
189
|
const entries = [
|
|
186
190
|
["command", config.mcp_server.command],
|
|
@@ -51,9 +51,10 @@ export async function resolveMemoryConfig(command, io, platform, deps) {
|
|
|
51
51
|
}
|
|
52
52
|
if (isUnsafeBinPath(found.path)) {
|
|
53
53
|
// A hook command is a shell string by the platform's design. A path that
|
|
54
|
-
// cannot be
|
|
55
|
-
// for a bare name that may resolve to something else either — the
|
|
56
|
-
// refuses and says why.
|
|
54
|
+
// cannot be expressed safely in one is not escaped cleverly, and it is not
|
|
55
|
+
// swapped for a bare name that may resolve to something else either — the
|
|
56
|
+
// install refuses and says why. See `shellSafeBinPath` for what CAN be
|
|
57
|
+
// expressed, including the Windows separator normalisation of BLI-4136.
|
|
57
58
|
return {
|
|
58
59
|
config: null,
|
|
59
60
|
source: "none",
|
|
@@ -61,7 +62,7 @@ export async function resolveMemoryConfig(command, io, platform, deps) {
|
|
|
61
62
|
target: "bin",
|
|
62
63
|
status: "failed",
|
|
63
64
|
reason: "bin_path_unsafe",
|
|
64
|
-
detail: "the resolved bin path
|
|
65
|
+
detail: "the resolved bin path cannot be expressed safely in a hook command; nothing was written",
|
|
65
66
|
},
|
|
66
67
|
bin_source: found.source,
|
|
67
68
|
};
|
|
@@ -49,6 +49,12 @@
|
|
|
49
49
|
* machine resolved before anything is written. Skipping that step registered
|
|
50
50
|
* three hooks that answered `command not found` on every turn.
|
|
51
51
|
*
|
|
52
|
+
* **And the path it is re-pointed at is written for a SHELL, not for the
|
|
53
|
+
* filesystem** — `shellSafeBinPath`. A hook command is a shell string, Windows
|
|
54
|
+
* runs it through bash, and a native path's backslashes are eaten as escapes:
|
|
55
|
+
* the same three hooks, the same `command not found`, arrived at from the other
|
|
56
|
+
* direction (BLI-4136).
|
|
57
|
+
*
|
|
52
58
|
* Nothing here touches the filesystem. The halves that do are
|
|
53
59
|
* `memory-install-claude.ts` and `memory-install-codex.ts`.
|
|
54
60
|
*/
|
|
@@ -96,16 +102,56 @@ export const MEMORY_AUTO_APPROVE_TOOLS = [
|
|
|
96
102
|
];
|
|
97
103
|
/** Characters that would let a resolved path change the meaning of a hook command string. */
|
|
98
104
|
const UNSAFE_PATH_CHARACTERS = /["'`$;&|<>\r\n]/u;
|
|
105
|
+
/**
|
|
106
|
+
* Windows device paths — `\\?\C:\…` (extended length) and `\\.\…` (device
|
|
107
|
+
* namespace). Both prefixes are DEFINED in terms of backslashes and stop being
|
|
108
|
+
* that prefix the moment `shellSafeBinPath` turns them into forward slashes, so
|
|
109
|
+
* a hook command cannot carry one without silently naming a different file.
|
|
110
|
+
* Refused with a name rather than rewritten into something plausible.
|
|
111
|
+
*/
|
|
112
|
+
const WINDOWS_DEVICE_PATH = /^\\\\[?.]\\/u;
|
|
99
113
|
export function isUnsafeBinPath(binPath) {
|
|
100
|
-
return UNSAFE_PATH_CHARACTERS.test(binPath);
|
|
114
|
+
return UNSAFE_PATH_CHARACTERS.test(binPath) || WINDOWS_DEVICE_PATH.test(binPath);
|
|
101
115
|
}
|
|
102
116
|
/**
|
|
103
|
-
* A
|
|
104
|
-
*
|
|
105
|
-
*
|
|
117
|
+
* A path that no shell gives a meaning to, so it needs no quoting. Deliberately
|
|
118
|
+
* a tight allow-list rather than a list of known-bad characters: `(` and `)` in
|
|
119
|
+
* `C:/Program Files (x86)/…` are a bash syntax error, and the earlier
|
|
120
|
+
* whitespace-only trigger caught them only by the accident of the space.
|
|
121
|
+
*/
|
|
122
|
+
const PLAINLY_SAFE_PATH = /^[A-Za-z0-9_@:./+,-]+$/u;
|
|
123
|
+
/**
|
|
124
|
+
* The resolved bin path, as it must appear INSIDE a hook command string.
|
|
125
|
+
*
|
|
126
|
+
* A Claude Code hook `command` is a shell string by the platform's design, and
|
|
127
|
+
* on Windows the host runs it through **bash** — which eats every backslash as
|
|
128
|
+
* an escape. So the native path this installer resolved,
|
|
129
|
+
* `C:\Users\…\.bin\bli-memory-mcp.cmd`, reaches the shell as
|
|
130
|
+
* `C:Users….binbli-memory-mcp.cmd` and answers `command not found` on every
|
|
131
|
+
* SessionStart, every prompt and every Stop. Non-blocking and therefore silent:
|
|
132
|
+
* memory recall simply degrades, and nothing says so (BLI-4136, fixed by hand
|
|
133
|
+
* twice on the same machine before the installer was the thing that changed).
|
|
134
|
+
*
|
|
135
|
+
* Two independent defences, because either one alone is a thin edge:
|
|
136
|
+
*
|
|
137
|
+
* 1. **Separators are normalised to `/` on Windows.** Win32 accepts forward
|
|
138
|
+
* slashes in every path it resolves, and after this there is no escape
|
|
139
|
+
* character left in the string for a shell to consume. This is also what
|
|
140
|
+
* survives a `\\`: double-quoting ALONE would collapse the UNC prefix of
|
|
141
|
+
* `\\server\share\…` back to one backslash and name the wrong path.
|
|
142
|
+
* 2. **Anything not plainly safe is double-quoted**, which covers spaces and
|
|
143
|
+
* the bracket characters in `Program Files (x86)`.
|
|
144
|
+
*
|
|
145
|
+
* A path that still could not be expressed safely is refused upstream
|
|
146
|
+
* (`isUnsafeBinPath`, checked in `resolveMemoryConfig`) rather than escaped
|
|
147
|
+
* cleverly.
|
|
148
|
+
*
|
|
149
|
+
* POSIX keeps its separators untouched — a backslash there is a legal
|
|
150
|
+
* character in a filename, and rewriting it would name a different file.
|
|
106
151
|
*/
|
|
107
|
-
export function
|
|
108
|
-
|
|
152
|
+
export function shellSafeBinPath(binPath, platform) {
|
|
153
|
+
const forShell = platform === "win32" ? binPath.replace(/\\/gu, "/") : binPath;
|
|
154
|
+
return PLAINLY_SAFE_PATH.test(forShell) ? forShell : `"${forShell}"`;
|
|
109
155
|
}
|
|
110
156
|
/**
|
|
111
157
|
* Windows cannot spawn an npm `.cmd` shim directly — Node refuses it without a
|
|
@@ -125,13 +171,13 @@ export function memoryMcpServerEntry(options) {
|
|
|
125
171
|
return { command: options.binPath, args: [], env };
|
|
126
172
|
}
|
|
127
173
|
export function builtinMemoryInstallConfig(options) {
|
|
128
|
-
const
|
|
174
|
+
const binPath = shellSafeBinPath(options.binPath, options.platform);
|
|
129
175
|
return {
|
|
130
176
|
server_id: MEMORY_MCP_SERVER_ID,
|
|
131
177
|
mcp_server: memoryMcpServerEntry(options),
|
|
132
178
|
hooks: MEMORY_HOOK_EVENTS.map((event) => ({
|
|
133
179
|
event,
|
|
134
|
-
command: `${
|
|
180
|
+
command: `${binPath} ${MEMORY_HOOK_SUBCOMMAND[event]}`,
|
|
135
181
|
timeout_seconds: MEMORY_HOOK_TIMEOUT_SECONDS[event],
|
|
136
182
|
})),
|
|
137
183
|
permissions_allow: [...MEMORY_AUTO_APPROVE_TOOLS],
|
|
@@ -237,13 +283,13 @@ export function parsePrintedMemoryInstallConfig(stdout) {
|
|
|
237
283
|
* hook that runs something else.
|
|
238
284
|
*/
|
|
239
285
|
export function withResolvedBinPath(config, options) {
|
|
240
|
-
const
|
|
286
|
+
const binPath = shellSafeBinPath(options.binPath, options.platform);
|
|
241
287
|
const hooks = [];
|
|
242
288
|
for (const hook of config.hooks) {
|
|
243
289
|
const tail = hookSubcommandTail(hook.command);
|
|
244
290
|
if (!tail)
|
|
245
291
|
return null;
|
|
246
|
-
hooks.push({ ...hook, command: `${
|
|
292
|
+
hooks.push({ ...hook, command: `${binPath} ${tail}` });
|
|
247
293
|
}
|
|
248
294
|
const entry = memoryMcpServerEntry(options);
|
|
249
295
|
return {
|
|
@@ -83,17 +83,7 @@ export function logMemoryOutcome(outcome, platform) {
|
|
|
83
83
|
: "[memory-install] BLI Memory registration converged", JSON.stringify(fields));
|
|
84
84
|
}
|
|
85
85
|
export function memoryOutcomeLines(outcome) {
|
|
86
|
-
const headline = outcome
|
|
87
|
-
? "BLI Memory registered on this machine."
|
|
88
|
-
: outcome.status === "already"
|
|
89
|
-
? "BLI Memory is already registered on this machine."
|
|
90
|
-
: outcome.status === "would_install"
|
|
91
|
-
? "BLI Memory would be registered (dry run; nothing was written)."
|
|
92
|
-
: outcome.status === "missing"
|
|
93
|
-
? "BLI Memory is not registered on this machine."
|
|
94
|
-
: outcome.status === "skipped"
|
|
95
|
-
? "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet."
|
|
96
|
-
: `BLI Memory is not fully registered: ${outcome.reason}.`;
|
|
86
|
+
const headline = memoryOutcomeHeadline(outcome);
|
|
97
87
|
const lines = [headline, ` ${memoryReceiptLine(outcome.receipt)}`];
|
|
98
88
|
// BLI-3884. Only `status` asks; an absent reading is not printed as "no
|
|
99
89
|
// daemon", because nothing looked.
|
|
@@ -115,4 +105,19 @@ export function memoryOutcomeLines(outcome) {
|
|
|
115
105
|
lines.push(` ${target.target}: ${target.status} (${target.reason})${where}${detail}`);
|
|
116
106
|
}
|
|
117
107
|
return lines;
|
|
108
|
+
}
|
|
109
|
+
function memoryOutcomeHeadline(outcome) {
|
|
110
|
+
if (outcome.status === "installed")
|
|
111
|
+
return "BLI Memory registered on this machine.";
|
|
112
|
+
if (outcome.status === "already")
|
|
113
|
+
return "BLI Memory is already registered on this machine.";
|
|
114
|
+
if (outcome.status === "would_install") {
|
|
115
|
+
return "BLI Memory would be registered (dry run; nothing was written).";
|
|
116
|
+
}
|
|
117
|
+
if (outcome.status === "missing")
|
|
118
|
+
return "BLI Memory is not registered on this machine.";
|
|
119
|
+
if (outcome.status === "skipped") {
|
|
120
|
+
return "BLI Memory was not registered and nothing was written: the bli-memory-mcp server is not on this machine yet.";
|
|
121
|
+
}
|
|
122
|
+
return `BLI Memory is not fully registered: ${outcome.reason}.`;
|
|
118
123
|
}
|
|
@@ -13,16 +13,6 @@
|
|
|
13
13
|
* install is idempotent to the byte and a drifted copy is replaced rather than
|
|
14
14
|
* merged — the same deal `cockpit agent-rules` offers for its managed block.
|
|
15
15
|
*/
|
|
16
|
-
/** Relative path → exact file contents. The map IS the install. */
|
|
17
|
-
export function memoryCodexSkillFiles() {
|
|
18
|
-
return {
|
|
19
|
-
"SKILL.md": SKILL_MD,
|
|
20
|
-
"references/search.md": SEARCH_MD,
|
|
21
|
-
"references/save.md": SAVE_MD,
|
|
22
|
-
"references/update.md": UPDATE_MD,
|
|
23
|
-
"references/forget.md": FORGET_MD,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
16
|
const SKILL_MD = `---
|
|
27
17
|
name: bli-memory
|
|
28
18
|
description: BLI Memory — durable memory for this machine. Use when you need to recall what was decided before, or when a session produced a durable decision, preference or correction worth keeping.
|
|
@@ -118,4 +108,14 @@ again. Matching by content is exact after whitespace normalisation — never
|
|
|
118
108
|
fuzzy, and never widened to every container.
|
|
119
109
|
|
|
120
110
|
It returns what it removed. A bare "done" is not an answer.
|
|
121
|
-
`;
|
|
111
|
+
`;
|
|
112
|
+
/** Relative path → exact file contents. The map IS the install. */
|
|
113
|
+
export function memoryCodexSkillFiles() {
|
|
114
|
+
return {
|
|
115
|
+
"SKILL.md": SKILL_MD,
|
|
116
|
+
"references/search.md": SEARCH_MD,
|
|
117
|
+
"references/save.md": SAVE_MD,
|
|
118
|
+
"references/update.md": UPDATE_MD,
|
|
119
|
+
"references/forget.md": FORGET_MD,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -5,20 +5,37 @@ import { callTower, openTower } from "./tower-command.js";
|
|
|
5
5
|
function sender(command, io) {
|
|
6
6
|
return async (entry) => {
|
|
7
7
|
const tower = await openTower("memory log", command, io);
|
|
8
|
-
const result = await callTower(tower, {
|
|
9
|
-
|
|
8
|
+
const result = await callTower(tower, {
|
|
9
|
+
path: "/api/memory/experience",
|
|
10
|
+
method: "POST",
|
|
11
|
+
body: entry,
|
|
12
|
+
label: "memory experience",
|
|
13
|
+
timeoutMs: 5000,
|
|
14
|
+
});
|
|
15
|
+
return result.ok
|
|
16
|
+
? {
|
|
17
|
+
ok: result.body?.ok === true,
|
|
18
|
+
reason: "experience_not_acknowledged",
|
|
19
|
+
}
|
|
20
|
+
: result;
|
|
10
21
|
};
|
|
11
22
|
}
|
|
12
23
|
export async function runMemoryLog(command, io) {
|
|
13
|
-
const reason = command.reasonStdin
|
|
24
|
+
const reason = command.reasonStdin
|
|
25
|
+
? (await readPipedText(io.stdin, { maxChars: 1002 })).trim()
|
|
26
|
+
: command.reason;
|
|
14
27
|
validateExperience(command.store, command.verdict, reason);
|
|
15
28
|
const entry = await appendExperience({ store: command.store, verdict: command.verdict, reason }, {
|
|
16
|
-
homeDir: command.homeDir,
|
|
29
|
+
homeDir: command.homeDir,
|
|
30
|
+
agent: "cockpit",
|
|
31
|
+
project: path.basename(process.cwd()),
|
|
17
32
|
});
|
|
18
33
|
const delivery = await shipExperience(entry, sender(command, io), command.homeDir);
|
|
19
34
|
const receipt = { ok: true, id: entry.id, local: true, ...delivery };
|
|
20
35
|
writeLine(io.stderr, `[memory experience] recorded ${JSON.stringify(receipt)}`);
|
|
21
|
-
writeLine(io.stdout, command.json
|
|
36
|
+
writeLine(io.stdout, command.json
|
|
37
|
+
? JSON.stringify(receipt)
|
|
38
|
+
: `Experience appended; shipped: ${delivery.shipped} (${delivery.reason}).`);
|
|
22
39
|
return 0;
|
|
23
40
|
}
|
|
24
41
|
export async function runMemoryExperienceAfterSync(command, io) {
|
package/dist/commands/msg.js
CHANGED
|
@@ -220,16 +220,23 @@ async function createChannel(command, door) {
|
|
|
220
220
|
const membersAdded = body.members_added ?? [];
|
|
221
221
|
const membersFailed = body.members_failed ?? [];
|
|
222
222
|
const requestedMembers = command.memberEmails?.length ?? 0;
|
|
223
|
+
logChannelCreation(door, channel, command.isPrivate, requestedMembers, membersAdded, membersFailed);
|
|
224
|
+
if (door.json) {
|
|
225
|
+
return emitAgentDoor(door, { ok: true, channel, membersAdded, membersFailed });
|
|
226
|
+
}
|
|
227
|
+
reportChannelCreationToHuman(door, channel, name, requestedMembers, membersAdded, membersFailed);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
function logChannelCreation(door, channel, isPrivate, requestedMembers, membersAdded, membersFailed) {
|
|
223
231
|
writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({
|
|
224
232
|
channel_id: channel?.id ?? null,
|
|
225
|
-
is_private:
|
|
233
|
+
is_private: isPrivate,
|
|
226
234
|
members_requested: requestedMembers,
|
|
227
235
|
members_added: membersAdded.length,
|
|
228
236
|
members_failed: membersFailed.length,
|
|
229
237
|
})}`);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
238
|
+
}
|
|
239
|
+
function reportChannelCreationToHuman(door, channel, name, requestedMembers, membersAdded, membersFailed) {
|
|
233
240
|
writeLine(door.io.stdout, `Created #${channel?.name ?? name} (${channel?.id ?? "?"}).`);
|
|
234
241
|
if (membersAdded.length > 0) {
|
|
235
242
|
writeLine(door.io.stdout, `${membersAdded.length} member(s) added.`);
|
|
@@ -246,7 +253,6 @@ async function createChannel(command, door) {
|
|
|
246
253
|
for (const failure of membersFailed) {
|
|
247
254
|
writeLine(door.io.stdout, `Not added (${failure.reason}): ${failure.userId}`);
|
|
248
255
|
}
|
|
249
|
-
return 0;
|
|
250
256
|
}
|
|
251
257
|
async function openDm(command, door) {
|
|
252
258
|
const email = command.dmEmail ?? "";
|