@plur-ai/cli 0.9.10 → 0.9.11
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 +3 -2
- package/dist/commands/doctor.js +131 -28
- package/dist/commands/embedder-probe.js +47 -0
- package/dist/commands/hook-session-guard.js +26 -1
- package/dist/commands/init.js +155 -109
- package/dist/commands/stores.js +1 -1
- package/dist/commands/tensions.js +168 -0
- package/dist/index.js +8 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ npx @plur-ai/cli@0.9.4 status
|
|
|
21
21
|
## Quick Start
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
# Install Claude Code hooks (automatic memory injection)
|
|
24
|
+
# Install Claude Code hooks + local hook binary (automatic memory injection)
|
|
25
25
|
plur init
|
|
26
26
|
|
|
27
27
|
# Store a learning
|
|
@@ -59,7 +59,8 @@ plur forget ENG-2026-0329-001
|
|
|
59
59
|
| `plur sync` | Cross-device sync via git |
|
|
60
60
|
| `plur packs list` | List installed engram packs |
|
|
61
61
|
| `plur packs install <source>` | Install an engram pack |
|
|
62
|
-
| `plur init` | Install Claude Code hooks for automatic injection |
|
|
62
|
+
| `plur init` | Install Claude Code hooks + local hook binary for automatic injection |
|
|
63
|
+
| `plur doctor` | Diagnose installation health (hooks, MCP, shim, embedder) |
|
|
63
64
|
|
|
64
65
|
## Global Flags
|
|
65
66
|
|
package/dist/commands/doctor.js
CHANGED
|
@@ -10,23 +10,50 @@ import {
|
|
|
10
10
|
outputText,
|
|
11
11
|
shouldOutputJson
|
|
12
12
|
} from "./chunk-7U4W4J3G.js";
|
|
13
|
-
import {
|
|
14
|
-
createPlur
|
|
15
|
-
} from "./chunk-O6WTH7H7.js";
|
|
16
13
|
|
|
17
14
|
// src/commands/doctor.ts
|
|
18
15
|
import { spawn } from "child_process";
|
|
16
|
+
import { existsSync, readFileSync, realpathSync } from "fs";
|
|
17
|
+
import { join, extname } from "path";
|
|
18
|
+
import { homedir, platform } from "os";
|
|
19
19
|
function hasAnyPlurHook(config) {
|
|
20
20
|
const hooks = config.hooks ?? {};
|
|
21
21
|
for (const entries of Object.values(hooks)) {
|
|
22
22
|
for (const entry of entries) {
|
|
23
23
|
for (const h of entry.hooks ?? []) {
|
|
24
|
-
if (h.command && h.command.includes("@plur-ai/cli")) return true;
|
|
24
|
+
if (h.command && (h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook"))) return true;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function hasStaleNpxHooks(config) {
|
|
31
|
+
const hooks = config.hooks ?? {};
|
|
32
|
+
for (const entries of Object.values(hooks)) {
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
for (const h of entry.hooks ?? []) {
|
|
35
|
+
if (h.command && h.command.includes("npx") && h.command.includes("@plur-ai/cli")) return true;
|
|
25
36
|
}
|
|
26
37
|
}
|
|
27
38
|
}
|
|
28
39
|
return false;
|
|
29
40
|
}
|
|
41
|
+
function validateHookShim() {
|
|
42
|
+
const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
|
|
43
|
+
const path = join(homedir(), ".plur", "bin", name);
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
return { valid: false, shimPath: path, error: "shim not found \u2014 run `plur init` to create it" };
|
|
46
|
+
}
|
|
47
|
+
const content = readFileSync(path, "utf-8");
|
|
48
|
+
const match = content.match(/"([^"]+index\.js)"/);
|
|
49
|
+
if (!match) {
|
|
50
|
+
return { valid: false, shimPath: path, error: "shim has unexpected format" };
|
|
51
|
+
}
|
|
52
|
+
if (!existsSync(match[1])) {
|
|
53
|
+
return { valid: false, shimPath: path, error: `entrypoint missing: ${match[1]} \u2014 run \`plur init\` to fix` };
|
|
54
|
+
}
|
|
55
|
+
return { valid: true, shimPath: path };
|
|
56
|
+
}
|
|
30
57
|
function inspectConfigs() {
|
|
31
58
|
return knownConfigFiles().map((cf) => {
|
|
32
59
|
if (!cf.exists) {
|
|
@@ -121,46 +148,111 @@ async function mcpHandshake(timeoutMs = 2e4) {
|
|
|
121
148
|
proc.stdin?.write(JSON.stringify(initRequest) + "\n");
|
|
122
149
|
});
|
|
123
150
|
}
|
|
124
|
-
|
|
151
|
+
function resolveCliJsEntry() {
|
|
152
|
+
const argv1 = process.argv[1];
|
|
153
|
+
if (!argv1) return null;
|
|
154
|
+
let resolved;
|
|
125
155
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
available: status.available,
|
|
138
|
-
loaded: status.loaded,
|
|
139
|
-
lastError: status.lastError,
|
|
140
|
-
modelLoaded: status.available && status.loaded,
|
|
141
|
-
disabled: status.disabled,
|
|
142
|
-
disabledReason: status.disabledReason
|
|
143
|
-
};
|
|
144
|
-
} catch (err) {
|
|
145
|
-
return {
|
|
156
|
+
resolved = realpathSync(argv1);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const ext = extname(resolved).toLowerCase();
|
|
161
|
+
if (ext !== ".js" && ext !== ".mjs" && ext !== ".cjs") return null;
|
|
162
|
+
return resolved;
|
|
163
|
+
}
|
|
164
|
+
async function checkEmbedder(_flags, timeoutMs = 1e4) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const fallback = (lastError) => ({
|
|
146
167
|
available: false,
|
|
147
168
|
loaded: false,
|
|
148
|
-
lastError
|
|
169
|
+
lastError,
|
|
149
170
|
modelLoaded: false,
|
|
150
171
|
disabled: false,
|
|
151
172
|
disabledReason: null
|
|
173
|
+
});
|
|
174
|
+
const cliEntry = resolveCliJsEntry();
|
|
175
|
+
if (!cliEntry) {
|
|
176
|
+
resolve(fallback("embedder probe skipped: CLI entry is not a JS file (compiled binary?)"));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let resolved = false;
|
|
180
|
+
const finish = (result) => {
|
|
181
|
+
if (resolved) return;
|
|
182
|
+
resolved = true;
|
|
183
|
+
try {
|
|
184
|
+
proc.kill();
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
187
|
+
resolve(result);
|
|
152
188
|
};
|
|
153
|
-
|
|
189
|
+
let proc;
|
|
190
|
+
try {
|
|
191
|
+
proc = spawn(process.execPath, [cliEntry, "_embedder-probe"], {
|
|
192
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
193
|
+
// Mark the subprocess as the parent-spawned probe — the probe checks
|
|
194
|
+
// this and refuses to run if invoked directly by a curious user.
|
|
195
|
+
env: { ...process.env, PLUR_INTERNAL_PROBE: "1" }
|
|
196
|
+
});
|
|
197
|
+
} catch (err) {
|
|
198
|
+
finish(fallback(`spawn failed: ${err.message}`));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const timeout = setTimeout(() => {
|
|
202
|
+
finish(fallback(`probe timeout after ${timeoutMs}ms`));
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
let stdoutBuf = "";
|
|
205
|
+
let stderrBuf = "";
|
|
206
|
+
proc.stdout?.on("data", (chunk) => {
|
|
207
|
+
stdoutBuf += chunk.toString("utf8");
|
|
208
|
+
});
|
|
209
|
+
proc.stderr?.on("data", (chunk) => {
|
|
210
|
+
stderrBuf += chunk.toString("utf8");
|
|
211
|
+
});
|
|
212
|
+
proc.on("error", (err) => {
|
|
213
|
+
clearTimeout(timeout);
|
|
214
|
+
finish(fallback(`probe spawn error: ${err.message}`));
|
|
215
|
+
});
|
|
216
|
+
proc.on("exit", (code, signal) => {
|
|
217
|
+
clearTimeout(timeout);
|
|
218
|
+
const lines = stdoutBuf.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("{"));
|
|
219
|
+
const resultLine = lines[lines.length - 1];
|
|
220
|
+
if (resultLine) {
|
|
221
|
+
try {
|
|
222
|
+
const parsed = JSON.parse(resultLine);
|
|
223
|
+
finish({
|
|
224
|
+
available: !!parsed.available,
|
|
225
|
+
loaded: !!parsed.loaded,
|
|
226
|
+
lastError: parsed.lastError ?? null,
|
|
227
|
+
modelLoaded: !!parsed.modelLoaded,
|
|
228
|
+
disabled: !!parsed.disabled,
|
|
229
|
+
disabledReason: parsed.disabledReason ?? null
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
} catch {
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const detail = signal ? `signal ${signal}` : `exit ${code}`;
|
|
236
|
+
const stderrTrim = stderrBuf.trim().slice(0, 200);
|
|
237
|
+
finish(fallback(`embedder probe failed (${detail})${stderrTrim ? `: ${stderrTrim}` : ""}`));
|
|
238
|
+
});
|
|
239
|
+
});
|
|
154
240
|
}
|
|
155
241
|
function buildReport(skipHandshake, flags) {
|
|
156
242
|
const configs = inspectConfigs();
|
|
157
243
|
const hooksInstalled = configs.some((c) => c.hasPlurHooks);
|
|
158
244
|
const mcpRegistered = configs.some((c) => c.hasPlurMcp);
|
|
159
245
|
const datacoreCollision = configs.some((c) => c.hasDatacoreMcp);
|
|
246
|
+
const staleNpxHooks = configs.some((c) => {
|
|
247
|
+
if (!c.exists) return false;
|
|
248
|
+
const config = readConfig(c.path);
|
|
249
|
+
return hasStaleNpxHooks(config);
|
|
250
|
+
});
|
|
251
|
+
const hookShim = validateHookShim();
|
|
160
252
|
const handshakePromise = skipHandshake ? Promise.resolve({ ok: false, error: "skipped (--no-handshake)" }) : mcpHandshake();
|
|
161
253
|
return Promise.all([handshakePromise, checkEmbedder(flags)]).then(([handshake, embedder]) => {
|
|
162
254
|
const overall = hooksInstalled && mcpRegistered && (skipHandshake || handshake.ok) ? "ok" : "fail";
|
|
163
|
-
return { configs, hooksInstalled, mcpRegistered, datacoreCollision, handshake, embedder, overall };
|
|
255
|
+
return { configs, hooksInstalled, mcpRegistered, datacoreCollision, staleNpxHooks, hookShim, handshake, embedder, overall };
|
|
164
256
|
});
|
|
165
257
|
}
|
|
166
258
|
function printText(report) {
|
|
@@ -192,6 +284,17 @@ function printText(report) {
|
|
|
192
284
|
outputText(" If your agent confuses them, this is the cause.");
|
|
193
285
|
}
|
|
194
286
|
outputText("");
|
|
287
|
+
if (report.hookShim.valid) {
|
|
288
|
+
outputText(`\u2713 Hook shim: ${report.hookShim.shimPath}`);
|
|
289
|
+
} else {
|
|
290
|
+
outputText(`\u2717 Hook shim: ${report.hookShim.error}`);
|
|
291
|
+
}
|
|
292
|
+
if (report.staleNpxHooks) {
|
|
293
|
+
outputText("");
|
|
294
|
+
outputText("\u26A0 Hooks still use npx \u2014 slow (200-2000ms per hook) and vulnerable to cache corruption.");
|
|
295
|
+
outputText(" Fix: run `plur init` to migrate to the local hook binary (<5ms per hook).");
|
|
296
|
+
}
|
|
297
|
+
outputText("");
|
|
195
298
|
if (report.handshake.ok) {
|
|
196
299
|
outputText(`\u2713 MCP handshake: ${report.handshake.serverName} v${report.handshake.serverVersion}`);
|
|
197
300
|
} else {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPlur
|
|
3
|
+
} from "./chunk-O6WTH7H7.js";
|
|
4
|
+
|
|
5
|
+
// src/commands/embedder-probe.ts
|
|
6
|
+
async function run(_args, flags) {
|
|
7
|
+
if (process.env.PLUR_INTERNAL_PROBE !== "1") {
|
|
8
|
+
process.stderr.write(
|
|
9
|
+
"_embedder-probe is an internal subcommand spawned by `plur doctor`. Run `plur doctor` instead.\n"
|
|
10
|
+
);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const plur = createPlur(flags);
|
|
15
|
+
const preStatus = plur.embedderStatus();
|
|
16
|
+
if (!preStatus.disabled) {
|
|
17
|
+
plur.resetEmbedder();
|
|
18
|
+
try {
|
|
19
|
+
await plur.recallSemantic("plur doctor probe", { limit: 1 });
|
|
20
|
+
} catch {
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const status = plur.embedderStatus();
|
|
24
|
+
process.stdout.write(JSON.stringify({
|
|
25
|
+
available: status.available,
|
|
26
|
+
loaded: status.loaded,
|
|
27
|
+
lastError: status.lastError,
|
|
28
|
+
modelLoaded: status.available && status.loaded,
|
|
29
|
+
disabled: status.disabled,
|
|
30
|
+
disabledReason: status.disabledReason
|
|
31
|
+
}) + "\n");
|
|
32
|
+
process.exit(0);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
process.stdout.write(JSON.stringify({
|
|
35
|
+
available: false,
|
|
36
|
+
loaded: false,
|
|
37
|
+
lastError: err instanceof Error ? err.message : String(err),
|
|
38
|
+
modelLoaded: false,
|
|
39
|
+
disabled: false,
|
|
40
|
+
disabledReason: null
|
|
41
|
+
}) + "\n");
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
run
|
|
47
|
+
};
|
|
@@ -3,13 +3,14 @@ import {
|
|
|
3
3
|
} from "./chunk-6RANUJMM.js";
|
|
4
4
|
|
|
5
5
|
// src/commands/hook-session-guard.ts
|
|
6
|
-
import { readSync, existsSync } from "fs";
|
|
6
|
+
import { readSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
import { tmpdir } from "os";
|
|
9
9
|
var EXEMPT_TOOLS = /* @__PURE__ */ new Set([
|
|
10
10
|
"mcp__plur__plur_session_start",
|
|
11
11
|
"ToolSearch"
|
|
12
12
|
]);
|
|
13
|
+
var MAX_BLOCKS_BEFORE_FALLBACK = 5;
|
|
13
14
|
function readStdinRaw() {
|
|
14
15
|
try {
|
|
15
16
|
const chunks = [];
|
|
@@ -31,6 +32,22 @@ function readStdinRaw() {
|
|
|
31
32
|
function sentinelPath(sessionId) {
|
|
32
33
|
return join(tmpdir(), `plur-session-${sessionId}`);
|
|
33
34
|
}
|
|
35
|
+
function blockCountPath(sessionId) {
|
|
36
|
+
const dir = join(tmpdir(), "plur-sessions");
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
return join(dir, `${sessionId}.guard-count`);
|
|
39
|
+
}
|
|
40
|
+
function incrementBlockCount(sessionId) {
|
|
41
|
+
const path = blockCountPath(sessionId);
|
|
42
|
+
let count = 0;
|
|
43
|
+
try {
|
|
44
|
+
count = parseInt(readFileSync(path, "utf8"), 10) || 0;
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
count++;
|
|
48
|
+
writeFileSync(path, String(count));
|
|
49
|
+
return count;
|
|
50
|
+
}
|
|
34
51
|
async function run(_args, _flags) {
|
|
35
52
|
if (!isPlurConfigured()) return;
|
|
36
53
|
const raw = readStdinRaw();
|
|
@@ -45,6 +62,14 @@ async function run(_args, _flags) {
|
|
|
45
62
|
if (EXEMPT_TOOLS.has(toolName)) return;
|
|
46
63
|
if (!sessionId) return;
|
|
47
64
|
if (existsSync(sentinelPath(sessionId))) return;
|
|
65
|
+
const blockCount = incrementBlockCount(sessionId);
|
|
66
|
+
if (blockCount > MAX_BLOCKS_BEFORE_FALLBACK) {
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
`[plur] WARNING: session guard gave up after ${MAX_BLOCKS_BEFORE_FALLBACK} blocked calls. The plur MCP server may not be running. Run \`plur doctor\` to diagnose.
|
|
69
|
+
`
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
48
73
|
const output = {
|
|
49
74
|
hookSpecificOutput: {
|
|
50
75
|
hookEventName: "PreToolUse",
|
package/dist/commands/init.js
CHANGED
|
@@ -11,115 +11,153 @@ import {
|
|
|
11
11
|
} from "./chunk-7U4W4J3G.js";
|
|
12
12
|
|
|
13
13
|
// src/commands/init.ts
|
|
14
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
15
|
-
import { join } from "path";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
var PLUR_HOOKS_INJECTION = {
|
|
47
|
-
// First message: inject engrams based on the prompt.
|
|
48
|
-
// Subsequent messages: periodic reminder to call plur_learn (~1ms skip).
|
|
49
|
-
UserPromptSubmit: [
|
|
50
|
-
{
|
|
51
|
-
hooks: [
|
|
52
|
-
{ type: "command", command: `${CLI} hook-inject`, timeout: 15 }
|
|
53
|
-
]
|
|
54
|
-
}
|
|
55
|
-
],
|
|
56
|
-
// Re-inject after context compaction so engrams survive long conversations.
|
|
57
|
-
PostCompact: [
|
|
58
|
-
{
|
|
59
|
-
matcher: "auto|manual",
|
|
60
|
-
hooks: [
|
|
61
|
-
{ type: "command", command: `${CLI} hook-inject --rehydrate`, timeout: 15 }
|
|
62
|
-
]
|
|
63
|
-
}
|
|
64
|
-
],
|
|
65
|
-
PreToolUse: [
|
|
66
|
-
// Full injection when entering plan mode — planning needs broad context
|
|
67
|
-
{
|
|
68
|
-
matcher: "EnterPlanMode",
|
|
69
|
-
hooks: [
|
|
70
|
-
{ type: "command", command: `${CLI} hook-inject --event plan_mode`, timeout: 10 }
|
|
71
|
-
]
|
|
72
|
-
},
|
|
73
|
-
// Domain-specific engrams when a skill is invoked
|
|
74
|
-
{
|
|
75
|
-
matcher: "Skill",
|
|
76
|
-
hooks: [
|
|
77
|
-
{ type: "command", command: `${CLI} hook-inject --event skill`, timeout: 10 }
|
|
78
|
-
]
|
|
79
|
-
},
|
|
80
|
-
// Agent-scoped engrams when spawning an agent
|
|
81
|
-
{
|
|
82
|
-
matcher: "Agent",
|
|
83
|
-
hooks: [
|
|
84
|
-
{ type: "command", command: `${CLI} hook-inject --event agent`, timeout: 10 }
|
|
85
|
-
]
|
|
86
|
-
},
|
|
87
|
-
// Observation capture — log tool calls for offline pattern extraction
|
|
88
|
-
{
|
|
89
|
-
matcher: "Bash|Edit|Write|Agent",
|
|
90
|
-
hooks: [
|
|
91
|
-
{ type: "command", command: `${CLI} hook-observe`, timeout: 3 }
|
|
92
|
-
]
|
|
93
|
-
}
|
|
94
|
-
],
|
|
95
|
-
PostToolUse: [
|
|
96
|
-
{
|
|
97
|
-
matcher: "Bash|Edit|Write|Agent",
|
|
98
|
-
hooks: [
|
|
99
|
-
{ type: "command", command: `${CLI} hook-observe --post`, timeout: 3 }
|
|
100
|
-
]
|
|
101
|
-
}
|
|
102
|
-
],
|
|
103
|
-
// Inject agent-scoped engrams into subagent context
|
|
104
|
-
SubagentStart: [
|
|
105
|
-
{
|
|
106
|
-
matcher: ".*",
|
|
107
|
-
hooks: [
|
|
108
|
-
{ type: "command", command: `${CLI} hook-inject --event subagent`, timeout: 10 }
|
|
109
|
-
]
|
|
110
|
-
}
|
|
111
|
-
],
|
|
112
|
-
// Learning reflection — nudge the LLM to call plur_learn after responses
|
|
113
|
-
// where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
|
|
114
|
-
Stop: [
|
|
115
|
-
{
|
|
116
|
-
matcher: "*",
|
|
117
|
-
hooks: [
|
|
118
|
-
{ type: "command", command: `${CLI} hook-learn-check`, timeout: 2 }
|
|
119
|
-
]
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
|
|
15
|
+
import { join, dirname } from "path";
|
|
16
|
+
import { fileURLToPath } from "url";
|
|
17
|
+
import { homedir, platform } from "os";
|
|
18
|
+
function resolveCliEntrypoint() {
|
|
19
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
20
|
+
return join(dirname(thisFile), "..", "index.js");
|
|
21
|
+
}
|
|
22
|
+
function shimPath() {
|
|
23
|
+
const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
|
|
24
|
+
return join(homedir(), ".plur", "bin", name);
|
|
25
|
+
}
|
|
26
|
+
function installHookBinary() {
|
|
27
|
+
const binDir = join(homedir(), ".plur", "bin");
|
|
28
|
+
mkdirSync(binDir, { recursive: true });
|
|
29
|
+
const entrypoint = resolveCliEntrypoint();
|
|
30
|
+
const nodeBin = process.execPath;
|
|
31
|
+
if (!existsSync(entrypoint)) {
|
|
32
|
+
return { shimPath: "", status: `error: CLI entrypoint not found at ${entrypoint}` };
|
|
33
|
+
}
|
|
34
|
+
const target = shimPath();
|
|
35
|
+
if (platform() === "win32") {
|
|
36
|
+
writeFileSync(target, `@echo off\r
|
|
37
|
+
"${nodeBin}" "${entrypoint}" %*\r
|
|
38
|
+
`);
|
|
39
|
+
} else {
|
|
40
|
+
writeFileSync(target, `#!/bin/sh
|
|
41
|
+
exec "${nodeBin}" "${entrypoint}" "$@"
|
|
42
|
+
`, { mode: 493 });
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(target, 493);
|
|
45
|
+
} catch {
|
|
120
46
|
}
|
|
121
|
-
|
|
122
|
-
};
|
|
47
|
+
}
|
|
48
|
+
const meta = { entrypoint, node: nodeBin, installed: (/* @__PURE__ */ new Date()).toISOString() };
|
|
49
|
+
writeFileSync(join(binDir, "plur-hook.meta.json"), JSON.stringify(meta, null, 2) + "\n");
|
|
50
|
+
return { shimPath: target, status: "installed" };
|
|
51
|
+
}
|
|
52
|
+
function buildEnforcementHooks(cmd) {
|
|
53
|
+
return {
|
|
54
|
+
SessionStart: [
|
|
55
|
+
{
|
|
56
|
+
hooks: [
|
|
57
|
+
{ type: "command", command: `${cmd} hook-session-remind`, timeout: 3 }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
PreToolUse: [
|
|
62
|
+
// Session guard — blocks all tools until plur_session_start is called.
|
|
63
|
+
// Must be first so it runs before any other PreToolUse hook.
|
|
64
|
+
{
|
|
65
|
+
matcher: "*",
|
|
66
|
+
hooks: [
|
|
67
|
+
{ type: "command", command: `${cmd} hook-session-guard`, timeout: 3 }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
PostToolUse: [
|
|
72
|
+
// Session sentinel — creates marker file after plur_session_start succeeds
|
|
73
|
+
{
|
|
74
|
+
matcher: "mcp__plur__plur_session_start",
|
|
75
|
+
hooks: [
|
|
76
|
+
{ type: "command", command: `${cmd} hook-session-mark`, timeout: 3 }
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function buildInjectionHooks(cmd) {
|
|
83
|
+
return {
|
|
84
|
+
// First message: inject engrams based on the prompt.
|
|
85
|
+
// Subsequent messages: periodic reminder to call plur_learn (~1ms skip).
|
|
86
|
+
UserPromptSubmit: [
|
|
87
|
+
{
|
|
88
|
+
hooks: [
|
|
89
|
+
{ type: "command", command: `${cmd} hook-inject`, timeout: 15 }
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
// Re-inject after context compaction so engrams survive long conversations.
|
|
94
|
+
PostCompact: [
|
|
95
|
+
{
|
|
96
|
+
matcher: "auto|manual",
|
|
97
|
+
hooks: [
|
|
98
|
+
{ type: "command", command: `${cmd} hook-inject --rehydrate`, timeout: 15 }
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
PreToolUse: [
|
|
103
|
+
// Full injection when entering plan mode — planning needs broad context
|
|
104
|
+
{
|
|
105
|
+
matcher: "EnterPlanMode",
|
|
106
|
+
hooks: [
|
|
107
|
+
{ type: "command", command: `${cmd} hook-inject --event plan_mode`, timeout: 10 }
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
// Domain-specific engrams when a skill is invoked
|
|
111
|
+
{
|
|
112
|
+
matcher: "Skill",
|
|
113
|
+
hooks: [
|
|
114
|
+
{ type: "command", command: `${cmd} hook-inject --event skill`, timeout: 10 }
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
// Agent-scoped engrams when spawning an agent
|
|
118
|
+
{
|
|
119
|
+
matcher: "Agent",
|
|
120
|
+
hooks: [
|
|
121
|
+
{ type: "command", command: `${cmd} hook-inject --event agent`, timeout: 10 }
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
// Observation capture — log tool calls for offline pattern extraction
|
|
125
|
+
{
|
|
126
|
+
matcher: "Bash|Edit|Write|Agent",
|
|
127
|
+
hooks: [
|
|
128
|
+
{ type: "command", command: `${cmd} hook-observe`, timeout: 3 }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
PostToolUse: [
|
|
133
|
+
{
|
|
134
|
+
matcher: "Bash|Edit|Write|Agent",
|
|
135
|
+
hooks: [
|
|
136
|
+
{ type: "command", command: `${cmd} hook-observe --post`, timeout: 3 }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
],
|
|
140
|
+
// Inject agent-scoped engrams into subagent context
|
|
141
|
+
SubagentStart: [
|
|
142
|
+
{
|
|
143
|
+
matcher: ".*",
|
|
144
|
+
hooks: [
|
|
145
|
+
{ type: "command", command: `${cmd} hook-inject --event subagent`, timeout: 10 }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
],
|
|
149
|
+
// Learning reflection — nudge the LLM to call plur_learn after responses
|
|
150
|
+
// where it discovered or learned something. Fires every 3rd Stop to avoid fatigue.
|
|
151
|
+
Stop: [
|
|
152
|
+
{
|
|
153
|
+
matcher: "*",
|
|
154
|
+
hooks: [
|
|
155
|
+
{ type: "command", command: `${cmd} hook-learn-check`, timeout: 2 }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
};
|
|
160
|
+
}
|
|
123
161
|
function mergeHookMaps(a, b) {
|
|
124
162
|
const out = {};
|
|
125
163
|
for (const [event, entries] of Object.entries(a)) out[event] = [...entries];
|
|
@@ -224,7 +262,9 @@ function loadSettings(path) {
|
|
|
224
262
|
}
|
|
225
263
|
}
|
|
226
264
|
function isPlurHook(entry) {
|
|
227
|
-
return (entry.hooks ?? []).some(
|
|
265
|
+
return (entry.hooks ?? []).some(
|
|
266
|
+
(h) => h.command.includes("@plur-ai/cli") || h.command.includes(".plur/bin/plur-hook")
|
|
267
|
+
);
|
|
228
268
|
}
|
|
229
269
|
function hasPlurHooks(settings) {
|
|
230
270
|
const hooks = settings.hooks ?? {};
|
|
@@ -278,6 +318,10 @@ function hooksStatusFor(before, after, hadHooks) {
|
|
|
278
318
|
return before === after ? "already up to date" : "upgraded";
|
|
279
319
|
}
|
|
280
320
|
async function run(args, flags) {
|
|
321
|
+
const shim = installHookBinary();
|
|
322
|
+
const cmd = shim.shimPath || "npx @plur-ai/cli";
|
|
323
|
+
const PLUR_HOOKS_ENFORCEMENT = buildEnforcementHooks(cmd);
|
|
324
|
+
const PLUR_HOOKS_INJECTION = buildInjectionHooks(cmd);
|
|
281
325
|
const injectionPath = findSettingsPath(flags, args);
|
|
282
326
|
const enforcementPath = join(homedir(), ".claude", "settings.json");
|
|
283
327
|
const samePath = injectionPath === enforcementPath;
|
|
@@ -330,6 +374,8 @@ async function run(args, flags) {
|
|
|
330
374
|
const entry = buildMcpServerEntry();
|
|
331
375
|
outputText("PLUR installed for Claude Code.");
|
|
332
376
|
outputText("");
|
|
377
|
+
outputText(`Hook binary: ${shim.status}${shim.shimPath ? ` (${shim.shimPath})` : ""}`);
|
|
378
|
+
outputText("");
|
|
333
379
|
outputText("Architecture: One global engram store (~/.plur/), enforcement hooks global, injection hooks project-scoped.");
|
|
334
380
|
outputText("Multi-project scoping via domain/scope fields on engrams, not separate installs.");
|
|
335
381
|
outputText("");
|
package/dist/commands/stores.js
CHANGED
|
@@ -29,7 +29,7 @@ async function run(args, flags) {
|
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
31
|
if (!subcommand || subcommand === "list") {
|
|
32
|
-
const storeList = plur.
|
|
32
|
+
const storeList = await plur.listStoresAsync();
|
|
33
33
|
if (shouldOutputJson(flags)) {
|
|
34
34
|
outputJson({ stores: storeList, count: storeList.length });
|
|
35
35
|
} else {
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
exit,
|
|
3
|
+
outputJson,
|
|
4
|
+
outputText,
|
|
5
|
+
shouldOutputJson
|
|
6
|
+
} from "./chunk-7U4W4J3G.js";
|
|
7
|
+
import {
|
|
8
|
+
createPlur
|
|
9
|
+
} from "./chunk-O6WTH7H7.js";
|
|
10
|
+
|
|
11
|
+
// src/commands/tensions.ts
|
|
12
|
+
function makeHttpLlm(baseUrl, apiKey, model = "gpt-4o-mini") {
|
|
13
|
+
return async (prompt) => {
|
|
14
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
Authorization: `Bearer ${apiKey}`
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
model,
|
|
22
|
+
messages: [{ role: "user", content: prompt }],
|
|
23
|
+
temperature: 0.1
|
|
24
|
+
})
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`LLM API error: ${response.status} ${response.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
const data = await response.json();
|
|
30
|
+
return data.choices?.[0]?.message?.content ?? "";
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function getLlmFunction() {
|
|
34
|
+
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
35
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
36
|
+
if (openrouterKey) return makeHttpLlm("https://openrouter.ai/api/v1", openrouterKey, "openai/gpt-4o-mini");
|
|
37
|
+
if (openaiKey) return makeHttpLlm("https://api.openai.com/v1", openaiKey, "gpt-4o-mini");
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
async function run(args, flags) {
|
|
41
|
+
let scan = false;
|
|
42
|
+
let scope;
|
|
43
|
+
let domain;
|
|
44
|
+
let minConfidence = 0.7;
|
|
45
|
+
let maxPairs = 50;
|
|
46
|
+
let llmBaseUrl;
|
|
47
|
+
let llmApiKey;
|
|
48
|
+
let llmModel;
|
|
49
|
+
let i = 0;
|
|
50
|
+
while (i < args.length) {
|
|
51
|
+
const arg = args[i];
|
|
52
|
+
if (arg === "--scan") {
|
|
53
|
+
scan = true;
|
|
54
|
+
i++;
|
|
55
|
+
} else if (arg === "--scope" && i + 1 < args.length) {
|
|
56
|
+
scope = args[++i];
|
|
57
|
+
i++;
|
|
58
|
+
} else if (arg === "--domain" && i + 1 < args.length) {
|
|
59
|
+
domain = args[++i];
|
|
60
|
+
i++;
|
|
61
|
+
} else if (arg === "--min-confidence" && i + 1 < args.length) {
|
|
62
|
+
minConfidence = parseFloat(args[++i]);
|
|
63
|
+
i++;
|
|
64
|
+
} else if (arg === "--max-pairs" && i + 1 < args.length) {
|
|
65
|
+
maxPairs = parseInt(args[++i], 10);
|
|
66
|
+
i++;
|
|
67
|
+
} else if (arg === "--llm-base-url" && i + 1 < args.length) {
|
|
68
|
+
llmBaseUrl = args[++i];
|
|
69
|
+
i++;
|
|
70
|
+
} else if (arg === "--llm-api-key" && i + 1 < args.length) {
|
|
71
|
+
llmApiKey = args[++i];
|
|
72
|
+
i++;
|
|
73
|
+
} else if (arg === "--model" && i + 1 < args.length) {
|
|
74
|
+
llmModel = args[++i];
|
|
75
|
+
i++;
|
|
76
|
+
} else {
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const plur = createPlur(flags, { readonly: true });
|
|
81
|
+
const engrams = plur.list({ scope, domain });
|
|
82
|
+
if (scan) {
|
|
83
|
+
const llm = llmBaseUrl ? makeHttpLlm(llmBaseUrl, llmApiKey ?? "", llmModel) : getLlmFunction();
|
|
84
|
+
if (!llm) {
|
|
85
|
+
exit(
|
|
86
|
+
1,
|
|
87
|
+
"tensions --scan requires an LLM.\nSet OPENROUTER_API_KEY or OPENAI_API_KEY, or pass --llm-base-url + --llm-api-key."
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!shouldOutputJson(flags)) {
|
|
92
|
+
outputText(`Scanning ${engrams.length} engrams for contradictions\u2026`);
|
|
93
|
+
if (scope) outputText(` scope: ${scope}`);
|
|
94
|
+
if (domain) outputText(` domain: ${domain}`);
|
|
95
|
+
outputText(` min-confidence: ${minConfidence} max-pairs: ${maxPairs}`);
|
|
96
|
+
outputText("");
|
|
97
|
+
}
|
|
98
|
+
const { scanForTensions } = await import("@plur-ai/core");
|
|
99
|
+
const result = await scanForTensions(engrams, llm, { min_confidence: minConfidence, max_pairs: maxPairs });
|
|
100
|
+
if (shouldOutputJson(flags)) {
|
|
101
|
+
outputJson({
|
|
102
|
+
pairs_checked: result.pairs_checked,
|
|
103
|
+
count: result.new_tensions,
|
|
104
|
+
tensions: result.tensions.map((t) => ({
|
|
105
|
+
engram_a: { id: t.id_a, statement: t.statement_a },
|
|
106
|
+
engram_b: { id: t.id_b, statement: t.statement_b },
|
|
107
|
+
confidence: t.confidence,
|
|
108
|
+
reason: t.reason
|
|
109
|
+
}))
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
outputText(`Checked: ${result.pairs_checked} candidate pairs`);
|
|
114
|
+
outputText(`Found: ${result.new_tensions} tension${result.new_tensions === 1 ? "" : "s"} (confidence >= ${minConfidence})`);
|
|
115
|
+
outputText("");
|
|
116
|
+
if (result.tensions.length === 0) {
|
|
117
|
+
outputText("No contradictions detected.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const t of result.tensions) {
|
|
121
|
+
outputText(`\u2500\u2500 TENSION (confidence: ${t.confidence.toFixed(2)}) \u2500\u2500`);
|
|
122
|
+
outputText(` A [${t.id_a}]: ${t.statement_a}`);
|
|
123
|
+
outputText(` B [${t.id_b}]: ${t.statement_b}`);
|
|
124
|
+
outputText(` Reason: ${t.reason}`);
|
|
125
|
+
outputText("");
|
|
126
|
+
}
|
|
127
|
+
outputText("Next steps:");
|
|
128
|
+
outputText(" Resolve: determine which statement is correct, retire the other via plur forget <id>");
|
|
129
|
+
outputText(" Dismiss: if not a real conflict, both statements can coexist");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const tensions = [];
|
|
133
|
+
const seen = /* @__PURE__ */ new Set();
|
|
134
|
+
for (const engram of engrams) {
|
|
135
|
+
if (!engram.relations?.conflicts?.length) continue;
|
|
136
|
+
for (const conflictId of engram.relations.conflicts) {
|
|
137
|
+
const pairKey = [engram.id, conflictId].sort().join(":");
|
|
138
|
+
if (seen.has(pairKey)) continue;
|
|
139
|
+
seen.add(pairKey);
|
|
140
|
+
const other = engrams.find((e) => e.id === conflictId);
|
|
141
|
+
if (!other) continue;
|
|
142
|
+
tensions.push({
|
|
143
|
+
engram_a: { id: engram.id, statement: engram.statement },
|
|
144
|
+
engram_b: { id: other.id, statement: other.statement },
|
|
145
|
+
detected_at: engram.activation.last_accessed
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (shouldOutputJson(flags)) {
|
|
150
|
+
outputJson({ tensions, count: tensions.length });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (tensions.length === 0) {
|
|
154
|
+
outputText("No stored tensions. Run `plur tensions --scan` to detect live contradictions.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
outputText(`Stored tensions: ${tensions.length}`);
|
|
158
|
+
outputText("");
|
|
159
|
+
for (const t of tensions) {
|
|
160
|
+
outputText(` A [${t.engram_a.id}]: ${t.engram_a.statement}`);
|
|
161
|
+
outputText(` B [${t.engram_b.id}]: ${t.engram_b.statement}`);
|
|
162
|
+
outputText(` Detected: ${t.detected_at}`);
|
|
163
|
+
outputText("");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export {
|
|
167
|
+
run
|
|
168
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -49,7 +49,7 @@ function createPlur(flags2) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
// src/index.ts
|
|
52
|
-
var VERSION = "0.9.
|
|
52
|
+
var VERSION = "0.9.11";
|
|
53
53
|
var argv = process.argv.slice(2);
|
|
54
54
|
if (argv.includes("--version") || argv.includes("-v")) {
|
|
55
55
|
console.log(VERSION);
|
|
@@ -84,6 +84,7 @@ Commands:
|
|
|
84
84
|
init Install Claude Code hooks + register plur MCP server
|
|
85
85
|
init-remote Opt this project into recall from a PLUR Enterprise server
|
|
86
86
|
doctor Diagnose Claude Code / Claude Desktop integration
|
|
87
|
+
tensions [--scan] List or scan for engram contradictions
|
|
87
88
|
audit [--source X] Audit working memory (claude-code|claw|hermes) for conflicts vs engrams
|
|
88
89
|
hook-inject (internal) Hook handler for engram injection
|
|
89
90
|
hook-observe (internal) Hook handler for observation capture
|
|
@@ -127,6 +128,7 @@ var COMMANDS = {
|
|
|
127
128
|
init: "./commands/init.js",
|
|
128
129
|
"init-remote": "./commands/init-remote.js",
|
|
129
130
|
doctor: "./commands/doctor.js",
|
|
131
|
+
tensions: "./commands/tensions.js",
|
|
130
132
|
audit: "./commands/audit.js",
|
|
131
133
|
"hook-inject": "./commands/hook-inject.js",
|
|
132
134
|
"hook-observe": "./commands/hook-observe.js",
|
|
@@ -135,7 +137,11 @@ var COMMANDS = {
|
|
|
135
137
|
"hook-session-mark": "./commands/hook-session-mark.js",
|
|
136
138
|
"hook-session-remind": "./commands/hook-session-remind.js",
|
|
137
139
|
"hook-correction-detect": "./commands/hook-correction-detect.js",
|
|
138
|
-
"hook-revert-detect": "./commands/hook-revert-detect.js"
|
|
140
|
+
"hook-revert-detect": "./commands/hook-revert-detect.js",
|
|
141
|
+
// Hidden internal subcommand — spawned by `plur doctor` to isolate the
|
|
142
|
+
// ONNX embedder probe (issue #197). If the probe crashes with SIGABRT
|
|
143
|
+
// on libc++ thread pool cleanup, only the subprocess dies; doctor stays alive.
|
|
144
|
+
"_embedder-probe": "./commands/embedder-probe.js"
|
|
139
145
|
};
|
|
140
146
|
if (!command || !COMMANDS[command]) {
|
|
141
147
|
exit(1, `Unknown command: ${command}. Run 'plur --help' for usage.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plur-ai/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"plur": "dist/index.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@plur-ai/core": "0.9.
|
|
13
|
+
"@plur-ai/core": "0.9.11"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^25.5.0"
|