@pcircle/memesh 4.6.0 → 4.6.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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/AGENTS.md +21 -0
- package/README.de.md +12 -13
- package/README.md +12 -14
- package/README.zh-TW.md +12 -13
- package/dashboard/dist/index.html +14 -13
- package/dist/core/analytics.d.ts +9 -0
- package/dist/core/analytics.d.ts.map +1 -1
- package/dist/core/analytics.js +36 -18
- 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/config.d.ts +0 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/demo.d.ts.map +1 -1
- package/dist/core/demo.js +1 -1
- package/dist/core/demo.js.map +1 -1
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +25 -6
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/dreamer.d.ts +2 -2
- package/dist/core/dreamer.d.ts.map +1 -1
- package/dist/core/dreamer.js +221 -0
- package/dist/core/dreamer.js.map +1 -1
- package/dist/core/embedder.d.ts +4 -1
- package/dist/core/embedder.d.ts.map +1 -1
- package/dist/core/embedder.js +67 -17
- package/dist/core/embedder.js.map +1 -1
- package/dist/core/graph.d.ts +12 -0
- package/dist/core/graph.d.ts.map +1 -1
- package/dist/core/graph.js +56 -1
- package/dist/core/graph.js.map +1 -1
- package/dist/core/guards.d.ts +20 -0
- package/dist/core/guards.d.ts.map +1 -0
- package/dist/core/guards.js +103 -0
- package/dist/core/guards.js.map +1 -0
- package/dist/core/kg-backfill.d.ts +5 -1
- package/dist/core/kg-backfill.d.ts.map +1 -1
- package/dist/core/kg-backfill.js +155 -2
- package/dist/core/kg-backfill.js.map +1 -1
- package/dist/core/operations.d.ts +13 -2
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +93 -15
- package/dist/core/operations.js.map +1 -1
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +8 -0
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/types.d.ts +1 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/why.d.ts +54 -0
- package/dist/core/why.d.ts.map +1 -0
- package/dist/core/why.js +168 -0
- package/dist/core/why.js.map +1 -0
- package/dist/core/work-topology.d.ts +3 -0
- package/dist/core/work-topology.d.ts.map +1 -1
- package/dist/core/work-topology.js +11 -2
- package/dist/core/work-topology.js.map +1 -1
- package/dist/db.d.ts +31 -4
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +163 -31
- package/dist/db.js.map +1 -1
- package/dist/skills-manifest.json +29 -19
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +1 -1
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/transports/cli/cli.js +198 -61
- 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 +43 -5
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +2 -2
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +8 -0
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +7 -0
- package/dist/transports/schemas.js.map +1 -1
- package/hooks/hooks.json +10 -0
- package/package.json +3 -2
- package/scripts/hooks/_generated/guards.js +110 -0
- package/scripts/hooks/_generated/sqlite.js +1 -1
- package/scripts/hooks/_generated/work-topology.js +11 -2
- package/scripts/hooks/_shared.js +77 -4
- package/scripts/hooks/guard-check.js +76 -0
- package/scripts/hooks/post-commit.js +27 -0
- package/scripts/hooks/pre-edit-recall.js +157 -121
- package/scripts/hooks/session-start.js +34 -23
- package/scripts/hooks/session-summary.js +43 -90
- package/skills/memesh/SKILL.md +11 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Lesson guards — PreToolUse hook for Bash (G1).
|
|
4
|
+
//
|
|
5
|
+
// A guard is a failure-lesson a human promoted (via the dream proposal
|
|
6
|
+
// queue) to speak at the moment its mistake is about to repeat: a regex
|
|
7
|
+
// over the command about to run. This hook is the Bash evaluation point;
|
|
8
|
+
// the Edit/Write point lives inside pre-edit-recall, which is already
|
|
9
|
+
// wired to that matcher.
|
|
10
|
+
//
|
|
11
|
+
// Contract, in order of importance:
|
|
12
|
+
// 1. NEVER block or slow the user's work: every failure path is a
|
|
13
|
+
// silent pass (with a stderr trace), and v1 guards only WARN — the
|
|
14
|
+
// warning is additionalContext, exit code stays 0.
|
|
15
|
+
// 2. The guard message is memory content — attacker-influenced in the
|
|
16
|
+
// general case — so it is fenced by buildReferenceContext exactly
|
|
17
|
+
// like every other injection path.
|
|
18
|
+
// 3. Every fire is counted (metadata.guard.fires): a guard that never
|
|
19
|
+
// fires or fires constantly is a review item, and the count is what
|
|
20
|
+
// surfaces it.
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'fs';
|
|
23
|
+
import {
|
|
24
|
+
buildReferenceContext,
|
|
25
|
+
getDbPath,
|
|
26
|
+
loadActiveGuards,
|
|
27
|
+
matchingGuards,
|
|
28
|
+
guardWarningLines,
|
|
29
|
+
recordGuardFires,
|
|
30
|
+
} from './_shared.js';
|
|
31
|
+
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
32
|
+
|
|
33
|
+
const dbPath = getDbPath();
|
|
34
|
+
|
|
35
|
+
let input = '';
|
|
36
|
+
process.stdin.setEncoding('utf8');
|
|
37
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
38
|
+
process.stdin.on('end', () => {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(input);
|
|
41
|
+
const command = data?.tool_input?.command;
|
|
42
|
+
if (!command || typeof command !== 'string') return pass();
|
|
43
|
+
if (!existsSync(dbPath)) return pass();
|
|
44
|
+
|
|
45
|
+
// `readOnly`, not `readonly`: node:sqlite ignores the lowercase
|
|
46
|
+
// spelling and hands back a WRITABLE handle. This hook only reads;
|
|
47
|
+
// the fire counter opens its own writable handle for the one UPDATE.
|
|
48
|
+
const db = new MemeshDatabase(dbPath, { readOnly: true });
|
|
49
|
+
let matches;
|
|
50
|
+
try {
|
|
51
|
+
matches = matchingGuards(loadActiveGuards(db, 'Bash'), 'Bash', command);
|
|
52
|
+
} finally {
|
|
53
|
+
db.close();
|
|
54
|
+
}
|
|
55
|
+
if (!matches || matches.length === 0) return pass();
|
|
56
|
+
|
|
57
|
+
recordGuardFires(dbPath, matches.map((g) => g.lessonId));
|
|
58
|
+
|
|
59
|
+
console.log(JSON.stringify({
|
|
60
|
+
hookSpecificOutput: {
|
|
61
|
+
hookEventName: 'PreToolUse',
|
|
62
|
+
additionalContext: buildReferenceContext(guardWarningLines(matches, 'Bash')),
|
|
63
|
+
},
|
|
64
|
+
}));
|
|
65
|
+
process.exit(0);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Never crash Claude Code, but trace — a silent break here means
|
|
68
|
+
// every accepted guard stops firing and nothing reports it.
|
|
69
|
+
try { process.stderr.write(`[memesh guard-check] ${err?.message || err}\n`); } catch {}
|
|
70
|
+
pass();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function pass() {
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
@@ -133,6 +133,32 @@ process.stdin.on('end', () => {
|
|
|
133
133
|
// git show failed — no diff stats recorded, existing behavior unchanged
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Commit → session linkage, the hop `memesh why` walks. The payload has
|
|
137
|
+
// carried session_id all along; this hook just never recorded it, so
|
|
138
|
+
// every commit entity was an island (no file list, no session, no
|
|
139
|
+
// relations). Recorded as METADATA, not tags, on purpose: a `file:*`
|
|
140
|
+
// tag here would make pre-edit-recall inject commit noise into every
|
|
141
|
+
// edit of a touched file (Strategy 1 joins on exactly that tag).
|
|
142
|
+
const whyMetadata = {};
|
|
143
|
+
if (typeof data.session_id === 'string' && data.session_id) {
|
|
144
|
+
whyMetadata.session_id = data.session_id;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const nameOnly = execFileSync('git', ['-C', data.cwd, 'show', '--name-only', '--format=', commitHash], {
|
|
148
|
+
encoding: 'utf8',
|
|
149
|
+
timeout: 5000,
|
|
150
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
151
|
+
}).trim();
|
|
152
|
+
if (nameOnly) {
|
|
153
|
+
// Repo-relative paths, capped: a lockfile-churn commit can touch
|
|
154
|
+
// thousands of files and metadata rides every entity read.
|
|
155
|
+
const files = nameOnly.split('\n').map(l => l.trim()).filter(Boolean);
|
|
156
|
+
if (files.length > 0) whyMetadata.files = files.slice(0, 50);
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// Best-effort like the diff stats above — no file list recorded.
|
|
160
|
+
}
|
|
161
|
+
|
|
136
162
|
// Shared write dance — upsert entity + observations + tags AND reindex FTS.
|
|
137
163
|
//
|
|
138
164
|
// `source:auto-capture` is the provenance marker every capture hook
|
|
@@ -148,6 +174,7 @@ process.stdin.on('end', () => {
|
|
|
148
174
|
observations,
|
|
149
175
|
tags: [AUTO_CAPTURE_TAG, `project:${projectName}`],
|
|
150
176
|
title: truncateTitle(commitMsg),
|
|
177
|
+
metadata: whyMetadata,
|
|
151
178
|
});
|
|
152
179
|
|
|
153
180
|
// Heartbeat AFTER capture, so the stamp certifies "the capture loop
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// Continuous Recall — PreToolUse hook for Edit/Write
|
|
4
4
|
// When editing a file, checks if MeMesh has relevant memories
|
|
5
5
|
// and injects them as context. Throttled: max 1 recall per file per session.
|
|
6
|
+
//
|
|
7
|
+
// Also the Edit/Write evaluation point for lesson guards (G1): accepted
|
|
8
|
+
// guards match against the path plus the content about to be written, and
|
|
9
|
+
// a hit injects the lesson's warning at the exact moment its mistake is
|
|
10
|
+
// about to repeat. Guards are NOT throttled — a dangerous edit is
|
|
11
|
+
// dangerous every time.
|
|
6
12
|
|
|
7
13
|
import { basename, join } from 'path';
|
|
8
14
|
import { existsSync, readFileSync } from 'fs';
|
|
@@ -15,6 +21,10 @@ import {
|
|
|
15
21
|
isTrustedForAutoContext,
|
|
16
22
|
writePrivateJson,
|
|
17
23
|
hookMatchExpression,
|
|
24
|
+
loadActiveGuards,
|
|
25
|
+
matchingGuards,
|
|
26
|
+
guardWarningLines,
|
|
27
|
+
recordGuardFires,
|
|
18
28
|
} from './_shared.js';
|
|
19
29
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
20
30
|
|
|
@@ -43,7 +53,9 @@ process.stdin.on('end', () => {
|
|
|
43
53
|
return pass();
|
|
44
54
|
}
|
|
45
55
|
|
|
46
|
-
// Throttle
|
|
56
|
+
// Throttle — the RECALL half runs once per file per session. The guard
|
|
57
|
+
// half is deliberately outside it, so a throttled call still opens the
|
|
58
|
+
// database for the guard pass.
|
|
47
59
|
const fileKey = filePath.toLowerCase();
|
|
48
60
|
let seenFiles = [];
|
|
49
61
|
try {
|
|
@@ -54,151 +66,175 @@ process.stdin.on('end', () => {
|
|
|
54
66
|
} catch {
|
|
55
67
|
seenFiles = [];
|
|
56
68
|
}
|
|
57
|
-
|
|
58
|
-
if (seenFiles.includes(fileKey)) {
|
|
59
|
-
return pass();
|
|
60
|
-
}
|
|
69
|
+
const throttled = seenFiles.includes(fileKey);
|
|
61
70
|
|
|
62
71
|
if (!existsSync(dbPath)) return pass();
|
|
63
72
|
|
|
64
|
-
// Get project name from cwd for project-scoped filtering
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
73
|
+
// Get project name from cwd for project-scoped RECALL filtering — this
|
|
74
|
+
// spawns 1-2 git subprocesses, and the throttled path (every repeat
|
|
75
|
+
// edit of the same file) must not pay for it. Guards are global by
|
|
76
|
+
// design: a mistake recorded in one project is usually a mistake
|
|
77
|
+
// everywhere (the pipe-eats-exit-code shape), and the warning names
|
|
78
|
+
// its source lesson either way.
|
|
79
|
+
const projectName = throttled ? null : getProjectName(data.cwd);
|
|
68
80
|
|
|
69
81
|
// `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
|
|
70
|
-
// and hands back a WRITABLE handle. This hook only reads
|
|
82
|
+
// and hands back a WRITABLE handle. This hook only reads; the guard
|
|
83
|
+
// fire counter opens its own writable handle for its one UPDATE.
|
|
71
84
|
const db = new MemeshDatabase(dbPath, { readOnly: true });
|
|
85
|
+
let guardMatches = [];
|
|
86
|
+
const recallLines = [];
|
|
72
87
|
try {
|
|
88
|
+
// Guard pass (G1) — matched against the path plus the content about
|
|
89
|
+
// to land, which is what the mistake would be made OF.
|
|
90
|
+
const toolName = data.tool_name === 'Write' ? 'Write' : 'Edit';
|
|
91
|
+
const guardHaystack = `${filePath}\n${toolInput.new_string ?? toolInput.content ?? ''}`;
|
|
92
|
+
guardMatches = matchingGuards(loadActiveGuards(db, toolName), toolName, guardHaystack);
|
|
73
93
|
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
.some(c => c.name === 'status');
|
|
91
|
-
const statusFilter = hasStatus ? "AND e.status = 'active'" : '';
|
|
94
|
+
// Recall pass — throttled, project-scoped.
|
|
95
|
+
if (!throttled) {
|
|
96
|
+
// Check if entities table exists
|
|
97
|
+
// Both tables, not just `entities`. Strategy 2 below joins entities_fts,
|
|
98
|
+
// and this hook opens the database READ-ONLY without going through
|
|
99
|
+
// openHookDb, so it never creates that table. Checking only `entities`
|
|
100
|
+
// meant a structurally-absent index reached the query and failed there —
|
|
101
|
+
// which, now that the failure is no longer swallowed, would print on
|
|
102
|
+
// every single Edit.
|
|
103
|
+
const tables = new Set(
|
|
104
|
+
db.prepare(
|
|
105
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('entities','entities_fts')"
|
|
106
|
+
).all().map((r) => r.name)
|
|
107
|
+
);
|
|
108
|
+
if (tables.has('entities')) {
|
|
109
|
+
const hasFts = tables.has('entities_fts');
|
|
92
110
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// 3. FTS5 search on the basename (without extension)
|
|
97
|
-
const fileName = basename(filePath);
|
|
98
|
-
const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
|
|
111
|
+
const hasStatus = db.prepare("PRAGMA table_info(entities)").all()
|
|
112
|
+
.some(c => c.name === 'status');
|
|
113
|
+
const statusFilter = hasStatus ? "AND e.status = 'active'" : '';
|
|
99
114
|
|
|
100
|
-
|
|
115
|
+
// Search strategies:
|
|
116
|
+
// 1. Entities tagged with the file's basename (e.g., "file:auth.ts")
|
|
117
|
+
// 2. Entities with name matching the file's basename (without extension)
|
|
118
|
+
// 3. FTS5 search on the basename (without extension)
|
|
119
|
+
const fileName = basename(filePath);
|
|
120
|
+
const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
|
|
101
121
|
|
|
102
|
-
|
|
103
|
-
// CRITICAL: Filter by project to prevent cross-project memory injection
|
|
104
|
-
const projectTag = `project:${projectName}`;
|
|
105
|
-
const tagResults = db.prepare(`
|
|
106
|
-
SELECT DISTINCT e.id, e.name, e.type, e.metadata
|
|
107
|
-
FROM entities e
|
|
108
|
-
JOIN tags t1 ON t1.entity_id = e.id
|
|
109
|
-
JOIN tags t2 ON t2.entity_id = e.id
|
|
110
|
-
WHERE (t1.tag = ? OR t1.tag = ?)
|
|
111
|
-
AND t2.tag = ?
|
|
112
|
-
${statusFilter}
|
|
113
|
-
LIMIT ?
|
|
114
|
-
`).all(`file:${fileName}`, `file:${fileNameNoExt}`, projectTag, MAX_RESULTS * 3);
|
|
115
|
-
results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
|
|
122
|
+
const results = [];
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
// tokens the index actually holds. Quoting the raw basename here meant
|
|
122
|
-
// a CJK or decomposed-Unicode filename matched nothing at all against
|
|
123
|
-
// the segmented index — and the catch below made that invisible.
|
|
124
|
-
const matchExpr = hookMatchExpression(fileNameNoExt);
|
|
125
|
-
try {
|
|
126
|
-
// ORDER BY rank is load-bearing now that terms are OR-ed.
|
|
127
|
-
//
|
|
128
|
-
// The match expression used to be a single phrase, so `LIMIT` picked
|
|
129
|
-
// from a handful of rows that all genuinely contained the basename and
|
|
130
|
-
// arbitrary selection was tolerable. `hookMatchExpression` now emits
|
|
131
|
-
// `"knowledge" OR "graph"` for `knowledge-graph.ts` — necessary,
|
|
132
|
-
// because a CJK basename has to be reachable by its bigrams — which
|
|
133
|
-
// makes the match set large and unranked selection IS the result:
|
|
134
|
-
// editing that file in a project whose memories merely mention
|
|
135
|
-
// "graph" injected whatever the scan happened to reach first, where
|
|
136
|
-
// the old code correctly injected nothing. BM25 is what makes the OR
|
|
137
|
-
// safe; without it the fix trades a CJK miss for an ASCII false hit.
|
|
138
|
-
const ftsResults = matchExpr === null ? [] : db.prepare(`
|
|
124
|
+
// Strategy 1: Tag-based search (file:name or mentions of the file)
|
|
125
|
+
// CRITICAL: Filter by project to prevent cross-project memory injection
|
|
126
|
+
const projectTag = `project:${projectName}`;
|
|
127
|
+
const tagResults = db.prepare(`
|
|
139
128
|
SELECT DISTINCT e.id, e.name, e.type, e.metadata
|
|
140
129
|
FROM entities e
|
|
141
|
-
JOIN
|
|
142
|
-
JOIN tags
|
|
143
|
-
WHERE
|
|
144
|
-
AND
|
|
130
|
+
JOIN tags t1 ON t1.entity_id = e.id
|
|
131
|
+
JOIN tags t2 ON t2.entity_id = e.id
|
|
132
|
+
WHERE (t1.tag = ? OR t1.tag = ?)
|
|
133
|
+
AND t2.tag = ?
|
|
145
134
|
${statusFilter}
|
|
146
|
-
ORDER BY fts.rank, e.id DESC
|
|
147
135
|
LIMIT ?
|
|
148
|
-
`).all(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
136
|
+
`).all(`file:${fileName}`, `file:${fileNameNoExt}`, projectTag, MAX_RESULTS * 3);
|
|
137
|
+
results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
|
|
138
|
+
|
|
139
|
+
// Strategy 2: FTS5 search on file name (if not enough results)
|
|
140
|
+
// CRITICAL: Filter by project to prevent cross-project memory injection
|
|
141
|
+
if (hasFts && results.length < MAX_RESULTS && fileNameNoExt.length >= 4) {
|
|
142
|
+
// Built by the same function core uses, so this query asks for the
|
|
143
|
+
// tokens the index actually holds. Quoting the raw basename here meant
|
|
144
|
+
// a CJK or decomposed-Unicode filename matched nothing at all against
|
|
145
|
+
// the segmented index — and the catch below made that invisible.
|
|
146
|
+
const matchExpr = hookMatchExpression(fileNameNoExt);
|
|
147
|
+
try {
|
|
148
|
+
// ORDER BY rank is load-bearing now that terms are OR-ed.
|
|
149
|
+
//
|
|
150
|
+
// The match expression used to be a single phrase, so `LIMIT` picked
|
|
151
|
+
// from a handful of rows that all genuinely contained the basename and
|
|
152
|
+
// arbitrary selection was tolerable. `hookMatchExpression` now emits
|
|
153
|
+
// `"knowledge" OR "graph"` for `knowledge-graph.ts` — necessary,
|
|
154
|
+
// because a CJK basename has to be reachable by its bigrams — which
|
|
155
|
+
// makes the match set large and unranked selection IS the result:
|
|
156
|
+
// editing that file in a project whose memories merely mention
|
|
157
|
+
// "graph" injected whatever the scan happened to reach first, where
|
|
158
|
+
// the old code correctly injected nothing. BM25 is what makes the OR
|
|
159
|
+
// safe; without it the fix trades a CJK miss for an ASCII false hit.
|
|
160
|
+
const ftsResults = matchExpr === null ? [] : db.prepare(`
|
|
161
|
+
SELECT DISTINCT e.id, e.name, e.type, e.metadata
|
|
162
|
+
FROM entities e
|
|
163
|
+
JOIN entities_fts fts ON fts.rowid = e.id
|
|
164
|
+
JOIN tags t ON t.entity_id = e.id
|
|
165
|
+
WHERE entities_fts MATCH ?
|
|
166
|
+
AND t.tag = ?
|
|
167
|
+
${statusFilter}
|
|
168
|
+
ORDER BY fts.rank, e.id DESC
|
|
169
|
+
LIMIT ?
|
|
170
|
+
`).all(matchExpr, projectTag, (MAX_RESULTS - results.length) * 3);
|
|
171
|
+
// Deduplicate
|
|
172
|
+
for (const r of ftsResults) {
|
|
173
|
+
if (!isTrustedForAutoContext(r.metadata)) continue;
|
|
174
|
+
if (!results.some(existing => existing.id === r.id)) {
|
|
175
|
+
results.push(r);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
// Never fail the user's edit over a recall miss, but do not pretend
|
|
180
|
+
// nothing happened either: a silently-skipped FTS query is how this
|
|
181
|
+
// hook injected zero memories for months without anyone noticing.
|
|
182
|
+
//
|
|
183
|
+
// Throttled, because PreToolUse fires a fresh process per Edit/Write
|
|
184
|
+
// and a persistent fault would otherwise print on every keystroke's
|
|
185
|
+
// worth of tool calls. Once per distinct message per day is enough to
|
|
186
|
+
// be noticed without becoming noise the user learns to ignore.
|
|
187
|
+
reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
|
|
154
188
|
}
|
|
155
189
|
}
|
|
156
|
-
} catch (err) {
|
|
157
|
-
// Never fail the user's edit over a recall miss, but do not pretend
|
|
158
|
-
// nothing happened either: a silently-skipped FTS query is how this
|
|
159
|
-
// hook injected zero memories for months without anyone noticing.
|
|
160
|
-
//
|
|
161
|
-
// Throttled, because PreToolUse fires a fresh process per Edit/Write
|
|
162
|
-
// and a persistent fault would otherwise print on every keystroke's
|
|
163
|
-
// worth of tool calls. Once per distinct message per day is enough to
|
|
164
|
-
// be noticed without becoming noise the user learns to ignore.
|
|
165
|
-
reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
190
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
191
|
+
if (results.length > 0) {
|
|
192
|
+
// Fetch first observation for each result
|
|
193
|
+
const getObs = db.prepare(
|
|
194
|
+
'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
|
|
195
|
+
);
|
|
174
196
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
197
|
+
recallLines.push(`Relevant memories for ${fileName}:`);
|
|
198
|
+
for (const r of results.slice(0, MAX_RESULTS)) {
|
|
199
|
+
const obs = getObs.get(r.id);
|
|
200
|
+
const snippet = obs ? obs.content.slice(0, 120) : '';
|
|
201
|
+
recallLines.push(snippet
|
|
202
|
+
? `• ${r.name} (${r.type}): ${snippet}`
|
|
203
|
+
: `• ${r.name} (${r.type})`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
179
207
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const snippet = obs ? obs.content.slice(0, 120) : '';
|
|
184
|
-
lines.push(snippet
|
|
185
|
-
? `• ${r.name} (${r.type}): ${snippet}`
|
|
186
|
-
: `• ${r.name} (${r.type})`
|
|
187
|
-
);
|
|
208
|
+
// Record as seen either way (avoid re-querying a no-result file)
|
|
209
|
+
recordSeen(seenFiles, fileKey);
|
|
210
|
+
}
|
|
188
211
|
}
|
|
189
|
-
|
|
190
|
-
// Record as seen
|
|
191
|
-
recordSeen(seenFiles, fileKey);
|
|
192
|
-
|
|
193
|
-
console.log(JSON.stringify({
|
|
194
|
-
hookSpecificOutput: {
|
|
195
|
-
hookEventName: 'PreToolUse',
|
|
196
|
-
additionalContext: buildReferenceContext(lines),
|
|
197
|
-
},
|
|
198
|
-
}));
|
|
199
212
|
} finally {
|
|
200
213
|
db.close();
|
|
201
214
|
}
|
|
215
|
+
|
|
216
|
+
if (guardMatches.length === 0 && recallLines.length === 0) {
|
|
217
|
+
return pass();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (guardMatches.length > 0) {
|
|
221
|
+
recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// One fenced block, guards first — the warning about the edit at hand
|
|
225
|
+
// outranks background recall. Both halves are memory content, so both
|
|
226
|
+
// ride the same "background data" fence.
|
|
227
|
+
const toolLabel = data.tool_name === 'Write' ? 'Write' : 'Edit';
|
|
228
|
+
const lines = guardMatches.length > 0
|
|
229
|
+
? [...guardWarningLines(guardMatches, toolLabel), ...(recallLines.length > 0 ? ['', ...recallLines] : [])]
|
|
230
|
+
: recallLines;
|
|
231
|
+
|
|
232
|
+
console.log(JSON.stringify({
|
|
233
|
+
hookSpecificOutput: {
|
|
234
|
+
hookEventName: 'PreToolUse',
|
|
235
|
+
additionalContext: buildReferenceContext(lines),
|
|
236
|
+
},
|
|
237
|
+
}));
|
|
202
238
|
} catch (err) {
|
|
203
239
|
// Never crash Claude Code, but trace — peer hooks (post-commit,
|
|
204
240
|
// pre-compact, session-summary) all stderr-trace their outer
|
|
@@ -802,14 +802,16 @@ process.stdin.on('end', async () => {
|
|
|
802
802
|
// the assembleTopologyBlock call — "the same block" depends on the two
|
|
803
803
|
// surfaces agreeing, so neither side restates the numbers.
|
|
804
804
|
|
|
805
|
+
// Only the entities we will actually render — the lesson query pulls
|
|
806
|
+
// up to 50 rows for the banner count, but at most 5 are injected, and
|
|
807
|
+
// this runs before the user's first turn. Bounded well under SQLite's
|
|
808
|
+
// 999-variable limit by construction (5 lessons + sessionLimit
|
|
809
|
+
// project + 5 recent). Declared out here because the injected-set
|
|
810
|
+
// record below must list the SAME lessons the block renders.
|
|
811
|
+
const topLessons = lessonEntities.slice(0, 5);
|
|
812
|
+
|
|
805
813
|
const memoryLines = [];
|
|
806
814
|
try {
|
|
807
|
-
// Only the entities we will actually render — the lesson query pulls
|
|
808
|
-
// up to 50 rows for the banner count, but at most 5 are injected, and
|
|
809
|
-
// this runs before the user's first turn. Bounded well under SQLite's
|
|
810
|
-
// 999-variable limit by construction (5 lessons + sessionLimit
|
|
811
|
-
// project + 5 recent).
|
|
812
|
-
const topLessons = lessonEntities.slice(0, 5);
|
|
813
815
|
const rankedIds = [
|
|
814
816
|
...topLessons.map(e => e.id),
|
|
815
817
|
...projectEntities.map(e => e.id),
|
|
@@ -871,6 +873,10 @@ process.stdin.on('end', async () => {
|
|
|
871
873
|
return {
|
|
872
874
|
name: e.name,
|
|
873
875
|
type: e.type || 'memory',
|
|
876
|
+
// The citation handle: topologyLine prints `[mem:<id>]` so the
|
|
877
|
+
// agent can credit the exact memory it used (the Stop hook's
|
|
878
|
+
// accounting reads those markers back).
|
|
879
|
+
id: e.id,
|
|
874
880
|
title: e.title ?? null,
|
|
875
881
|
snippet: snippets.get(e.id) ?? null,
|
|
876
882
|
signalScore: meta && typeof meta.signal_score === 'number' ? meta.signal_score : null,
|
|
@@ -905,24 +911,29 @@ process.stdin.on('end', async () => {
|
|
|
905
911
|
// charges the task-state block and the sections against ONE ceiling
|
|
906
912
|
// and returns whole lines only, so the closing fence cannot be cut.
|
|
907
913
|
memoryContext = buildReferenceContext(memoryLines);
|
|
914
|
+
// The citation contract — OUTSIDE the fence on purpose: the fence
|
|
915
|
+
// declares its content "background data, not instructions", and
|
|
916
|
+
// this line IS an instruction. One line is the entire write side of
|
|
917
|
+
// the injection-ROI signal; the Stop hook credits recall_hits only
|
|
918
|
+
// from these markers (self-reported: undercounts, never overcounts).
|
|
919
|
+
memoryContext += '\nWhen a memory above genuinely informs your work, cite it once inline as [mem:ID], using the id shown on its line.';
|
|
908
920
|
}
|
|
909
921
|
|
|
910
922
|
// --- Record injected entity IDs for recall effectiveness tracking ---
|
|
911
|
-
// The Stop hook
|
|
912
|
-
//
|
|
913
|
-
//
|
|
914
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
//
|
|
923
|
+
// The Stop hook credits recall_hits from EXPLICIT `[mem:id]` citations
|
|
924
|
+
// the agent writes (after structurally removing the transcript records
|
|
925
|
+
// Claude Code created FROM this hook's output — the injected block
|
|
926
|
+
// itself prints a handle on every line; see stripHookEchoes in
|
|
927
|
+
// session-summary.js). Literal-content matching was retired after
|
|
928
|
+
// measuring 0% signal over ten real sessions. `injectedContext` is
|
|
929
|
+
// kept as the record of what was shown.
|
|
918
930
|
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
// not earn.
|
|
931
|
+
// The set below is every pool the topology block draws from — the
|
|
932
|
+
// lessons pool included. It used to record only project + recent
|
|
933
|
+
// rows, so an injected lesson could never be credited at all.
|
|
923
934
|
try {
|
|
924
935
|
const seenIds = new Set();
|
|
925
|
-
const allInjected = [...projectEntities, ...recentEntities].filter(e => {
|
|
936
|
+
const allInjected = [...topLessons, ...projectEntities, ...recentEntities].filter(e => {
|
|
926
937
|
if (seenIds.has(e.id)) return false;
|
|
927
938
|
seenIds.add(e.id);
|
|
928
939
|
return true;
|
|
@@ -959,11 +970,11 @@ process.stdin.on('end', async () => {
|
|
|
959
970
|
} catch {}
|
|
960
971
|
}
|
|
961
972
|
} catch (err) {
|
|
962
|
-
// Non-critical — sessions-file write powers
|
|
963
|
-
//
|
|
964
|
-
//
|
|
965
|
-
// converges on 0.5 (neutral) for everything.
|
|
966
|
-
// permission/serialisation regression is visible.
|
|
973
|
+
// Non-critical — sessions-file write powers citation accounting
|
|
974
|
+
// (recall_hits credited from `[mem:id]` markers). If it silently
|
|
975
|
+
// breaks, no hit can ever be credited and the impact-score factor
|
|
976
|
+
// in core/scoring.ts converges on 0.5 (neutral) for everything.
|
|
977
|
+
// Stderr trace so a permission/serialisation regression is visible.
|
|
967
978
|
try { process.stderr.write(`[memesh session-start] sessions-write: ${err?.message || err}\n`); } catch {}
|
|
968
979
|
}
|
|
969
980
|
|