@pcircle/memesh 4.6.2 → 4.7.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/AGENTS.md +9 -2
- package/README.de.md +80 -17
- package/README.md +22 -11
- package/README.zh-TW.md +79 -17
- package/dashboard/dist/index.html +6 -6
- package/dist/core/analytics.d.ts.map +1 -1
- package/dist/core/analytics.js +1 -1
- package/dist/core/analytics.js.map +1 -1
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +1 -0
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/citation-rule.d.ts +24 -0
- package/dist/core/citation-rule.d.ts.map +1 -0
- package/dist/core/citation-rule.js +71 -0
- package/dist/core/citation-rule.js.map +1 -0
- package/dist/core/conflict-candidates.d.ts.map +1 -1
- package/dist/core/conflict-candidates.js +4 -12
- package/dist/core/conflict-candidates.js.map +1 -1
- package/dist/core/conflict-judge.d.ts +11 -0
- package/dist/core/conflict-judge.d.ts.map +1 -1
- package/dist/core/conflict-judge.js +1 -1
- package/dist/core/conflict-judge.js.map +1 -1
- package/dist/core/demo.d.ts.map +1 -1
- package/dist/core/demo.js +22 -3
- package/dist/core/demo.js.map +1 -1
- package/dist/core/digest-validator.d.ts.map +1 -1
- package/dist/core/digest-validator.js +3 -3
- package/dist/core/digest-validator.js.map +1 -1
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +130 -22
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/dreamer.d.ts.map +1 -1
- package/dist/core/dreamer.js +10 -6
- package/dist/core/dreamer.js.map +1 -1
- package/dist/core/install-channel.d.ts +7 -2
- package/dist/core/install-channel.d.ts.map +1 -1
- package/dist/core/install-channel.js +44 -7
- package/dist/core/install-channel.js.map +1 -1
- package/dist/core/install-hooks.d.ts +6 -0
- package/dist/core/install-hooks.d.ts.map +1 -1
- package/dist/core/install-hooks.js +0 -0
- package/dist/core/install-hooks.js.map +1 -1
- package/dist/core/kg-backfill.d.ts.map +1 -1
- package/dist/core/kg-backfill.js +1 -1
- package/dist/core/kg-backfill.js.map +1 -1
- package/dist/core/lesson-engine.d.ts.map +1 -1
- package/dist/core/lesson-engine.js +3 -3
- package/dist/core/lesson-engine.js.map +1 -1
- package/dist/core/lifecycle.js +2 -2
- package/dist/core/llm-client.d.ts.map +1 -1
- package/dist/core/llm-client.js +7 -3
- package/dist/core/llm-client.js.map +1 -1
- package/dist/core/llm-telemetry.d.ts.map +1 -1
- package/dist/core/llm-telemetry.js +5 -2
- package/dist/core/llm-telemetry.js.map +1 -1
- package/dist/core/operations.d.ts +2 -0
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +2 -2
- package/dist/core/operations.js.map +1 -1
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +3 -2
- package/dist/core/paths.js.map +1 -1
- package/dist/core/patterns.d.ts +0 -5
- package/dist/core/patterns.d.ts.map +1 -1
- package/dist/core/patterns.js +1 -43
- package/dist/core/patterns.js.map +1 -1
- package/dist/core/schema-export.js +1 -1
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +53 -10
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/signal-scorer.d.ts +0 -1
- package/dist/core/signal-scorer.d.ts.map +1 -1
- package/dist/core/signal-scorer.js +12 -8
- package/dist/core/signal-scorer.js.map +1 -1
- package/dist/core/transcript-extractor.d.ts +2 -0
- package/dist/core/transcript-extractor.d.ts.map +1 -1
- package/dist/core/transcript-extractor.js +10 -5
- package/dist/core/transcript-extractor.js.map +1 -1
- package/dist/core/types.d.ts +11 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/updater.d.ts.map +1 -1
- package/dist/core/updater.js +1 -1
- package/dist/core/updater.js.map +1 -1
- package/dist/core/why.d.ts +1 -1
- package/dist/core/why.d.ts.map +1 -1
- package/dist/core/why.js +5 -0
- package/dist/core/why.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +20 -27
- package/dist/db.js.map +1 -1
- package/dist/knowledge-graph.d.ts +3 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +44 -28
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/skills-manifest.json +25 -20
- package/dist/storage/fts-index.d.ts.map +1 -1
- package/dist/storage/fts-index.js.map +1 -1
- package/dist/storage/schema.d.ts.map +1 -1
- package/dist/storage/schema.js +18 -22
- package/dist/storage/schema.js.map +1 -1
- package/dist/storage/vector-index.d.ts.map +1 -1
- package/dist/storage/vector-index.js +10 -4
- package/dist/storage/vector-index.js.map +1 -1
- package/dist/transports/cli/cli.js +131 -49
- package/dist/transports/cli/cli.js.map +1 -1
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +58 -3
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +5 -3
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +27 -18
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +0 -1
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +4 -3
- package/dist/transports/schemas.js.map +1 -1
- package/hooks/hooks.json +4 -2
- package/llms-install.md +28 -0
- package/package.json +2 -2
- package/scripts/hooks/_generated/citation-rule.js +78 -0
- package/scripts/hooks/_generated/core-paths.js +3 -2
- package/scripts/hooks/_generated/schema.js +18 -22
- package/scripts/hooks/_shared.js +75 -4
- package/scripts/hooks/guard-check.js +5 -0
- package/scripts/hooks/pre-compact.js +14 -7
- package/scripts/hooks/pre-edit-recall.js +22 -0
- package/scripts/hooks/session-start.js +106 -3
- package/scripts/hooks/session-summary.js +41 -4
- package/scripts/upgrade-plugin.sh +9 -1
|
@@ -130,28 +130,24 @@ export function safeAlter(db, sql) {
|
|
|
130
130
|
}
|
|
131
131
|
export function migrateEntitiesSchema(db) {
|
|
132
132
|
const entityColumns = new Set(db.prepare("PRAGMA table_info(entities)").all().map((c) => c.name));
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
if (!entityColumns.has('title')) {
|
|
153
|
-
safeAlter(db, "ALTER TABLE entities ADD COLUMN title TEXT");
|
|
154
|
-
}
|
|
133
|
+
const addColumn = (column, sql) => {
|
|
134
|
+
if (entityColumns.has(column))
|
|
135
|
+
return;
|
|
136
|
+
safeAlter(db, sql);
|
|
137
|
+
entityColumns.add(column);
|
|
138
|
+
};
|
|
139
|
+
addColumn('status', "ALTER TABLE entities ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
|
|
140
|
+
addColumn('access_count', "ALTER TABLE entities ADD COLUMN access_count INTEGER DEFAULT 0");
|
|
141
|
+
addColumn('last_accessed_at', "ALTER TABLE entities ADD COLUMN last_accessed_at TIMESTAMP");
|
|
142
|
+
addColumn('confidence', "ALTER TABLE entities ADD COLUMN confidence REAL DEFAULT 1.0");
|
|
143
|
+
addColumn('valid_from', "ALTER TABLE entities ADD COLUMN valid_from TIMESTAMP");
|
|
144
|
+
addColumn('valid_until', "ALTER TABLE entities ADD COLUMN valid_until TIMESTAMP");
|
|
145
|
+
addColumn('namespace', "ALTER TABLE entities ADD COLUMN namespace TEXT DEFAULT 'personal'");
|
|
146
|
+
addColumn('recall_hits', "ALTER TABLE entities ADD COLUMN recall_hits INTEGER DEFAULT 0");
|
|
147
|
+
addColumn('recall_misses', "ALTER TABLE entities ADD COLUMN recall_misses INTEGER DEFAULT 0");
|
|
148
|
+
addColumn('title', "ALTER TABLE entities ADD COLUMN title TEXT");
|
|
149
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_status ON entities(status);
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_entities_namespace ON entities(namespace);`);
|
|
155
151
|
}
|
|
156
152
|
export function ensureTagsUniqueIndex(db) {
|
|
157
153
|
try {
|
package/scripts/hooks/_shared.js
CHANGED
|
@@ -26,15 +26,18 @@ import { fileURLToPath, pathToFileURL } from 'url';
|
|
|
26
26
|
// Re-exported here so all 6 hooks keep importing these names from `_shared.js`
|
|
27
27
|
// unchanged.
|
|
28
28
|
import {
|
|
29
|
+
homeDir,
|
|
29
30
|
memeshDir,
|
|
30
31
|
getDbPath,
|
|
31
32
|
getMemeshDirFromDbPath,
|
|
32
33
|
getProjectName,
|
|
34
|
+
redactSecrets,
|
|
33
35
|
slugFromRemoteUrl,
|
|
34
36
|
} from './_generated/core-paths.js';
|
|
35
37
|
import { autoCaptureDecision } from './_generated/capture-flag.js';
|
|
36
38
|
export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DEFAULT_TOPOLOGY_BUDGET, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
|
|
37
39
|
export { matchingGuards, guardFromMetadata } from './_generated/guards.js';
|
|
40
|
+
export { writeCitationRule, citationRulePath, CITATION_RULE_BODY } from './_generated/citation-rule.js';
|
|
38
41
|
import { guardFromMetadata as guardFromMetadataLocal } from './_generated/guards.js';
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -101,7 +104,16 @@ export function recordGuardFires(dbPath, lessonIds) {
|
|
|
101
104
|
} finally {
|
|
102
105
|
db.close();
|
|
103
106
|
}
|
|
104
|
-
} catch {
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// Never block the user's work — but say so. A read-only database file, a
|
|
109
|
+
// lost lock, or a schema drift silently stopped the fire counter, and
|
|
110
|
+
// guard ROI is judged on exactly this number: a guard that fires often
|
|
111
|
+
// and a guard whose counter never landed look identical in review. One
|
|
112
|
+
// line on stderr is what guard-check already does for its own failures.
|
|
113
|
+
try {
|
|
114
|
+
process.stderr.write(`[memesh guard-fires] not counted: ${err?.message || err}\n`);
|
|
115
|
+
} catch { /* stderr gone */ }
|
|
116
|
+
}
|
|
105
117
|
}
|
|
106
118
|
import { isAutoInjectable } from './_generated/work-topology.js';
|
|
107
119
|
export { parseTaskState, taskStateLines, taskStateName } from './_generated/task-state.js';
|
|
@@ -114,7 +126,7 @@ import {
|
|
|
114
126
|
tokenizeQuery,
|
|
115
127
|
} from './_generated/fts-index.js';
|
|
116
128
|
|
|
117
|
-
export { memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, slugFromRemoteUrl };
|
|
129
|
+
export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, slugFromRemoteUrl };
|
|
118
130
|
|
|
119
131
|
const require = createRequire(import.meta.url);
|
|
120
132
|
|
|
@@ -332,6 +344,12 @@ import {
|
|
|
332
344
|
// nothing. node:sqlite is part of the runtime: there is no binary to
|
|
333
345
|
// miss, so the failure mode and its whole recovery apparatus are gone.
|
|
334
346
|
|
|
347
|
+
/** See the pragma in `openHookDb` for why this is not the 30s the shared
|
|
348
|
+
* database class uses. Exported because the three hooks that open a
|
|
349
|
+
* read-only handle directly (bypassing `openHookDb`, which cannot express
|
|
350
|
+
* `readOnly`) must apply the same cap themselves. */
|
|
351
|
+
export const HOOK_BUSY_TIMEOUT_MS = 2000;
|
|
352
|
+
|
|
335
353
|
export function openHookDb(env = process.env, opts = {}) {
|
|
336
354
|
|
|
337
355
|
// Path helpers read process.env directly (no-arg). The `env` parameter
|
|
@@ -349,6 +367,23 @@ export function openHookDb(env = process.env, opts = {}) {
|
|
|
349
367
|
const db = new MemeshDatabase(dbPath, { allowExtension: true });
|
|
350
368
|
db.pragma('journal_mode = WAL');
|
|
351
369
|
db.pragma('foreign_keys = ON');
|
|
370
|
+
// A hook waits for a held write lock for less time than Claude Code will
|
|
371
|
+
// wait for the hook.
|
|
372
|
+
//
|
|
373
|
+
// `MemeshDatabase` sets `busy_timeout = 30000`, and that number is right
|
|
374
|
+
// for the processes it was chosen for: a 30k-vector `swapVectorGeneration`
|
|
375
|
+
// holds the write lock for ~9s, and the CLI, the MCP server and the HTTP
|
|
376
|
+
// server should WAIT for it rather than fail. A hook cannot. Its budget in
|
|
377
|
+
// `hooks/hooks.json` is 3s (UserPromptSubmit) to 10s (Stop, PreCompact),
|
|
378
|
+
// so a 30s wait has exactly one possible ending: the harness kills the
|
|
379
|
+
// hook. The capture is lost either way — the difference is that the user
|
|
380
|
+
// also gets a hook-timeout error, which is the failure mode that makes
|
|
381
|
+
// memesh something to switch off.
|
|
382
|
+
//
|
|
383
|
+
// 2s fits inside every budget with room for the hook's own work. On
|
|
384
|
+
// contention the capture is skipped quietly, this run's `hook_runs` stamp
|
|
385
|
+
// is not written, and `memesh doctor` reports the gap honestly.
|
|
386
|
+
db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
|
|
352
387
|
// Bringing the schema current is a WRITE, and "cannot migrate" must not
|
|
353
388
|
// mean "cannot open": a database file that is read-only but behind on
|
|
354
389
|
// schema (a pre-upgrade backup, a permissions accident) dies on the
|
|
@@ -535,6 +570,23 @@ export { truncateTitle } from './_generated/title.js';
|
|
|
535
570
|
* @returns {{ id: number, isNew: boolean } | null} null if the row could not be resolved
|
|
536
571
|
*/
|
|
537
572
|
export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata }) {
|
|
573
|
+
// One transaction, because this function performs six writes that only
|
|
574
|
+
// mean anything together: the entity row, its observations, its tags, and
|
|
575
|
+
// the contentless-FTS delete + insert that make them findable.
|
|
576
|
+
//
|
|
577
|
+
// Without it, a throw anywhere in the middle — a lock lost to the CLI, a
|
|
578
|
+
// full disk, an FTS corruption — committed the prefix and dropped the
|
|
579
|
+
// rest, and the two most likely resting places are both invisible:
|
|
580
|
+
// observations inserted with no FTS row (a memory that exists and can
|
|
581
|
+
// never be recalled), or the old FTS row deleted and the new one not
|
|
582
|
+
// written (a memory that just stopped being findable). Neither is
|
|
583
|
+
// retried, because the callers dedupe on the entity NAME existing —
|
|
584
|
+
// `INSERT OR IGNORE` reports "already there" on the next run and the
|
|
585
|
+
// half-written state is permanent.
|
|
586
|
+
return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function captureEntityInner(db, { name, type, observations, tags, title, metadata }) {
|
|
538
590
|
// source_host provenance: these hooks only ever run under Claude Code (they
|
|
539
591
|
// are wired into ~/.claude/settings.json), so a hook-captured entity is by
|
|
540
592
|
// definition a claude-code capture. Stamped only on the INSERT — an OR
|
|
@@ -573,8 +625,11 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
|
|
|
573
625
|
// it's corrupted — replace with {} and log the healing.
|
|
574
626
|
if (!meta && metaRow?.metadata) {
|
|
575
627
|
try {
|
|
628
|
+
// The id, not the name. The id is what a maintainer needs to look
|
|
629
|
+
// the row up; the name is user-authored content, and this line goes
|
|
630
|
+
// to a stderr stream the user may paste anywhere.
|
|
576
631
|
process.stderr.write(
|
|
577
|
-
`MeMesh: healed corrupted metadata for entity ${id}
|
|
632
|
+
`MeMesh: healed corrupted metadata for entity ${id}. ` +
|
|
578
633
|
`Original value was unparseable; replaced with {}.\n`,
|
|
579
634
|
);
|
|
580
635
|
} catch { /* stderr gone */ }
|
|
@@ -745,9 +800,25 @@ export function decideAutoUpdateHook(currentVersion, cache, policy) {
|
|
|
745
800
|
const policyAllows = (POLICY_RANK[policy] ?? 0) >= BUMP_RANK[bump];
|
|
746
801
|
if (policyAllows) return { run: true, latest, bump, deprecationOverride: false };
|
|
747
802
|
|
|
803
|
+
// The deprecation override does NOT apply when the policy is `off`.
|
|
804
|
+
//
|
|
805
|
+
// Look at what the override could ever do: it fires only for a `patch`
|
|
806
|
+
// bump, and any policy above `off` already permits a patch. So its ONLY
|
|
807
|
+
// effect was to defeat `off` — the one setting whose whole meaning is
|
|
808
|
+
// "never install anything without me asking".
|
|
809
|
+
//
|
|
810
|
+
// And its trigger is `currentVersionDeprecation`, a string the PUBLISHER
|
|
811
|
+
// writes into the npm registry. Anyone able to publish the package could
|
|
812
|
+
// therefore make every user who had turned auto-update OFF run a detached
|
|
813
|
+
// `npm install -g`, unattended, from a Stop hook. That is not a security
|
|
814
|
+
// override; it is a remote switch on a user's explicit refusal.
|
|
815
|
+
//
|
|
816
|
+
// A deprecated version still gets said out loud: `memesh doctor` escalates
|
|
817
|
+
// the update-status row to FAIL for it (there is a test named for that),
|
|
818
|
+
// and the session banner reports it. The user decides.
|
|
748
819
|
const deprecated = typeof cache.currentVersionDeprecation === 'string'
|
|
749
820
|
&& cache.currentVersionDeprecation.length > 0;
|
|
750
|
-
if (deprecated && bump === 'patch') {
|
|
821
|
+
if (deprecated && bump === 'patch' && policy !== 'off') {
|
|
751
822
|
return { run: true, latest, bump, deprecationOverride: true };
|
|
752
823
|
}
|
|
753
824
|
|
|
@@ -23,6 +23,7 @@ import { existsSync } from 'fs';
|
|
|
23
23
|
import {
|
|
24
24
|
buildReferenceContext,
|
|
25
25
|
getDbPath,
|
|
26
|
+
HOOK_BUSY_TIMEOUT_MS,
|
|
26
27
|
loadActiveGuards,
|
|
27
28
|
matchingGuards,
|
|
28
29
|
guardWarningLines,
|
|
@@ -46,6 +47,10 @@ process.stdin.on('end', () => {
|
|
|
46
47
|
// spelling and hands back a WRITABLE handle. This hook only reads;
|
|
47
48
|
// the fire counter opens its own writable handle for the one UPDATE.
|
|
48
49
|
const db = new MemeshDatabase(dbPath, { readOnly: true });
|
|
50
|
+
// MemeshDatabase's constructor always sets busy_timeout to the 30s that
|
|
51
|
+
// is correct for the CLI/MCP/HTTP writers; this hook's own budget
|
|
52
|
+
// (hooks.json) is 5s, so left alone a contended lock outlives the hook.
|
|
53
|
+
db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
|
|
49
54
|
let matches;
|
|
50
55
|
try {
|
|
51
56
|
matches = matchingGuards(loadActiveGuards(db, 'Bash'), 'Bash', command);
|
|
@@ -4,13 +4,20 @@ import { basename } from 'path';
|
|
|
4
4
|
import { existsSync, readFileSync } from 'fs';
|
|
5
5
|
import { AUTO_CAPTURE_TAG, captureEntity, getProjectName, isAutoCaptureEnabled, openHookDb, recordHookRun, truncateTitle } from './_shared.js';
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
// There is no in-process timeout guard, and its absence is deliberate.
|
|
8
|
+
//
|
|
9
|
+
// This file used to arm `setTimeout(() => process.exit(0), 10_000).unref()`.
|
|
10
|
+
// It could not fire. Everything after `stdin`'s `end` event is one
|
|
11
|
+
// synchronous block — no `await`, no callback — so the event loop never gets
|
|
12
|
+
// a turn between the handler starting and the process exiting, and a JS
|
|
13
|
+
// timer cannot interrupt a blocking SQLite call. The one window where it
|
|
14
|
+
// COULD have run is while stdin is still open, which is not where a hook
|
|
15
|
+
// hangs.
|
|
16
|
+
//
|
|
17
|
+
// The timeout that does work is external: `hooks/hooks.json` declares
|
|
18
|
+
// `"timeout": 10` for PreCompact, and the harness enforces it on the
|
|
19
|
+
// process. `openHookDb` additionally caps the SQLite lock wait at 2s so
|
|
20
|
+
// contention ends in a skipped capture rather than in that kill.
|
|
14
21
|
|
|
15
22
|
let input = '';
|
|
16
23
|
process.stdin.setEncoding('utf8');
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
getDbPath,
|
|
19
19
|
getMemeshDirFromDbPath,
|
|
20
20
|
getProjectName,
|
|
21
|
+
HOOK_BUSY_TIMEOUT_MS,
|
|
21
22
|
isTrustedForAutoContext,
|
|
22
23
|
writePrivateJson,
|
|
23
24
|
hookMatchExpression,
|
|
@@ -82,6 +83,27 @@ process.stdin.on('end', () => {
|
|
|
82
83
|
// and hands back a WRITABLE handle. This hook only reads; the guard
|
|
83
84
|
// fire counter opens its own writable handle for its one UPDATE.
|
|
84
85
|
const db = new MemeshDatabase(dbPath, { readOnly: true });
|
|
86
|
+
// MemeshDatabase's constructor always sets busy_timeout to the 30s that
|
|
87
|
+
// is correct for the CLI/MCP/HTTP writers; this hook's own budget
|
|
88
|
+
// (hooks.json) is 5s, so left alone a contended lock outlives the hook.
|
|
89
|
+
db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
|
|
90
|
+
// A single probe before either pass below. `loadActiveGuards` swallows
|
|
91
|
+
// a query failure internally — by design, so a guard-matching problem
|
|
92
|
+
// can never be the reason this hook crashes — which means a lock still
|
|
93
|
+
// held after the busy_timeout wait comes back as "no guards matched"
|
|
94
|
+
// rather than as an error this hook can see. Unlike guard-check.js,
|
|
95
|
+
// this hook still has the recall pass to run after the guard pass, and
|
|
96
|
+
// that query is NOT swallowed — so a genuinely contended connection
|
|
97
|
+
// paid the full busy_timeout wait TWICE in sequence, once hidden and
|
|
98
|
+
// once fatal, before giving up. Probing once here means a contended
|
|
99
|
+
// database is discovered (and given up on) after paying that wait
|
|
100
|
+
// exactly once.
|
|
101
|
+
try {
|
|
102
|
+
db.prepare('SELECT 1').get();
|
|
103
|
+
} catch {
|
|
104
|
+
db.close();
|
|
105
|
+
return pass();
|
|
106
|
+
}
|
|
85
107
|
let guardMatches = [];
|
|
86
108
|
const recallLines = [];
|
|
87
109
|
try {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getDbPath,
|
|
12
12
|
getMemeshDirFromDbPath,
|
|
13
13
|
getProjectName,
|
|
14
|
+
HOOK_BUSY_TIMEOUT_MS,
|
|
14
15
|
importFromPluginRoot,
|
|
15
16
|
assembleTopologyBlock,
|
|
16
17
|
DEFAULT_TOPOLOGY_BUDGET,
|
|
@@ -27,7 +28,9 @@ import {
|
|
|
27
28
|
resolvePluginRoot,
|
|
28
29
|
resolveSessionLimit,
|
|
29
30
|
taskStateLines,
|
|
31
|
+
homeDir,
|
|
30
32
|
taskStateName,
|
|
33
|
+
writeCitationRule,
|
|
31
34
|
writePrivateJson,
|
|
32
35
|
} from './_shared.js';
|
|
33
36
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
@@ -135,6 +138,11 @@ function buildDeprecationBanner(currentVersion, cache) {
|
|
|
135
138
|
? ` Run: memesh update (or set autoUpdate: memesh config set autoUpdate patch)`
|
|
136
139
|
: ` Run: memesh update (resolves @latest — or set: memesh config set autoUpdate patch)`,
|
|
137
140
|
);
|
|
141
|
+
} else if (channel === 'plugin-marketplace') {
|
|
142
|
+
// Was missing entirely, so a plugin user under an active security
|
|
143
|
+
// advisory fell to the generic npm line below — advice that does not
|
|
144
|
+
// apply to a version-pinned plugin install on either host.
|
|
145
|
+
lines.push(pluginUpgradeLine(resolvePluginRoot(import.meta.url)));
|
|
138
146
|
} else if (channel === 'source-checkout') {
|
|
139
147
|
lines.push(` Source checkout: pull and rebuild (\`git pull && npm install && npm run build\`).`);
|
|
140
148
|
} else if (channel === 'npm-local') {
|
|
@@ -262,7 +270,7 @@ function buildUpdateAvailableBanner(currentVersion, cache, getChannel) {
|
|
|
262
270
|
if (channel === 'npm-global') {
|
|
263
271
|
lines.push(` Run: memesh update`);
|
|
264
272
|
} else if (channel === 'plugin-marketplace') {
|
|
265
|
-
lines.push(
|
|
273
|
+
lines.push(pluginUpgradeLine(resolvePluginRoot(import.meta.url)));
|
|
266
274
|
} else if (channel === 'source-checkout') {
|
|
267
275
|
lines.push(` Source checkout: \`git pull && npm install && npm run build\`.`);
|
|
268
276
|
} else if (channel === 'npm-local') {
|
|
@@ -314,6 +322,40 @@ function detectInstallChannelHook(pluginRoot) {
|
|
|
314
322
|
}
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Which plugin runtime owns this copy: 'claude-code', 'codex', or null.
|
|
327
|
+
*
|
|
328
|
+
* Only consulted to pick the remediation command a plugin user is shown,
|
|
329
|
+
* so null (dist absent, old dist without the export) falls back to the
|
|
330
|
+
* Claude Code wording rather than suppressing the hint entirely — the
|
|
331
|
+
* banner is more useful naming the majority host than naming none.
|
|
332
|
+
*/
|
|
333
|
+
function pluginHostOf(pluginRoot) {
|
|
334
|
+
try {
|
|
335
|
+
return _installChannelMod?.detectPluginHost?.(pluginRoot) ?? null;
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The one upgrade line a plugin-marketplace install should be shown.
|
|
343
|
+
*
|
|
344
|
+
* Shared by BOTH banners on purpose. They drifted once: the routine
|
|
345
|
+
* "update available" banner learned that Codex needs a different command
|
|
346
|
+
* and the deprecation banner did not — and the deprecation banner is the
|
|
347
|
+
* one that fires on a security advisory AND takes precedence over the
|
|
348
|
+
* other. So the highest-stakes message carried the least actionable
|
|
349
|
+
* instruction, and a second copy of this logic is exactly how that
|
|
350
|
+
* happened. One owner, so it cannot happen again.
|
|
351
|
+
*/
|
|
352
|
+
function pluginUpgradeLine(pluginRoot) {
|
|
353
|
+
if (pluginHostOf(pluginRoot) === 'codex') {
|
|
354
|
+
return ` Run: codex plugin marketplace upgrade pcircle-memesh && codex plugin add memesh@pcircle-memesh`;
|
|
355
|
+
}
|
|
356
|
+
return ` Run: memesh upgrade-plugin (no CLI? npx @pcircle/memesh upgrade-plugin — or reinstall from /plugin UI)`;
|
|
357
|
+
}
|
|
358
|
+
|
|
317
359
|
// Don't fire a fresh-check more often than this. Two parallel
|
|
318
360
|
// session-starts both spawning `memesh status` could otherwise race
|
|
319
361
|
// the cache: a later writer that hits a deprecation-only timeout
|
|
@@ -542,6 +584,36 @@ process.stdin.on('end', async () => {
|
|
|
542
584
|
const data = JSON.parse(input);
|
|
543
585
|
const projectName = getProjectName(data.cwd);
|
|
544
586
|
|
|
587
|
+
// Self-heal the citation contract.
|
|
588
|
+
//
|
|
589
|
+
// `install-hooks` writes it too, but a PLUGIN install never runs that
|
|
590
|
+
// command — and plugin is how most users arrive. Without this, the
|
|
591
|
+
// contract would reach only npm installs, which is the same shape as the
|
|
592
|
+
// bug it exists to fix: a mechanism that is correct on a path nobody
|
|
593
|
+
// takes. Idempotent (a byte-identical file is left alone), refuses to
|
|
594
|
+
// touch a file memesh did not write, and never blocks the session: a
|
|
595
|
+
// failure here traces and the hook carries on.
|
|
596
|
+
try {
|
|
597
|
+
// Scope comes from the install marker, NOT hardcoded to 'user'. A
|
|
598
|
+
// `--scope project` install keeps everything inside that project, and
|
|
599
|
+
// writing the contract to ~/.claude/rules/ anyway would leak it into
|
|
600
|
+
// every OTHER project on the machine — and survive
|
|
601
|
+
// `uninstall-hooks --scope project`, which only knows about the
|
|
602
|
+
// project path. No marker means a plugin install, which is user-level
|
|
603
|
+
// by construction.
|
|
604
|
+
let ruleScope = 'user';
|
|
605
|
+
try {
|
|
606
|
+
const markerPath = join(memeshHomeDir(), 'install-hooks.json');
|
|
607
|
+
if (existsSync(markerPath)) {
|
|
608
|
+
const marker = JSON.parse(readFileSync(markerPath, 'utf8'));
|
|
609
|
+
if (marker?.scope === 'project') ruleScope = 'project';
|
|
610
|
+
}
|
|
611
|
+
} catch { /* unreadable marker → user scope, the safe default */ }
|
|
612
|
+
writeCitationRule(ruleScope, homeDir(), data.cwd || process.cwd());
|
|
613
|
+
} catch (err) {
|
|
614
|
+
try { process.stderr.write(`[memesh session-start] citation rule: ${err?.message || err}\n`); } catch {}
|
|
615
|
+
}
|
|
616
|
+
|
|
545
617
|
// Clear per-session throttle files from previous session
|
|
546
618
|
try {
|
|
547
619
|
if (existsSync(throttlePath)) {
|
|
@@ -594,6 +666,10 @@ process.stdin.on('end', async () => {
|
|
|
594
666
|
// property of the database file that the writing side already set, and a
|
|
595
667
|
// reader opens a WAL database perfectly well without asking for it.
|
|
596
668
|
const db = new MemeshDatabase(dbPath, { readOnly: true });
|
|
669
|
+
// MemeshDatabase's constructor always sets busy_timeout to the 30s that
|
|
670
|
+
// is correct for the CLI/MCP/HTTP writers; this hook's own budget
|
|
671
|
+
// (hooks.json) is 10s, so left alone a contended lock outlives the hook.
|
|
672
|
+
db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
|
|
597
673
|
// Whether the noise-compression epilogue below should run at all —
|
|
598
674
|
// pre-read from this readonly handle before it closes. Defaults to
|
|
599
675
|
// true so any early exit still lets the epilogue's own throttle decide.
|
|
@@ -916,7 +992,19 @@ process.stdin.on('end', async () => {
|
|
|
916
992
|
// this line IS an instruction. One line is the entire write side of
|
|
917
993
|
// the injection-ROI signal; the Stop hook credits recall_hits only
|
|
918
994
|
// from these markers (self-reported: undercounts, never overcounts).
|
|
919
|
-
|
|
995
|
+
// The citation instruction used to be appended here, outside the
|
|
996
|
+
// fence, so it would read as an instruction rather than as data.
|
|
997
|
+
// It never worked: Claude Code wraps a hook's additionalContext in a
|
|
998
|
+
// system-reminder ending "you should not respond to this context
|
|
999
|
+
// unless it is highly relevant", so the whole block — instruction
|
|
1000
|
+
// included — arrives as data. Measured on a real database:
|
|
1001
|
+
// citation_sessions_total=4, sessions WITH a citation = 0.
|
|
1002
|
+
//
|
|
1003
|
+
// The contract now lives in `.claude/rules/memesh-citations.md`,
|
|
1004
|
+
// which Claude Code loads as an instruction. Writing it is the
|
|
1005
|
+
// self-heal below; the line here is gone rather than duplicated,
|
|
1006
|
+
// because a per-session copy of an instruction that is read as data
|
|
1007
|
+
// is a per-session cost with no effect.
|
|
920
1008
|
}
|
|
921
1009
|
|
|
922
1010
|
// --- Record injected entity IDs for recall effectiveness tracking ---
|
|
@@ -1038,7 +1126,22 @@ process.stdin.on('end', async () => {
|
|
|
1038
1126
|
const lifecycleMod = await importFromPluginRoot(pluginRoot, 'dist/core/lifecycle.js');
|
|
1039
1127
|
dbMod.openDatabase();
|
|
1040
1128
|
try {
|
|
1041
|
-
|
|
1129
|
+
// Say what it did. This archives at least 20 of the user's memories
|
|
1130
|
+
// per week processed and the count was thrown away, so the one
|
|
1131
|
+
// operation in memesh that removes things from view was also the
|
|
1132
|
+
// only one that left no trace anywhere — not in the hook output, not
|
|
1133
|
+
// in doctor, not in the dashboard. Nothing to opt into: it runs at
|
|
1134
|
+
// most once a day and stays silent when it compresses nothing.
|
|
1135
|
+
const noise = lifecycleMod.compressWeeklyNoise(dbMod.getDatabase());
|
|
1136
|
+
if (noise && noise.compressed > 0) {
|
|
1137
|
+
try {
|
|
1138
|
+
process.stderr.write(
|
|
1139
|
+
`[memesh] archived ${noise.compressed} low-signal memor${noise.compressed === 1 ? 'y' : 'ies'} `
|
|
1140
|
+
+ `into ${noise.weeksProcessed} weekly summar${noise.weeksProcessed === 1 ? 'y' : 'ies'} `
|
|
1141
|
+
+ `(recover with \`memesh recall --include-archived\`)\n`,
|
|
1142
|
+
);
|
|
1143
|
+
} catch { /* stderr gone */ }
|
|
1144
|
+
}
|
|
1042
1145
|
} finally {
|
|
1043
1146
|
dbMod.closeDatabase();
|
|
1044
1147
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
isAutoCaptureEnabled,
|
|
37
37
|
openHookDb,
|
|
38
38
|
readUpdateCheckCache,
|
|
39
|
+
redactSecrets,
|
|
39
40
|
recordHookRun,
|
|
40
41
|
stampHookRunOnly,
|
|
41
42
|
resolveAutoUpdatePolicy,
|
|
@@ -117,7 +118,14 @@ function parseTranscript(transcriptPath) {
|
|
|
117
118
|
if (block.name === 'Bash') {
|
|
118
119
|
const cmd = block.input?.command ?? '';
|
|
119
120
|
if (typeof cmd === 'string' && cmd.length > 10 && !cmd.startsWith('ls') && !cmd.startsWith('cd')) {
|
|
120
|
-
|
|
121
|
+
// Redact BEFORE truncating. A bash command line is the single
|
|
122
|
+
// most likely place a credential appears in a transcript
|
|
123
|
+
// (`export ANTHROPIC_API_KEY=sk-...`, `curl -H "Authorization:
|
|
124
|
+
// Bearer ..."`), and this text is stored verbatim as an
|
|
125
|
+
// observation — a permanent, searchable, exportable copy.
|
|
126
|
+
// Truncating first would cut a token in half and leave the
|
|
127
|
+
// fragment unmatched by every pattern.
|
|
128
|
+
bashCommands.push(redactSecrets(cmd).slice(0, 100));
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
}
|
|
@@ -141,7 +149,14 @@ function parseTranscript(transcriptPath) {
|
|
|
141
149
|
const text = typeof block.content === 'string'
|
|
142
150
|
? block.content
|
|
143
151
|
: JSON.stringify(block.content);
|
|
144
|
-
|
|
152
|
+
// Same reason as the bash branch, and one more: this array is
|
|
153
|
+
// ALSO the payload `analyzeFailure` sends to the configured LLM
|
|
154
|
+
// provider. A failed request that echoes its own Authorization
|
|
155
|
+
// header — the ordinary shape of an auth error — would be stored
|
|
156
|
+
// and then transmitted off the machine. Redacted once here, at
|
|
157
|
+
// the point the text enters the process, so every downstream use
|
|
158
|
+
// inherits it.
|
|
159
|
+
errorsEncountered.push(redactSecrets(text).slice(0, 200));
|
|
145
160
|
}
|
|
146
161
|
}
|
|
147
162
|
|
|
@@ -511,8 +526,21 @@ process.stdin.on('end', async () => {
|
|
|
511
526
|
const updateHit = db.prepare(
|
|
512
527
|
'UPDATE entities SET recall_hits = COALESCE(recall_hits, 0) + 1 WHERE id = ?'
|
|
513
528
|
);
|
|
529
|
+
// Counted here, not recomputed below. The compliance
|
|
530
|
+
// numerator and `recall_hits` have to be the SAME
|
|
531
|
+
// measurement: `cited.size > 0` asked "did this transcript
|
|
532
|
+
// contain any [mem:N] at all", which counts a marker for an id
|
|
533
|
+
// this session never injected — one carried over from an
|
|
534
|
+
// earlier turn, or a number the agent invented — as compliance.
|
|
535
|
+
// The denominator counts sessions that received an injection,
|
|
536
|
+
// so the two halves of the rate were answering different
|
|
537
|
+
// questions.
|
|
538
|
+
let injectedAndCited = 0;
|
|
514
539
|
for (const id of entityIds) {
|
|
515
|
-
if (cited.has(id))
|
|
540
|
+
if (cited.has(id)) {
|
|
541
|
+
updateHit.run(id);
|
|
542
|
+
injectedAndCited++;
|
|
543
|
+
}
|
|
516
544
|
}
|
|
517
545
|
|
|
518
546
|
// Accounting-mode stamp (constant value, rewritten every
|
|
@@ -527,7 +555,16 @@ process.stdin.on('end', async () => {
|
|
|
527
555
|
ON CONFLICT(key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`
|
|
528
556
|
);
|
|
529
557
|
bump.run('citation_sessions_total');
|
|
530
|
-
|
|
558
|
+
// Initialised unconditionally, then bumped. Writing it only on
|
|
559
|
+
// a citation made "zero sessions cited" and "this code never
|
|
560
|
+
// ran" the same absent key — and that is exactly what a real
|
|
561
|
+
// database showed on 2026-08-24: total=4, cited absent, with
|
|
562
|
+
// no way to tell a 0% compliance rate from a dead counter.
|
|
563
|
+
db.prepare(
|
|
564
|
+
`INSERT INTO memesh_metadata (key, value) VALUES ('citation_sessions_cited', '0')
|
|
565
|
+
ON CONFLICT(key) DO NOTHING`
|
|
566
|
+
).run();
|
|
567
|
+
if (injectedAndCited > 0) bump.run('citation_sessions_cited');
|
|
531
568
|
}
|
|
532
569
|
}
|
|
533
570
|
}
|
|
@@ -94,7 +94,15 @@ CURRENT_VERSION="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" node -e "
|
|
|
94
94
|
const entries = (j.plugins && j.plugins['memesh@pcircle-memesh']) || [];
|
|
95
95
|
if (entries.length === 0) { process.stdout.write('none'); process.exit(0); }
|
|
96
96
|
process.stdout.write(entries[0].version || 'unknown');
|
|
97
|
-
")"
|
|
97
|
+
")" || {
|
|
98
|
+
# Its sibling twelve lines up has this guard; this read did not, so an
|
|
99
|
+
# unreadable or malformed installed_plugins.json made CURRENT_VERSION the
|
|
100
|
+
# empty string. That compares unequal to every target, so the script
|
|
101
|
+
# reported an upgrade from "" and carried on — on a registry it had just
|
|
102
|
+
# failed to parse.
|
|
103
|
+
echo "ERROR: could not read the installed memesh version from $INSTALL_REGISTRY" >&2
|
|
104
|
+
exit 1
|
|
105
|
+
}
|
|
98
106
|
|
|
99
107
|
echo "==> Currently installed: $CURRENT_VERSION"
|
|
100
108
|
|