@pcircle/memesh 4.9.4 → 4.10.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/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +75 -13
- package/README.de.md +5 -5
- package/README.md +5 -5
- package/README.zh-TW.md +5 -5
- package/dashboard/dist/index.html +8 -8
- package/dist/cli/view-live.d.ts.map +1 -1
- package/dist/cli/view-live.js +0 -427
- package/dist/cli/view-live.js.map +1 -1
- package/dist/core/agent-message-inbox.d.ts +6 -0
- package/dist/core/agent-message-inbox.d.ts.map +1 -1
- package/dist/core/agent-message-inbox.js +28 -1
- package/dist/core/agent-message-inbox.js.map +1 -1
- package/dist/core/briefing-index.d.ts +30 -0
- package/dist/core/briefing-index.d.ts.map +1 -0
- package/dist/core/briefing-index.js +140 -0
- package/dist/core/briefing-index.js.map +1 -0
- package/dist/core/briefing-level.d.ts +22 -0
- package/dist/core/briefing-level.d.ts.map +1 -0
- package/dist/core/briefing-level.js +75 -0
- package/dist/core/briefing-level.js.map +1 -0
- package/dist/core/briefing.d.ts +7 -0
- package/dist/core/briefing.d.ts.map +1 -1
- package/dist/core/briefing.js +72 -12
- package/dist/core/briefing.js.map +1 -1
- package/dist/core/capture-liveness.d.ts +137 -0
- package/dist/core/capture-liveness.d.ts.map +1 -0
- package/dist/core/capture-liveness.js +448 -0
- package/dist/core/capture-liveness.js.map +1 -0
- package/dist/core/config.d.ts +2 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +5 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/delegation.d.ts +47 -0
- package/dist/core/delegation.d.ts.map +1 -0
- package/dist/core/delegation.js +174 -0
- package/dist/core/delegation.js.map +1 -0
- package/dist/core/doctor-fixes.d.ts +1 -0
- package/dist/core/doctor-fixes.d.ts.map +1 -1
- package/dist/core/doctor-fixes.js +21 -0
- package/dist/core/doctor-fixes.js.map +1 -1
- package/dist/core/doctor.d.ts +8 -0
- package/dist/core/doctor.d.ts.map +1 -1
- package/dist/core/doctor.js +148 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/note-derive.d.ts +14 -0
- package/dist/core/note-derive.d.ts.map +1 -0
- package/dist/core/note-derive.js +66 -0
- package/dist/core/note-derive.js.map +1 -0
- package/dist/core/note-ingest.d.ts +34 -0
- package/dist/core/note-ingest.d.ts.map +1 -0
- package/dist/core/note-ingest.js +445 -0
- package/dist/core/note-ingest.js.map +1 -0
- package/dist/core/operations.d.ts +10 -1
- package/dist/core/operations.d.ts.map +1 -1
- package/dist/core/operations.js +129 -14
- package/dist/core/operations.js.map +1 -1
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.d.ts.map +1 -1
- package/dist/core/paths.js +4 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/schema-export.d.ts.map +1 -1
- package/dist/core/schema-export.js +15 -5
- package/dist/core/schema-export.js.map +1 -1
- package/dist/core/serializer.d.ts +2 -0
- package/dist/core/serializer.d.ts.map +1 -1
- package/dist/core/serializer.js +119 -3
- package/dist/core/serializer.js.map +1 -1
- package/dist/core/session-insight.d.ts +44 -0
- package/dist/core/session-insight.d.ts.map +1 -0
- package/dist/core/session-insight.js +216 -0
- package/dist/core/session-insight.js.map +1 -0
- package/dist/core/task-state-store.d.ts +4 -0
- package/dist/core/task-state-store.d.ts.map +1 -1
- package/dist/core/task-state-store.js +18 -5
- package/dist/core/task-state-store.js.map +1 -1
- package/dist/core/task-state.d.ts +5 -0
- package/dist/core/task-state.d.ts.map +1 -1
- package/dist/core/task-state.js +70 -0
- package/dist/core/task-state.js.map +1 -1
- package/dist/core/turn-signal.d.ts +22 -0
- package/dist/core/turn-signal.d.ts.map +1 -0
- package/dist/core/turn-signal.js +89 -0
- package/dist/core/turn-signal.js.map +1 -0
- package/dist/core/types.d.ts +14 -3
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/update-entrypoint.d.ts +19 -0
- package/dist/core/update-entrypoint.d.ts.map +1 -0
- package/dist/core/update-entrypoint.js +222 -0
- package/dist/core/update-entrypoint.js.map +1 -0
- package/dist/core/update-notice.d.ts +69 -0
- package/dist/core/update-notice.d.ts.map +1 -0
- package/dist/core/update-notice.js +188 -0
- package/dist/core/update-notice.js.map +1 -0
- package/dist/core/work-topology.d.ts +1 -0
- package/dist/core/work-topology.d.ts.map +1 -1
- package/dist/core/work-topology.js +3 -0
- package/dist/core/work-topology.js.map +1 -1
- package/dist/host-runtime/codex-session.d.ts +10 -0
- package/dist/host-runtime/codex-session.d.ts.map +1 -1
- package/dist/host-runtime/codex-session.js +1 -1
- package/dist/host-runtime/codex-session.js.map +1 -1
- package/dist/knowledge-graph.d.ts.map +1 -1
- package/dist/knowledge-graph.js +24 -3
- package/dist/knowledge-graph.js.map +1 -1
- package/dist/mcp/THIRD_PARTY_NOTICES.txt +2 -2
- package/dist/mcp/server.js +1438 -122
- package/dist/mcp/server.js.map +3 -3
- package/dist/skills-manifest.json +61 -36
- package/dist/storage/fts-index.d.ts +1 -0
- package/dist/storage/fts-index.d.ts.map +1 -1
- package/dist/storage/fts-index.js +5 -0
- package/dist/storage/fts-index.js.map +1 -1
- package/dist/transports/cli/cli.d.ts.map +1 -1
- package/dist/transports/cli/cli.js +7846 -5613
- package/dist/transports/cli/cli.js.map +3 -3
- package/dist/transports/http/server.d.ts.map +1 -1
- package/dist/transports/http/server.js +48 -28
- package/dist/transports/http/server.js.map +1 -1
- package/dist/transports/mcp/handlers.d.ts +31 -7
- package/dist/transports/mcp/handlers.d.ts.map +1 -1
- package/dist/transports/mcp/handlers.js +49 -7
- package/dist/transports/mcp/handlers.js.map +1 -1
- package/dist/transports/schemas.d.ts +5 -2
- package/dist/transports/schemas.d.ts.map +1 -1
- package/dist/transports/schemas.js +30 -3
- package/dist/transports/schemas.js.map +1 -1
- package/docs/platforms/README.md +1 -0
- package/docs/platforms/agent-messaging.md +39 -8
- package/package.json +11 -6
- package/scripts/hooks/_generated/agent-message-inbox.js +28 -1
- package/scripts/hooks/_generated/briefing-index.js +147 -0
- package/scripts/hooks/_generated/briefing-level.js +82 -0
- package/scripts/hooks/_generated/capture-liveness.js +455 -0
- package/scripts/hooks/_generated/core-paths.js +4 -0
- package/scripts/hooks/_generated/fts-index.js +5 -0
- package/scripts/hooks/_generated/task-state.js +70 -0
- package/scripts/hooks/_generated/update-notice.js +195 -0
- package/scripts/hooks/_generated/work-topology.js +3 -0
- package/scripts/hooks/_shared.js +923 -27
- package/scripts/hooks/_stop-notes.js +504 -0
- package/scripts/hooks/auto-update-runner.mjs +22 -0
- package/scripts/hooks/decision-nudge.js +32 -5
- package/scripts/hooks/guard-check.js +30 -3
- package/scripts/hooks/post-commit.js +329 -133
- package/scripts/hooks/pre-compact.js +17 -2
- package/scripts/hooks/pre-edit-recall.js +416 -64
- package/scripts/hooks/session-start.js +617 -150
- package/scripts/hooks/session-summary.js +217 -41
- package/scripts/hooks/user-prompt-intent.js +124 -22
- package/scripts/upgrade-plugin.sh +3 -4
- package/skills/memesh/SKILL.md +45 -23
- package/dist/cli/assets/d3.v7.min.js +0 -2
- package/dist/core/graph.d.ts +0 -26
- package/dist/core/graph.d.ts.map +0 -1
- package/dist/core/graph.js +0 -74
- package/dist/core/graph.js.map +0 -1
package/scripts/hooks/_shared.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
1
|
+
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'fs';
|
|
2
|
+
import { createHash, randomBytes } from 'crypto';
|
|
3
3
|
import { spawn } from 'child_process';
|
|
4
4
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
5
5
|
import { dirname, join } from 'path';
|
|
@@ -33,12 +33,46 @@ import {
|
|
|
33
33
|
getProjectName,
|
|
34
34
|
redactSecrets,
|
|
35
35
|
canonicalRemoteLocator,
|
|
36
|
+
gitRepoRoot,
|
|
36
37
|
} from './_generated/core-paths.js';
|
|
37
38
|
import { autoCaptureDecision } from './_generated/capture-flag.js';
|
|
38
|
-
export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, DEFAULT_TOPOLOGY_BUDGET, GLOBAL_TOPOLOGY_LIMIT, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
|
|
39
|
+
export { assembleTopologyBlock, buildReferenceContext, extractCitedMemoryIds, hasBriefingContent, DEFAULT_TOPOLOGY_BUDGET, GLOBAL_TOPOLOGY_LIMIT, SNIPPET_FETCH_CHARS, TOPOLOGY_CANDIDATE_CAP } from './_generated/work-topology.js';
|
|
39
40
|
export { readRepoState, repoStateLines } from './_generated/repo-state.js';
|
|
40
41
|
export { matchingGuards, guardFromMetadata } from './_generated/guards.js';
|
|
41
42
|
export { writeCitationRule, citationRulePath, CITATION_RULE_BODY } from './_generated/citation-rule.js';
|
|
43
|
+
// Imported locally (recordHookOutcome below uses them) AND re-exported, so
|
|
44
|
+
// every hook reaches the same definition through one module.
|
|
45
|
+
import {
|
|
46
|
+
detectHookHost,
|
|
47
|
+
serializeHookOutcome,
|
|
48
|
+
trimHookOutcomeLines,
|
|
49
|
+
HOOK_OUTCOMES_FILENAME,
|
|
50
|
+
HOOK_OUTCOMES_ROTATE_BYTES,
|
|
51
|
+
} from './_generated/capture-liveness.js';
|
|
52
|
+
export {
|
|
53
|
+
advanceGraceState,
|
|
54
|
+
captureLivenessNotice,
|
|
55
|
+
captureLivenessVerdict,
|
|
56
|
+
graceInEffect,
|
|
57
|
+
parseGraceState,
|
|
58
|
+
parseHookOutcomes,
|
|
59
|
+
summarizeHookOutcomes,
|
|
60
|
+
HOOK_OUTCOMES_FILENAME,
|
|
61
|
+
SKIP_REASONS,
|
|
62
|
+
isGitCommitCommand,
|
|
63
|
+
} from './_generated/capture-liveness.js';
|
|
64
|
+
export {
|
|
65
|
+
resolveUpdateNotice,
|
|
66
|
+
shouldRefreshUpdateCache,
|
|
67
|
+
readSnooze,
|
|
68
|
+
writeSnooze,
|
|
69
|
+
clearSnooze,
|
|
70
|
+
readJustUpgradedMarker,
|
|
71
|
+
writeJustUpgradedMarker,
|
|
72
|
+
clearJustUpgradedMarker,
|
|
73
|
+
claimJustUpgradedMarker,
|
|
74
|
+
isStrictlyOlder,
|
|
75
|
+
} from './_generated/update-notice.js';
|
|
42
76
|
import { guardFromMetadata as guardFromMetadataLocal } from './_generated/guards.js';
|
|
43
77
|
|
|
44
78
|
/**
|
|
@@ -82,17 +116,36 @@ export function guardWarningLines(matches, toolName) {
|
|
|
82
116
|
return lines;
|
|
83
117
|
}
|
|
84
118
|
|
|
119
|
+
/** How long the fire counter waits for another writer's lock, in ms. Exported
|
|
120
|
+
* so the tests read the same number the hook uses. */
|
|
121
|
+
export const GUARD_COUNTER_WAIT_MS = 200;
|
|
122
|
+
|
|
85
123
|
/**
|
|
86
124
|
* Count a guard's fire. Opens its own WRITABLE handle briefly (the
|
|
87
125
|
* evaluating hooks read through a read-only one) and swallows every
|
|
88
126
|
* failure: the count powers guard-ROI review, and review data must never
|
|
89
127
|
* block the user's work.
|
|
128
|
+
*
|
|
129
|
+
* Waits at most `GUARD_COUNTER_WAIT_MS` for the write lock. The hooks of
|
|
130
|
+
* parallel tool calls each hold it for a millisecond or two and take turns;
|
|
131
|
+
* when the lock is still held after the wait, the count is skipped and it is
|
|
132
|
+
* reported on stderr as not counted. A missed count is invisible to the user;
|
|
133
|
+
* a long wait is not. The host kills a hook at its `hooks.json` budget, and a
|
|
134
|
+
* killed hook loses the guard warning the user needed — on slow CI runners a
|
|
135
|
+
* contended 2 s wait was measured at close to 4 s of the 5 s, so the wait is
|
|
136
|
+
* short. With no wait at all, hooks running at the same instant lost about a
|
|
137
|
+
* third of their counts.
|
|
90
138
|
*/
|
|
91
139
|
export function recordGuardFires(dbPath, lessonIds) {
|
|
92
140
|
if (!lessonIds || lessonIds.length === 0) return;
|
|
93
141
|
try {
|
|
94
142
|
const db = new MemeshDatabase(dbPath);
|
|
95
143
|
try {
|
|
144
|
+
// The constructor only opens the file and sets the 30 s wait meant for
|
|
145
|
+
// the CLI and servers; nothing has touched the lock yet, so lowering it
|
|
146
|
+
// here is early enough. Not `HOOK_BUSY_TIMEOUT_MS`: that wait is for
|
|
147
|
+
// reads and capture writes, which are worth retrying for longer.
|
|
148
|
+
db.pragma(`busy_timeout = ${GUARD_COUNTER_WAIT_MS}`);
|
|
96
149
|
const stmt = db.prepare(
|
|
97
150
|
`UPDATE entities
|
|
98
151
|
SET metadata = json_set(metadata,
|
|
@@ -117,17 +170,35 @@ export function recordGuardFires(dbPath, lessonIds) {
|
|
|
117
170
|
}
|
|
118
171
|
}
|
|
119
172
|
import { isAutoInjectable } from './_generated/work-topology.js';
|
|
120
|
-
export { parseTaskState, taskStateLines, taskStateName } from './_generated/task-state.js';
|
|
173
|
+
export { parseTaskState, taskStateLines, taskStateName, briefingTaskStateLines, STALE_TASK_STATE_HOURS } from './_generated/task-state.js';
|
|
174
|
+
// #360 — the one briefing-level policy, shared with the `briefing` tool via
|
|
175
|
+
// src/core/briefing-level.ts (this is the generated mirror; see that file).
|
|
176
|
+
export {
|
|
177
|
+
isBriefingLevel,
|
|
178
|
+
DEFAULT_BRIEFING_LEVEL,
|
|
179
|
+
briefingLevelPolicy,
|
|
180
|
+
sessionStartAppendsWorkPackageNotice,
|
|
181
|
+
} from './_generated/briefing-level.js';
|
|
182
|
+
import { resolveBriefingLevel as resolveBriefingLevelValue } from './_generated/briefing-level.js';
|
|
183
|
+
import { unreadInboxLinesFor } from './_generated/agent-message-inbox.js';
|
|
184
|
+
|
|
185
|
+
// The hook-only work-package notice's literal text — ONE declaration,
|
|
186
|
+
// exported so both `session-start.js` (which appends it) and the test
|
|
187
|
+
// suite (which needs to assert the hook's `full`-level remainder is
|
|
188
|
+
// EXACTLY this string, not a hardcoded second copy of it) read the same
|
|
189
|
+
// constant.
|
|
190
|
+
export const WORK_PACKAGE_NOTICE = 'Work packages: check work_package prepare for this project (digest or transcript). When available, offer a concise host-native interactive choice in the user’s conversation language: dispatch an agent task, later (defer not_now), or stop suggesting for this session. Never dispatch without the user choosing it. The Dashboard cannot dispatch agents, and no durable opt-out is implied.';
|
|
121
191
|
import {
|
|
122
192
|
indexedObservationText,
|
|
123
193
|
insertFtsRow,
|
|
124
194
|
joinIndexedObservations,
|
|
125
195
|
removeFromFts,
|
|
126
196
|
renderMatchExpression,
|
|
197
|
+
renderPhraseExpression,
|
|
127
198
|
tokenizeQuery,
|
|
128
199
|
} from './_generated/fts-index.js';
|
|
129
200
|
|
|
130
|
-
export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, canonicalRemoteLocator };
|
|
201
|
+
export { homeDir, memeshDir, getDbPath, getMemeshDirFromDbPath, getProjectName, redactSecrets, canonicalRemoteLocator, gitRepoRoot };
|
|
131
202
|
|
|
132
203
|
/**
|
|
133
204
|
* Resolve the package root from a hook file's `import.meta.url`.
|
|
@@ -181,10 +252,26 @@ export function importFromPluginRoot(pluginRoot, relativePath) {
|
|
|
181
252
|
return import(pathToFileURL(join(pluginRoot, relativePath)).href);
|
|
182
253
|
}
|
|
183
254
|
|
|
255
|
+
// A bounded, generic classification for "the config document itself could
|
|
256
|
+
// not be used" —
|
|
257
|
+
// deliberately NOT the raw file path, the JSON.parse error text, or any
|
|
258
|
+
// fragment of the file's own content (a parse error message can echo a
|
|
259
|
+
// slice of the source in some engines; this string never does). One
|
|
260
|
+
// constant, so the wording cannot drift between the outcome-record reason
|
|
261
|
+
// below and whatever a future second reader of this state might print.
|
|
262
|
+
// Kept here rather than reusing `src/core/config.ts`'s own message: that
|
|
263
|
+
// file's `warnUnreadable()` prints the real path and the parse-error
|
|
264
|
+
// detail, is stateful (dedupes repeated warnings), and is not a zero-import
|
|
265
|
+
// leaf this hook could import (the A1a/F5 boundary) — the wording below is
|
|
266
|
+
// independently chosen to describe the SAME state, not literally shared.
|
|
267
|
+
export const HOOK_CONFIG_UNREADABLE_REASON =
|
|
268
|
+
'config: config.json exists but could not be read as a settings object — using defaults until the file is fixed';
|
|
269
|
+
|
|
184
270
|
/**
|
|
185
|
-
* Read ~/.memesh/config.json directly
|
|
186
|
-
*
|
|
187
|
-
*
|
|
271
|
+
* Read ~/.memesh/config.json directly, with a classification of whether the
|
|
272
|
+
* document itself could be used at all. Hooks must not depend on dist/ (F5
|
|
273
|
+
* boundary), so this reads the JSON as a plain file rather than importing
|
|
274
|
+
* readConfig from src/core/config.ts.
|
|
188
275
|
*
|
|
189
276
|
* Always reads `~/.memesh/config.json` to stay consistent with
|
|
190
277
|
* `src/core/config.ts`, which is the single writer. Earlier versions
|
|
@@ -193,25 +280,49 @@ export function importFromPluginRoot(pluginRoot, relativePath) {
|
|
|
193
280
|
* would ignore `memesh config set autoCapture …` and friends. Fixed
|
|
194
281
|
* by treating the homedir path as the canonical source.
|
|
195
282
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
283
|
+
* `state` mirrors `src/core/config.ts`'s own `ConfigReadState` — same three
|
|
284
|
+
* values, same meaning — so a caller recording an outcome for one matches
|
|
285
|
+
* the wording a CLI/MCP caller would report for the other, without this
|
|
286
|
+
* file importing that one (see `HOOK_CONFIG_UNREADABLE_REASON`'s comment).
|
|
287
|
+
* `readHookConfig()` below is the pre-existing plain wrapper every current
|
|
288
|
+
* caller uses; this file's five internal readers were left untouched on
|
|
289
|
+
* purpose — only `session-start.js`'s malformed-config check needs `state`.
|
|
198
290
|
*
|
|
199
291
|
* @param {NodeJS.ProcessEnv} [_env=process.env] - kept for signature
|
|
200
292
|
* compatibility (env was the prior MEMESH_DB_PATH source); ignored.
|
|
201
|
-
* @returns {Record<string, any
|
|
293
|
+
* @returns {{ config: Record<string, any>, state: 'ok' | 'absent' | 'unreadable' }}
|
|
202
294
|
*/
|
|
203
|
-
export function
|
|
295
|
+
export function readHookConfigResult(_env = process.env) {
|
|
204
296
|
const path = join(memeshDir(), 'config.json');
|
|
205
|
-
if (!existsSync(path)) return {};
|
|
297
|
+
if (!existsSync(path)) return { config: {}, state: 'absent' };
|
|
206
298
|
try {
|
|
207
299
|
const raw = readFileSync(path, 'utf8');
|
|
208
300
|
const parsed = JSON.parse(raw);
|
|
209
|
-
|
|
301
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
302
|
+
return { config: parsed, state: 'ok' };
|
|
303
|
+
}
|
|
304
|
+
return { config: {}, state: 'unreadable' };
|
|
210
305
|
} catch {
|
|
211
|
-
return {};
|
|
306
|
+
return { config: {}, state: 'unreadable' };
|
|
212
307
|
}
|
|
213
308
|
}
|
|
214
309
|
|
|
310
|
+
/**
|
|
311
|
+
* The plain, backward-compatible reader every existing caller in this file
|
|
312
|
+
* uses (`isAutoCaptureEnabled`, `resolveSessionLimit`, `resolveAutoUpdatePolicy`,
|
|
313
|
+
* `resolveBriefingLevel`, `isUpdateCheckEnabled`) — returns an empty object
|
|
314
|
+
* on missing/unreadable/malformed file; callers
|
|
315
|
+
* must always be defensive about which fields are set. `readHookConfigResult()`
|
|
316
|
+
* above is the state-aware version for a caller that needs to know WHY the
|
|
317
|
+
* config came back empty, not just that it did.
|
|
318
|
+
*
|
|
319
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
320
|
+
* @returns {Record<string, any>}
|
|
321
|
+
*/
|
|
322
|
+
export function readHookConfig(env = process.env) {
|
|
323
|
+
return readHookConfigResult(env).config;
|
|
324
|
+
}
|
|
325
|
+
|
|
215
326
|
/**
|
|
216
327
|
* Resolve the auto-capture flag.
|
|
217
328
|
* Precedence: env > config > default(true).
|
|
@@ -254,6 +365,26 @@ export function resolveSessionLimit(env = process.env) {
|
|
|
254
365
|
return 10;
|
|
255
366
|
}
|
|
256
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Resolve the briefing level (#360). Precedence: env `MEMESH_BRIEFING` >
|
|
370
|
+
* config `briefing` > default. A present-but-invalid env value fails closed to
|
|
371
|
+
* the default without consulting config (unlike `resolveSessionLimit` above,
|
|
372
|
+
* which falls through to config).
|
|
373
|
+
* The validation and default live in the shared leaf (`resolveBriefingLevel`
|
|
374
|
+
* in `_generated/briefing-level.js`); this only supplies the two raw values —
|
|
375
|
+
* an unknown value on EITHER source is reported via `.invalid`, so the
|
|
376
|
+
* caller can record why the default was used instead of silently falling
|
|
377
|
+
* back (this repo treats a silent fallback as a defect).
|
|
378
|
+
* @param {NodeJS.ProcessEnv} [env=process.env]
|
|
379
|
+
* @param {Record<string, any>} [config=readHookConfig(env)] - the settings
|
|
380
|
+
* document, when the caller has already read it (and needs the read's
|
|
381
|
+
* `state` too), so config.json is read once.
|
|
382
|
+
* @returns {{level: 'minimal'|'standard'|'full', invalid: {source: 'env'|'config', value: string}|null}}
|
|
383
|
+
*/
|
|
384
|
+
export function resolveBriefingLevel(env = process.env, config = readHookConfig(env)) {
|
|
385
|
+
return resolveBriefingLevelValue(env.MEMESH_BRIEFING, config.briefing);
|
|
386
|
+
}
|
|
387
|
+
|
|
257
388
|
/**
|
|
258
389
|
* The tag every capture hook attaches to what it writes.
|
|
259
390
|
*
|
|
@@ -267,6 +398,26 @@ export function resolveSessionLimit(env = process.env) {
|
|
|
267
398
|
*/
|
|
268
399
|
export const AUTO_CAPTURE_TAG = 'source:auto-capture';
|
|
269
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Auto-captured SESSION-SNAPSHOT types — the transient rollups `pre-compact.js`
|
|
403
|
+
* and `session-summary.js` restate on every PreCompact / Stop, purely as
|
|
404
|
+
* session bookkeeping ("N tool calls", "compaction reason: auto"). Not the
|
|
405
|
+
* same set as `AUTO_CAPTURE_TAG`: that tag also marks `commit` entities,
|
|
406
|
+
* which can genuinely be about the file being edited, so it is too broad for
|
|
407
|
+
* this exclusion.
|
|
408
|
+
*
|
|
409
|
+
* `pre-edit-recall.js`'s Strategy 1 matches a `session-insight` entity for
|
|
410
|
+
* ANY file a session touched — `session-<id>-files`/`-fixes` carry a
|
|
411
|
+
* `file:<name>` tag per file, unconditionally — so without this exclusion it
|
|
412
|
+
* injects lines like "Session edited 1 file(s): X" for every edit of a file
|
|
413
|
+
* that session ever touched (#358). No list already in the codebase matches
|
|
414
|
+
* this pair alone: `EVIDENCE_LAYER_TYPES` (work-topology.ts), `NOISE_TYPES`
|
|
415
|
+
* (analytics.ts, lifecycle.ts) and `COMPACTABLE_TYPES` (dreamer.ts) all also
|
|
416
|
+
* include `commit` (and some include `session_keypoint`, `workflow_checkpoint`
|
|
417
|
+
* etc.), which this exclusion must NOT touch.
|
|
418
|
+
*/
|
|
419
|
+
export const SESSION_SNAPSHOT_TYPES = new Set(['session-insight', 'session-summary']);
|
|
420
|
+
|
|
270
421
|
const VALID_AUTO_UPDATE_POLICIES = new Set(['off', 'patch', 'minor', 'major']);
|
|
271
422
|
|
|
272
423
|
/**
|
|
@@ -353,6 +504,83 @@ import {
|
|
|
353
504
|
* `readOnly`) must apply the same cap themselves. */
|
|
354
505
|
export const HOOK_BUSY_TIMEOUT_MS = 2000;
|
|
355
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Who this session says it is, for the durable message inbox: the exact
|
|
509
|
+
* recipient id in `MEMESH_RECIPIENT`, or undefined when it is unset. A Claude
|
|
510
|
+
* Code session that was not started with the channel flag has no identity a
|
|
511
|
+
* sender could address, so without this it is never told a message is waiting.
|
|
512
|
+
* The id is compared exactly (after Unicode NFC, trimmed, 1-200 characters),
|
|
513
|
+
* the same rule the `message` tool applies to a recipient, so any id a sender
|
|
514
|
+
* can address can be declared here. It is shown JSON-quoted, which is what
|
|
515
|
+
* makes odd characters safe to print. An empty value counts as unset; one over
|
|
516
|
+
* 200 characters is ignored with a line on stderr rather than silently.
|
|
517
|
+
*/
|
|
518
|
+
export function resolveMessageRecipient(env = process.env) {
|
|
519
|
+
const raw = env.MEMESH_RECIPIENT;
|
|
520
|
+
if (raw === undefined) return undefined;
|
|
521
|
+
const id = String(raw).normalize('NFC').trim();
|
|
522
|
+
if (id === '') return undefined;
|
|
523
|
+
if (id.length > 200) {
|
|
524
|
+
try { process.stderr.write('[memesh] MEMESH_RECIPIENT ignored: a recipient id is at most 200 characters\n'); } catch { /* stderr gone */ }
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
return id;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* A failed inbox read is said twice: the full text on stderr, and one `error`
|
|
532
|
+
* outcome in the ledger through the calling hook's own recorder. Without the
|
|
533
|
+
* second, the ledger reads as a clean run and nothing afterwards can tell
|
|
534
|
+
* "no message was waiting" from "the inbox could not be read". The recorder
|
|
535
|
+
* gets the exception; it should persist a label, never the message (the
|
|
536
|
+
* ledger is permanent and exportable, see `hookErrorReason`).
|
|
537
|
+
*/
|
|
538
|
+
function inboxReadFailed(err, recordFailure) {
|
|
539
|
+
try { process.stderr.write(`[memesh] could not check for waiting messages: ${err?.message || err}\n`); } catch { /* stderr gone */ }
|
|
540
|
+
recordFailure?.(err);
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The reminder lines for `recipient` from an open database. Never throws
|
|
546
|
+
* itself: a reminder must not be the reason a prompt or a session start fails,
|
|
547
|
+
* and a failure that is not "nothing waiting" is said on stderr and handed to
|
|
548
|
+
* `recordFailure`, not swallowed. `recordFailure` must not throw either; both
|
|
549
|
+
* hooks pass one built on `recordHookOutcome`, which cannot.
|
|
550
|
+
*/
|
|
551
|
+
export function waitingMessageLines(db, recipient, recordFailure) {
|
|
552
|
+
if (!recipient) return [];
|
|
553
|
+
try {
|
|
554
|
+
return unreadInboxLinesFor(db, recipient);
|
|
555
|
+
} catch (err) {
|
|
556
|
+
return inboxReadFailed(err, recordFailure);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The reminder lines for messages waiting for this session's declared
|
|
562
|
+
* recipient. No `MEMESH_RECIPIENT` or no database: no lines, and the database
|
|
563
|
+
* is not opened. Read-only, and it never throws (given a `recordFailure` that
|
|
564
|
+
* does not, see `waitingMessageLines`).
|
|
565
|
+
*/
|
|
566
|
+
export function unreadMessageLines(env = process.env, recordFailure) {
|
|
567
|
+
const recipient = resolveMessageRecipient(env);
|
|
568
|
+
if (!recipient) return [];
|
|
569
|
+
const dbPath = env.MEMESH_DB_PATH ?? getDbPath();
|
|
570
|
+
if (!existsSync(dbPath)) return [];
|
|
571
|
+
let db;
|
|
572
|
+
try {
|
|
573
|
+
// `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling.
|
|
574
|
+
db = new MemeshDatabase(dbPath, { readOnly: true });
|
|
575
|
+
db.pragma(`busy_timeout = ${HOOK_BUSY_TIMEOUT_MS}`);
|
|
576
|
+
return waitingMessageLines(db, recipient, recordFailure);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
return inboxReadFailed(err, recordFailure);
|
|
579
|
+
} finally {
|
|
580
|
+
try { db?.close(); } catch { /* already closed */ }
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
356
584
|
export function openHookDb(env = process.env, opts = {}) {
|
|
357
585
|
|
|
358
586
|
// Path helpers read process.env directly (no-arg). The `env` parameter
|
|
@@ -454,8 +682,10 @@ function migrateHookDbToCurrent(db, opts) {
|
|
|
454
682
|
* BEFORE opening the database — and a correct nothing-to-do decision is a
|
|
455
683
|
* successful run that must stamp, or a user whose sessions are consistently
|
|
456
684
|
* short reads as "capture has stopped" in doctor within a day: the exact
|
|
457
|
-
* crying-wolf this table exists to end. Stop fires
|
|
458
|
-
* extra open+close
|
|
685
|
+
* crying-wolf this table exists to end. Stop fires at the end of EVERY turn
|
|
686
|
+
* (#322), so this extra open+close happens once per bailed turn, not once
|
|
687
|
+
* per session — still cheap enough next to the transcript read the bail
|
|
688
|
+
* already did to skip.
|
|
459
689
|
*
|
|
460
690
|
* Never throws: the heartbeat is diagnostics, and the bail it decorates was
|
|
461
691
|
* already a successful exit.
|
|
@@ -476,6 +706,186 @@ export function stampHookRunOnly(env, hook) {
|
|
|
476
706
|
}
|
|
477
707
|
}
|
|
478
708
|
|
|
709
|
+
const NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_NOFOLLOW : 0;
|
|
710
|
+
const APPEND_NOFOLLOW_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | NOFOLLOW;
|
|
711
|
+
const READ_NOFOLLOW_FLAGS = fsConstants.O_RDONLY | NOFOLLOW;
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* A plain `.slice(0, maxUnits)` on a string cuts by raw UTF-16 CODE UNIT,
|
|
715
|
+
* which can land between the two halves of a surrogate pair (an astral
|
|
716
|
+
* character — any emoji, for one) and leave a lone, unpaired surrogate at
|
|
717
|
+
* the very end — `"…".isWellFormed()` false, confirmed against a real cut landing on a
|
|
718
|
+
* pair straddling unit 200. `recordHookOutcome` below truncates every
|
|
719
|
+
* `reason`/`entity` this way, for every hook, on every call — most never
|
|
720
|
+
* hit this in practice (ASCII diagnostics), but nothing stops a future
|
|
721
|
+
* caller from passing through attacker- or import-controlled text that
|
|
722
|
+
* does. This is a MINIMAL, targeted fix: it only ever trims one
|
|
723
|
+
* ADDITIONAL character, only when the raw cut would otherwise split a
|
|
724
|
+
* pair, and never changes the ~200-unit cap other callers already rely on
|
|
725
|
+
* for anything else.
|
|
726
|
+
*/
|
|
727
|
+
export function sliceUtf16UnitsSurrogateSafe(s, maxUnits) {
|
|
728
|
+
if (s.length <= maxUnits) return s;
|
|
729
|
+
let end = maxUnits;
|
|
730
|
+
// Only back off when the last kept unit is a HIGH surrogate AND the very
|
|
731
|
+
// next unit (the one about to be cut off) is its matching LOW surrogate
|
|
732
|
+
// — i.e. only when the cut would split a REAL pair. A lone high
|
|
733
|
+
// surrogate that was already unpaired in the source string (no low
|
|
734
|
+
// surrogate follows it) is left exactly as it was; this function does
|
|
735
|
+
// not repair a source string that was already malformed, only avoid
|
|
736
|
+
// CREATING a new instance of that problem.
|
|
737
|
+
const lastKept = s.charCodeAt(end - 1);
|
|
738
|
+
const nextUnit = s.charCodeAt(end);
|
|
739
|
+
const lastKeptIsHighSurrogate = lastKept >= 0xd800 && lastKept <= 0xdbff;
|
|
740
|
+
const nextUnitIsLowSurrogate = nextUnit >= 0xdc00 && nextUnit <= 0xdfff;
|
|
741
|
+
if (lastKeptIsHighSurrogate && nextUnitIsLowSurrogate) end -= 1;
|
|
742
|
+
return s.slice(0, end);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Record what `hook` DID, on every exit path (issue #327).
|
|
747
|
+
*
|
|
748
|
+
* `recordHookRun` answers "did the hook execute"; this answers "and did it
|
|
749
|
+
* write anything, and if not, why not". The gap between those two questions
|
|
750
|
+
* is where two days of an empty graph hid: post-commit was executing on every
|
|
751
|
+
* Bash call and skipping every one of them, because the commits were made
|
|
752
|
+
* with `-q` and printed no line to match. From the outside that is
|
|
753
|
+
* indistinguishable from a hook broken by an upgrade.
|
|
754
|
+
*
|
|
755
|
+
* Contract, in the same spirit as `stampHookRunOnly`:
|
|
756
|
+
* - NEVER throws. Diagnostics must not take capture down with them.
|
|
757
|
+
* - NEVER writes to stdout. The hook output contract is a single JSON
|
|
758
|
+
* document or nothing at all; one stray line breaks both hosts.
|
|
759
|
+
* - APPENDS one line (O_APPEND), never read-modify-write. SessionStart,
|
|
760
|
+
* UserPromptSubmit and a PreToolUse hook fire inside the same second on
|
|
761
|
+
* a busy turn: three processes reading the same JSON document and
|
|
762
|
+
* writing back what each of them read means the last one wins and the
|
|
763
|
+
* other two records are gone — the concurrency that proves a session is
|
|
764
|
+
* busy would be the concurrency that erases the proof. An append has no
|
|
765
|
+
* read step to lose, and the OS orders the writes.
|
|
766
|
+
* - Rotation (keep each hook's last 20 records) is the only rewrite, and it goes
|
|
767
|
+
* through temp + rename so a reader sees the old complete file or the
|
|
768
|
+
* new one.
|
|
769
|
+
*
|
|
770
|
+
* @param {Record<string,string|undefined>} env
|
|
771
|
+
* `outcome` has four kinds, and the line between the first two is the whole
|
|
772
|
+
* point of the record: `wrote` means a MEMORY was stored, and nothing else —
|
|
773
|
+
* it is the numerator of the signal `memesh doctor` uses to answer "is memory
|
|
774
|
+
* capture still alive". `notified` is for a hook whose effect is text the user
|
|
775
|
+
* or the model sees: an injected context, a printed warning, a nudge. Six
|
|
776
|
+
* hooks recorded those as `wrote`, each with its own comment saying it was not
|
|
777
|
+
* a memory, and the answer to that question was inflated by all six.
|
|
778
|
+
*
|
|
779
|
+
* @param {{hook: string, outcome: 'wrote'|'notified'|'skipped'|'error', reason?: string, entity?: string, payload?: object}} info
|
|
780
|
+
*/
|
|
781
|
+
export function recordHookOutcome(env, { hook, outcome, reason, entity, payload }) {
|
|
782
|
+
try {
|
|
783
|
+
// getMemeshDirFromDbPath(), not memeshDir(): the record must sit beside
|
|
784
|
+
// the database it describes. A test (or a user) that points
|
|
785
|
+
// MEMESH_DB_PATH somewhere else would otherwise split the evidence — a
|
|
786
|
+
// graph in one directory, the liveness history of the hooks that filled
|
|
787
|
+
// it in another.
|
|
788
|
+
const dir = getMemeshDirFromDbPath();
|
|
789
|
+
ensurePrivateDir(dir);
|
|
790
|
+
const filePath = join(dir, HOOK_OUTCOMES_FILENAME);
|
|
791
|
+
const record = {
|
|
792
|
+
hook,
|
|
793
|
+
at: new Date().toISOString(),
|
|
794
|
+
host: detectHookHost(payload ?? null, env),
|
|
795
|
+
outcome,
|
|
796
|
+
};
|
|
797
|
+
// A hook's `reason` is, on the error path, the exception message — which
|
|
798
|
+
// may echo a credential a failed request or git command surfaced. Skip
|
|
799
|
+
// reasons are hard-coded literals and pass through unchanged, but the
|
|
800
|
+
// error ones are redacted before they persist: stderr is transient, this
|
|
801
|
+
// JSONL file is a permanent, exportable copy.
|
|
802
|
+
if (reason) record.reason = sliceUtf16UnitsSurrogateSafe(redactSecrets(String(reason)), 200);
|
|
803
|
+
if (entity) record.entity = sliceUtf16UnitsSurrogateSafe(redactSecrets(String(entity)), 200);
|
|
804
|
+
// One O_APPEND write of one line. `mode` applies only when the file is
|
|
805
|
+
// being created, which is the only moment the permission can be set
|
|
806
|
+
// without a second syscall on the hot path. O_NOFOLLOW: the directory
|
|
807
|
+
// can be a shared or repository path (MEMESH_DB_PATH), and a planted
|
|
808
|
+
// symlink named hook-outcomes.jsonl would otherwise turn every hook run
|
|
809
|
+
// into an append to a file of the planter's choosing. Windows has no
|
|
810
|
+
// O_NOFOLLOW (the constant is undefined there), so it contributes 0.
|
|
811
|
+
const fd = openSync(filePath, APPEND_NOFOLLOW_FLAGS, 0o600);
|
|
812
|
+
try {
|
|
813
|
+
writeSync(fd, serializeHookOutcome(record));
|
|
814
|
+
} finally {
|
|
815
|
+
closeSync(fd);
|
|
816
|
+
}
|
|
817
|
+
try { chmodSync(filePath, 0o600); } catch { /* best-effort hardening */ }
|
|
818
|
+
rotateHookOutcomes(filePath);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
try {
|
|
821
|
+
process.stderr.write(
|
|
822
|
+
`MeMesh: could not record the ${hook} hook outcome (${err?.message ?? err}). ` +
|
|
823
|
+
`Capture itself is unaffected, but 'memesh doctor' will under-report capture liveness.\n`,
|
|
824
|
+
);
|
|
825
|
+
} catch { /* stderr gone */ }
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* The `reason` an outer catch may persist for an exception: its `code` or
|
|
831
|
+
* class name, never its message.
|
|
832
|
+
*
|
|
833
|
+
* A message is not a label, it is a copy of whatever the failure echoed: a
|
|
834
|
+
* V8 JSON parse error quotes the payload it choked on, an execFileSync error
|
|
835
|
+
* carries git's stderr and absolute paths. stderr is transient; the outcome
|
|
836
|
+
* file is permanent, exportable, and rendered into doctor and a pasted issue.
|
|
837
|
+
* So the file gets `uncaught SyntaxError` / `uncaught ENOENT`, and the full
|
|
838
|
+
* text goes to stderr where the hook already writes it.
|
|
839
|
+
*
|
|
840
|
+
* @param {unknown} err
|
|
841
|
+
* @returns {string}
|
|
842
|
+
*/
|
|
843
|
+
export function hookErrorReason(err) {
|
|
844
|
+
const label = (value) => (typeof value === 'string' && /^[A-Za-z][\w-]{0,39}$/.test(value) ? value : null);
|
|
845
|
+
const e = err && typeof err === 'object' ? err : null;
|
|
846
|
+
return `uncaught ${label(e?.code) ?? label(e?.name) ?? 'error'}`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Keep the history bounded, without paying a read on every append.
|
|
851
|
+
*
|
|
852
|
+
* A line count would mean reading the file back on the hot path — the read
|
|
853
|
+
* step O_APPEND exists to remove. A `stat` is cheap, so size is the trigger
|
|
854
|
+
* and the trim is exact. The rewrite goes through temp + rename: a
|
|
855
|
+
* concurrent appender may lose ONE line to the swap, which is why the byte
|
|
856
|
+
* budget is far larger than the window any summary reads.
|
|
857
|
+
*/
|
|
858
|
+
function rotateHookOutcomes(filePath) {
|
|
859
|
+
let tmpPath = null;
|
|
860
|
+
try {
|
|
861
|
+
// One descriptor for both the size check and the read, so the file that
|
|
862
|
+
// was measured is the file that is read (a stat-then-open pair can be
|
|
863
|
+
// swapped in between). The size check is the hot path on every append
|
|
864
|
+
// and needs no random name.
|
|
865
|
+
let raw;
|
|
866
|
+
const fd = openSync(filePath, READ_NOFOLLOW_FLAGS);
|
|
867
|
+
try {
|
|
868
|
+
if (fstatSync(fd).size <= HOOK_OUTCOMES_ROTATE_BYTES) return;
|
|
869
|
+
raw = readFileSync(fd, 'utf8');
|
|
870
|
+
} finally {
|
|
871
|
+
closeSync(fd);
|
|
872
|
+
}
|
|
873
|
+
// An unpredictable name, created exclusively ('wx' = O_CREAT|O_EXCL,
|
|
874
|
+
// which refuses an existing path — a planted symlink included).
|
|
875
|
+
// `${pid}.tmp` was guessable, and the plain write followed whatever sat
|
|
876
|
+
// at that name.
|
|
877
|
+
tmpPath = `${filePath}.${randomBytes(8).toString('hex')}.tmp`;
|
|
878
|
+
const trimmed = trimHookOutcomeLines(raw);
|
|
879
|
+
writeFileSync(tmpPath, trimmed, { encoding: 'utf8', mode: PRIVATE_FILE_MODE, flag: 'wx' });
|
|
880
|
+
renameSync(tmpPath, filePath);
|
|
881
|
+
} catch (err) {
|
|
882
|
+
try { if (tmpPath && existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
|
|
883
|
+
try {
|
|
884
|
+
process.stderr.write(`[memesh hook-outcomes] rotation failed for ${filePath}: ${err?.message ?? err}\n`);
|
|
885
|
+
} catch { /* stderr gone */ }
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
479
889
|
/**
|
|
480
890
|
* Record that `hook` ran, right now.
|
|
481
891
|
*
|
|
@@ -534,6 +944,387 @@ export function hookMatchExpression(text) {
|
|
|
534
944
|
return renderMatchExpression(tokenizeQuery(text).slice(0, HOOK_MAX_QUERY_TERMS));
|
|
535
945
|
}
|
|
536
946
|
|
|
947
|
+
/**
|
|
948
|
+
* Build an FTS5 PHRASE expression the way `renderPhraseExpression()` does —
|
|
949
|
+
* the terms of `text`, adjacent and in order, instead of `hookMatchExpression`'s
|
|
950
|
+
* OR. `pre-edit-recall.js` uses this for an ASCII basename (extension
|
|
951
|
+
* included, e.g. "CLAUDE.md" → `"CLAUDE md"`) so a row that merely mentions
|
|
952
|
+
* ONE of a multi-word filename's words does not qualify as a match (#358).
|
|
953
|
+
*
|
|
954
|
+
* Not used for a non-ASCII basename: `text` there is bigram-segmented before
|
|
955
|
+
* this runs, and this function's caller keeps those on `hookMatchExpression`
|
|
956
|
+
* instead — see the comment at that call site.
|
|
957
|
+
*
|
|
958
|
+
* @returns the PHRASE expression, or null if there is nothing searchable
|
|
959
|
+
*/
|
|
960
|
+
export function hookPhraseExpression(text) {
|
|
961
|
+
return renderPhraseExpression(tokenizeQuery(text).slice(0, HOOK_MAX_QUERY_TERMS));
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Embedded in a longer identifier on the BEFORE side — "xCLAUDE.md" (a
|
|
965
|
+
// letter/digit/underscore/hyphen right before) or "foo.CLAUDE.md" (a dot
|
|
966
|
+
// right before, i.e. a different extension chain) are not a mention of
|
|
967
|
+
// THIS file.
|
|
968
|
+
//
|
|
969
|
+
// Deliberately ASCII-only — an explicit, tested decision rather than an
|
|
970
|
+
// incidental one: a non-ASCII letter directly before an ASCII basename —
|
|
971
|
+
// "設定CLAUDE.md" — is NOT treated as embedding, because this class does not
|
|
972
|
+
// match it, so that case falls through to the bare-mention return below and
|
|
973
|
+
// IS accepted. Widening this to `\p{L}` would reject it instead; that is a
|
|
974
|
+
// real, defensible alternative rule, just not the one shipped, and this
|
|
975
|
+
// comment plus its test are what make the choice a decision instead of an
|
|
976
|
+
// accident.
|
|
977
|
+
const FILENAME_EMBED_BEFORE = /[A-Za-z0-9_.-]/;
|
|
978
|
+
// A letter/digit/underscore/hyphen right after extends the same token
|
|
979
|
+
// ("CLAUDE.mdx", "CLAUDE.md_backup").
|
|
980
|
+
const FILENAME_EMBED_AFTER = /[A-Za-z0-9_-]/;
|
|
981
|
+
// What makes a `.` after the basename an appended extension rather than a
|
|
982
|
+
// sentence end: a letter or digit in ANY script ("CLAUDE.md.bak",
|
|
983
|
+
// "CLAUDE.md.備份"). Not ASCII-only like the two classes above: those
|
|
984
|
+
// protect CJK prose, which is written with no space around a filename, while
|
|
985
|
+
// a sentence that carries on with no space after its full stop is rare in
|
|
986
|
+
// any script and misreading one costs a missed recall, never a wrong line.
|
|
987
|
+
// Marks and invisible format characters (`\p{M}`, `\p{Cf}`: a combining
|
|
988
|
+
// accent, ZWJ, ZWSP) count too — "CLAUDE.md.\u200Dx" still names another
|
|
989
|
+
// file. Anything else after the dot (an emoji, a symbol) is read as the end
|
|
990
|
+
// of a sentence, and a fullwidth full stop "." is not a dot at all: both
|
|
991
|
+
// stay bare mentions, deliberately.
|
|
992
|
+
const FILENAME_EXTENSION_START = /[\p{L}\p{N}\p{M}\p{Cf}]/u;
|
|
993
|
+
// The same marks and format characters directly after the basename, with no
|
|
994
|
+
// dot ("CLAUDE.md\u200Bbak" reads as "CLAUDE.mdbak"): invisible or attached
|
|
995
|
+
// to the name, so part of a different name. Letters stay out of this class
|
|
996
|
+
// on purpose — CJK prose follows a filename with no space.
|
|
997
|
+
const FILENAME_EMBED_AFTER_INVISIBLE = /[\p{M}\p{Cf}]/u;
|
|
998
|
+
// Bidi marks, embeddings and isolates (LRM, RLM, ALM, LRE/RLE/PDF, LRI/RLI/
|
|
999
|
+
// FSI/PDI) are TRANSPARENT to the boundary check: right-to-left prose puts
|
|
1000
|
+
// them around an embedded Latin filename, so they must not count as part of
|
|
1001
|
+
// a longer name — but they are not delimiters either. They are skipped, and
|
|
1002
|
+
// the character after them decides, exactly as if they were not there:
|
|
1003
|
+
// "CLAUDE.md\u200Fقبل" confirms, "CLAUDE.md\u200Ebak" does not. The two
|
|
1004
|
+
// OVERRIDES (LRO U+202D, RLO U+202E) are not skipped: they reorder the text
|
|
1005
|
+
// that follows, which is how a name is spoofed, so they reject. This is the
|
|
1006
|
+
// AFTER side only; the BEFORE class is ASCII-only by its own decision above.
|
|
1007
|
+
const BIDI_TRANSPARENT = /[\u061C\u200E\u200F\u202A-\u202C\u2066-\u2069]/;
|
|
1008
|
+
/** Index of the first UTF-16 unit at or after `i` that is not a transparent bidi control (all of them are BMP). */
|
|
1009
|
+
function skipBidiTransparent(text, i) {
|
|
1010
|
+
while (i < text.length && BIDI_TRANSPARENT.test(text[i])) i++;
|
|
1011
|
+
return i;
|
|
1012
|
+
}
|
|
1013
|
+
/** The whole code point at `i`, or '' past the end. */
|
|
1014
|
+
function codePointAt(text, i) {
|
|
1015
|
+
return i < text.length ? String.fromCodePoint(text.codePointAt(i)) : '';
|
|
1016
|
+
}
|
|
1017
|
+
// A path token: what a backward walk from a `/` or `\` before the match
|
|
1018
|
+
// consumes as "part of the same path mention".
|
|
1019
|
+
//
|
|
1020
|
+
// Unicode-aware (`\p{L}\p{M}\p{N}`, the `u` flag): an ASCII-only class would
|
|
1021
|
+
// stop a backward walk through `文件/CLAUDE.md` right at the slash, producing
|
|
1022
|
+
// the bare token `/CLAUDE.md` and losing the directory name entirely. It
|
|
1023
|
+
// excludes `:` — see `pathMentionMatches`'s drive-letter handling below for
|
|
1024
|
+
// why that is a narrower, deliberate special case rather than a blanket
|
|
1025
|
+
// inclusion (a bare
|
|
1026
|
+
// `:` in the token class would swallow a `CLAUDE.md:12` line-number suffix
|
|
1027
|
+
// that sits AFTER a match, a completely different position, into what looks
|
|
1028
|
+
// like a path BEFORE the next one).
|
|
1029
|
+
//
|
|
1030
|
+
// `~` is in: it is an ordinary character inside a directory name, and
|
|
1031
|
+
// Windows' 8.3 short names put one mid-component (`C:\Users\RUNNER~1\...`,
|
|
1032
|
+
// the usual spelling of `%TEMP%`). Stopping the walk there cut such a mention
|
|
1033
|
+
// down to `1/.../CLAUDE.md`, a suffix of nothing, so a memory naming the exact
|
|
1034
|
+
// file was never recalled. A home-relative `~/docs/CLAUDE.md` is unaffected:
|
|
1035
|
+
// it was not a suffix of the edited path before and is not one now.
|
|
1036
|
+
//
|
|
1037
|
+
// The other side of the same change: a `~` glued to the front of a mention
|
|
1038
|
+
// is now part of it, so `x~docs/CLAUDE.md` is no longer read as
|
|
1039
|
+
// `docs/CLAUDE.md`, and `x~C:\repo\docs\CLAUDE.md` no longer gets its drive
|
|
1040
|
+
// letter spliced on (the splice requires a non-token character, or the start
|
|
1041
|
+
// of the text, before the letter). The first used to confirm because the walk
|
|
1042
|
+
// stopped at the `~`; the second because the drive-letter splice fired, which
|
|
1043
|
+
// it no longer does. Neither string is a path that names the edited file.
|
|
1044
|
+
const PATH_TOKEN_CHAR = /[\p{L}\p{M}\p{N}_.~\-/\\]/u;
|
|
1045
|
+
// A single ASCII drive letter immediately followed by `:` — the two
|
|
1046
|
+
// characters `pathMentionMatches` splices onto the front of a walked-back
|
|
1047
|
+
// token when they precede it exactly, so "C:\repo\...\CLAUDE.md" is not
|
|
1048
|
+
// truncated to "\repo\...\CLAUDE.md".
|
|
1049
|
+
const DRIVE_LETTER = /[A-Za-z]/;
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Confirm a candidate: does `text` literally contain `needle` as a whole
|
|
1053
|
+
* filename-shaped token?
|
|
1054
|
+
*
|
|
1055
|
+
* The FTS prefilter (`hookPhraseExpression`/`hookMatchExpression`) only
|
|
1056
|
+
* proves the text contains a TOKEN SEQUENCE shaped like the basename —
|
|
1057
|
+
* "05-CLAUDE-md.md" tokenizes to a "claude" token immediately followed by an
|
|
1058
|
+
* "md" token too, and prose like "claude-md", "CLAUDE_MD" or "the Claude MD
|
|
1059
|
+
* file" can pass a token-adjacency check while never containing the literal
|
|
1060
|
+
* string "CLAUDE.md" (#358). This is the confirmation step: does the ACTUAL
|
|
1061
|
+
* text contain that literal string, called on the small set of rows the
|
|
1062
|
+
* prefilter already narrowed down to (`entities_fts` is contentless — it can
|
|
1063
|
+
* only be MATCHed, never read from — so this runs over `entities.name` and
|
|
1064
|
+
* `observations.content`, fetched separately).
|
|
1065
|
+
*
|
|
1066
|
+
* Both sides are NFC-normalised — the same normalisation
|
|
1067
|
+
* `registerNfcFunction`/`memesh_nfc` (src/storage/fts-index.ts,
|
|
1068
|
+
* src/knowledge-graph.ts's archived-search branch) applies in SQL. This
|
|
1069
|
+
* reuses that same `String.prototype.normalize('NFC')` call in JS rather
|
|
1070
|
+
* than inventing a second normaliser; SQL is not an option here because the
|
|
1071
|
+
* confirmation runs over rows already fetched into JS, not a query.
|
|
1072
|
+
*
|
|
1073
|
+
* The contract this pins (by design — see docs/ARCHITECTURE.md
|
|
1074
|
+
* and CHANGELOG.md [Unreleased]): two spellings are the SAME name after NFC
|
|
1075
|
+
* exactly when they are CANONICALLY equivalent under Unicode — that covers
|
|
1076
|
+
* composed vs. decomposed accents ("café" vs. "café") AND the handful
|
|
1077
|
+
* of singleton canonical mappings, such as KELVIN SIGN U+212A → LATIN CAPITAL
|
|
1078
|
+
* LETTER K (U+004B) and ANGSTROM SIGN U+212B → LATIN CAPITAL LETTER A WITH
|
|
1079
|
+
* RING ABOVE (U+00C5, itself canonically "Å" = A + combining ring above).
|
|
1080
|
+
* `K.ts` (U+212A) confirming a mention of `K.ts` (ASCII) is this rule working
|
|
1081
|
+
* as designed, not a false positive — U+212A IS the letter K under canonical
|
|
1082
|
+
* equivalence, the same relationship that makes composed/decomposed "é" one
|
|
1083
|
+
* name. It is NOT special-cased away: a carve-out for one singleton mapping
|
|
1084
|
+
* would need a per-character exception table on this hot path and would
|
|
1085
|
+
* break the simplicity the "é" guarantee depends on. COMPATIBILITY
|
|
1086
|
+
* equivalents are deliberately NOT folded together — fullwidth "A" (U+FF21)
|
|
1087
|
+
* stays distinct from ASCII "A", and ligatures stay distinct from their
|
|
1088
|
+
* expansions — because this function normalises with NFC, not NFKC; NFKC
|
|
1089
|
+
* would additionally erase exactly those distinctions. Case folding (below)
|
|
1090
|
+
* runs AFTER normalisation and is ASCII `A-Z` only, so it does not reach
|
|
1091
|
+
* Turkish İ (U+0130) / ı (U+0131) or German ß (U+00DF) — none of those fold
|
|
1092
|
+
* to their naive ASCII lookalikes at any stage of this function.
|
|
1093
|
+
*
|
|
1094
|
+
* Case folding is ASCII-only and PER-CHARACTER, applied to both sides
|
|
1095
|
+
* unconditionally — never a locale/Unicode `.toLowerCase()`, and never gated
|
|
1096
|
+
* on the needle being ENTIRELY ASCII. An all-or-nothing gate (fold only when
|
|
1097
|
+
* `/^[\x00-\x7f]+$/` matches the whole needle) would skip folding for a
|
|
1098
|
+
* mixed-script basename (a non-ASCII stem with an ASCII extension, the
|
|
1099
|
+
* common case for any non-English filename), and "設定配置.TS" would fail to
|
|
1100
|
+
* confirm "設定配置.ts". An ASCII-only per-character fold has no such gate and
|
|
1101
|
+
* is a no-op on non-ASCII text (nothing in `\p{L}` outside `A-Za-z` has an
|
|
1102
|
+
* ASCII-fold mapping), so applying it unconditionally changes nothing for a
|
|
1103
|
+
* pure-CJK needle while handling the mixed-script one. `text.indexOf`, never
|
|
1104
|
+
* a RegExp built from `needle` — a basename with regex metacharacters
|
|
1105
|
+
* (`a+b(1).ts`, `[id].tsx`, `$types.d.ts`, `c++.md`) is matched literally,
|
|
1106
|
+
* not interpreted.
|
|
1107
|
+
*
|
|
1108
|
+
* A hit must also sit on a filename boundary:
|
|
1109
|
+
* - AFTER: rejected when the next character is `~` (a common backup-file
|
|
1110
|
+
* suffix, "CLAUDE.md~"), is `.` immediately followed by a letter or
|
|
1111
|
+
* digit in any script, a mark or a format character
|
|
1112
|
+
* ("CLAUDE.md.bak", "CLAUDE.md.備份"), or is `/` or `\`
|
|
1113
|
+
* ("docs/CLAUDE.md/" and "docs/CLAUDE.md/subfile" name a DIRECTORY
|
|
1114
|
+
* called CLAUDE.md, or a file inside it, never the edited file itself,
|
|
1115
|
+
* the same way `pathMentionMatches` below already treats a `/`-prefixed
|
|
1116
|
+
* mention as a path rather than a bare basename) — a bare trailing `.`,
|
|
1117
|
+
* `)`, `,`, `#`, `?`, backtick or end-of-text is a sentence ending (or a
|
|
1118
|
+
* URL fragment/query string, which still names the SAME file — a
|
|
1119
|
+
* fragment/query does not change which file a path points at), not more
|
|
1120
|
+
* of the filename, and still passes. A letter/digit/underscore/hyphen
|
|
1121
|
+
* immediately after also rejects ("CLAUDE.mdx").
|
|
1122
|
+
* - BEFORE: a letter/digit/underscore/dot/hyphen immediately before
|
|
1123
|
+
* rejects (embedded in a longer identifier or extension chain) —
|
|
1124
|
+
* ASCII-only, deliberately: a non-ASCII letter directly before an ASCII
|
|
1125
|
+
* basename ("設定CLAUDE.md") is NOT a boundary-breaker and IS accepted
|
|
1126
|
+
* as a bare mention (an explicit, tested decision;
|
|
1127
|
+
* see `FILENAME_EMBED_BEFORE`'s own comment for why this is a stated
|
|
1128
|
+
* decision, not an oversight). Start-of-text/whitespace/punctuation
|
|
1129
|
+
* before it also passes as a bare mention. When the character
|
|
1130
|
+
* immediately before is `/` or `\`, the mention is a PATH, not a bare
|
|
1131
|
+
* basename ("docs/CLAUDE.md" while editing a DIFFERENT file must not
|
|
1132
|
+
* count just because the basename matches) — see `pathMentionMatches`.
|
|
1133
|
+
* A DIFFERENT stated decision, same shape: the
|
|
1134
|
+
* character that must precede a PATH-style mention is any non-path-token
|
|
1135
|
+
* character — a delimiter, not a script boundary. CJK prose with no
|
|
1136
|
+
* delimiter directly before a path ("請看文件/CLAUDE.md", "please see
|
|
1137
|
+
* 文件/CLAUDE.md" with no space) is consumed into the path token by the
|
|
1138
|
+
* same Unicode-aware walk that correctly keeps a real CJK directory name
|
|
1139
|
+
* intact, and the resulting token is not a suffix of
|
|
1140
|
+
* the edited path — a per-mention false negative, not a per-memory one:
|
|
1141
|
+
* the same text is still reachable through any other bare or delimited
|
|
1142
|
+
* mention it contains. An emoji (or any other non-path-token character)
|
|
1143
|
+
* immediately before the same prose DOES delimit it correctly
|
|
1144
|
+
* ("📁文件/CLAUDE.md" matches). No heuristic script-boundary splitting is
|
|
1145
|
+
* applied — that would need per-script tables on this hot path, the same
|
|
1146
|
+
* reasoning that keeps the NFC-vs-NFKC boundary above a flat rule.
|
|
1147
|
+
*
|
|
1148
|
+
* @param {string} text
|
|
1149
|
+
* @param {string} needle - the full basename to confirm, every script,
|
|
1150
|
+
* ASCII or not (never the extension-less stem).
|
|
1151
|
+
* @param {{relPath: string | null, absPath: string, absPathAsGiven?: string} | null} [editedPath] -
|
|
1152
|
+
* the edited file's own path(s), forward-slash-normalised, for the PATH
|
|
1153
|
+
* branch above. `relPath` is relative to the file's repo root, or `null`
|
|
1154
|
+
* when there is no repo root (or resolving it escaped the root — see the
|
|
1155
|
+
* hook's own comment). `absPath` is the CANONICAL (realpath'd) absolute
|
|
1156
|
+
* path; `absPathAsGiven`, when different, is the absolute path built from
|
|
1157
|
+
* the directory AS THE PAYLOAD NAMED IT, before resolving any symlink —
|
|
1158
|
+
* both are checked, so a memory can name either form of a symlinked
|
|
1159
|
+
* location THE EDIT PAYLOAD ITSELF USED and both match (e.g. macOS
|
|
1160
|
+
* `/var/...` vs its canonical `/private/var/...`).
|
|
1161
|
+
* This is ONE-WAY, stated precisely, not the symmetric claim it might read
|
|
1162
|
+
* as: `absPathAsGiven` only exists when the
|
|
1163
|
+
* PAYLOAD's own as-given form differs from canonical — when the payload
|
|
1164
|
+
* is already canonical, there is no alias candidate at all, so a memory
|
|
1165
|
+
* naming an alias the payload never used does NOT match. Resolving an
|
|
1166
|
+
* alias mentioned only in memory text would need a filesystem call per
|
|
1167
|
+
* mention, which this hot path deliberately does not make; such a memory
|
|
1168
|
+
* remains reachable through a relative or bare mention instead. Omit only
|
|
1169
|
+
* when no reliable path info exists at all; a PATH-style mention then
|
|
1170
|
+
* cannot be verified and is rejected, while a bare mention is unaffected.
|
|
1171
|
+
*/
|
|
1172
|
+
export function containsFileNameLiterally(text, needle, editedPath = null) {
|
|
1173
|
+
if (!text || !needle) return false;
|
|
1174
|
+
const normalizedNeedle = needle.normalize('NFC');
|
|
1175
|
+
if (normalizedNeedle.length === 0) return false;
|
|
1176
|
+
const normalizedText = text.normalize('NFC');
|
|
1177
|
+
const haystack = foldAsciiCase(normalizedText);
|
|
1178
|
+
const target = foldAsciiCase(normalizedNeedle);
|
|
1179
|
+
let from = 0;
|
|
1180
|
+
for (;;) {
|
|
1181
|
+
const idx = haystack.indexOf(target, from);
|
|
1182
|
+
if (idx === -1) return false;
|
|
1183
|
+
const matchEnd = idx + target.length;
|
|
1184
|
+
const before = idx > 0 ? haystack[idx - 1] : '';
|
|
1185
|
+
// Whole code points, not UTF-16 units: half of a surrogate pair is never
|
|
1186
|
+
// `\p{L}`/`\p{Cf}`, so an astral letter or format character would slip
|
|
1187
|
+
// through. Transparent bidi controls are stepped over first.
|
|
1188
|
+
const afterAt = skipBidiTransparent(haystack, matchEnd);
|
|
1189
|
+
const after = codePointAt(haystack, afterAt);
|
|
1190
|
+
const afterNext = after === '.'
|
|
1191
|
+
? codePointAt(haystack, skipBidiTransparent(haystack, afterAt + 1)) : '';
|
|
1192
|
+
|
|
1193
|
+
const afterRejects = FILENAME_EMBED_AFTER.test(after) ||
|
|
1194
|
+
FILENAME_EMBED_AFTER_INVISIBLE.test(after) || after === '~' ||
|
|
1195
|
+
after === '/' || after === '\\' ||
|
|
1196
|
+
(after === '.' && FILENAME_EXTENSION_START.test(afterNext));
|
|
1197
|
+
if (afterRejects) { from = idx + 1; continue; }
|
|
1198
|
+
|
|
1199
|
+
if (before === '/' || before === '\\') {
|
|
1200
|
+
// NOT `haystack` (that copy is ASCII-folded in its ENTIRETY, which
|
|
1201
|
+
// would fold every directory component too).
|
|
1202
|
+
// `normalizedText` is NFC-normalised but un-folded, so the directory
|
|
1203
|
+
// portion of whatever token gets walked out of it keeps its real
|
|
1204
|
+
// case; `pathMentionMatches` folds only the final path segment (and a
|
|
1205
|
+
// Windows drive letter) itself. `foldAsciiCase` is a 1:1, length- and
|
|
1206
|
+
// position-preserving per-character map, so `idx`/`matchEnd` (computed
|
|
1207
|
+
// against the folded `haystack`) are valid indices into `normalizedText`
|
|
1208
|
+
// too — same positions, just the original casing at each one.
|
|
1209
|
+
if (pathMentionMatches(normalizedText, idx, matchEnd, editedPath)) return true;
|
|
1210
|
+
from = idx + 1;
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
if (FILENAME_EMBED_BEFORE.test(before)) { from = idx + 1; continue; }
|
|
1214
|
+
|
|
1215
|
+
return true; // bare mention: start of text, whitespace, quote, paren, ...
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Fold ONLY the ASCII letters `A-Z` to `a-z`; every other character —
|
|
1221
|
+
* digits, punctuation, separators, and every non-ASCII script — passes
|
|
1222
|
+
* through unchanged. This is the literal reading of
|
|
1223
|
+
* "ASCII case-insensitive": per character, not "only when the whole string
|
|
1224
|
+
* happens to be pure ASCII". `String.prototype.toLowerCase()` is
|
|
1225
|
+
* deliberately not used here — it is locale/Unicode-aware and can fold (or,
|
|
1226
|
+
* for some scripts under some engines, even change the length of) text this
|
|
1227
|
+
* function has no business touching; an explicit ASCII-only replace cannot.
|
|
1228
|
+
*/
|
|
1229
|
+
function foldAsciiCase(s) {
|
|
1230
|
+
return s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Fold ONLY the final path segment (the basename) of a forward-slash path,
|
|
1235
|
+
* plus a leading single-letter Windows drive (`C:` → `c:`) when present —
|
|
1236
|
+
* every directory component in between is returned UNCHANGED. The
|
|
1237
|
+
* case-insensitivity rule this whole file documents is
|
|
1238
|
+
* scoped to "the full basename", not "every directory component of a path
|
|
1239
|
+
* mention" — a directory's case sensitivity depends on the volume, which
|
|
1240
|
+
* this function has no filesystem call to ask (and does not make one: an
|
|
1241
|
+
* empty result beats a wrong one on this hot path). So `docs/CLAUDE.md` and
|
|
1242
|
+
* `DOCS/CLAUDE.md` are DIFFERENT tokens here on purpose, while
|
|
1243
|
+
* `docs/CLAUDE.md` and `docs/claude.MD` are the same one.
|
|
1244
|
+
*/
|
|
1245
|
+
function foldFinalPathSegment(p) {
|
|
1246
|
+
// A single ASCII letter + `:` at the start is a drive, whether or not a
|
|
1247
|
+
// `/` immediately follows — `C:/repo/CLAUDE.md` (absolute) and
|
|
1248
|
+
// `a:docs/CLAUDE.md` (drive-relative) both qualify; requiring the `/`
|
|
1249
|
+
// would leave the drive-relative form's own drive letter un-folded (it
|
|
1250
|
+
// would still compare unequal either way, since nothing else about a
|
|
1251
|
+
// drive-relative token matches an absolute candidate, but the gap would be
|
|
1252
|
+
// unexplained rather than a real boundary).
|
|
1253
|
+
const drive = /^([A-Za-z]):(.*)$/.exec(p);
|
|
1254
|
+
const prefix = drive ? `${foldAsciiCase(drive[1])}:` : '';
|
|
1255
|
+
const rest = drive ? drive[2] : p;
|
|
1256
|
+
const lastSlash = rest.lastIndexOf('/');
|
|
1257
|
+
if (lastSlash === -1) return prefix + foldAsciiCase(rest);
|
|
1258
|
+
return prefix + rest.slice(0, lastSlash + 1) + foldAsciiCase(rest.slice(lastSlash + 1));
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Is the PATH mentioned right before this match (idx-1 is `/` or `\`) a
|
|
1263
|
+
* reference to the SAME file being edited?
|
|
1264
|
+
*
|
|
1265
|
+
* Walks back from the match through the whole path-shaped token (Unicode
|
|
1266
|
+
* letters/marks/digits included — `文件/CLAUDE.md` must not lose `文件` to
|
|
1267
|
+
* an ASCII-only scan), and, when a single ASCII drive letter and `:` sit
|
|
1268
|
+
* immediately before where the walk stopped, splices them onto the front
|
|
1269
|
+
* too (`:` itself stays OUT of `PATH_TOKEN_CHAR`, or the walk would swallow a
|
|
1270
|
+
* `CLAUDE.md:12` line-number suffix that sits AFTER a match into what looks
|
|
1271
|
+
* like a path BEFORE the next one; this is a narrow, position-specific
|
|
1272
|
+
* splice, not a general inclusion). Normalises the token (backslash to
|
|
1273
|
+
* forward slash, THEN strip a leading `./` — in that order, or a
|
|
1274
|
+
* Windows-style `.\CLAUDE.md` mention would keep its `.\` un-stripped and
|
|
1275
|
+
* never compare equal), and accepts it only if it equals, or is a
|
|
1276
|
+
* path-segment-aligned suffix of, the edited file's own relative path,
|
|
1277
|
+
* canonical absolute path, or as-given absolute path (checked in that
|
|
1278
|
+
* order — an ABSOLUTE mention naturally cannot suffix-match a relative
|
|
1279
|
+
* path but can equal or suffix one of the absolute ones; no separate
|
|
1280
|
+
* branch needed, and the drive-letter case above falls out of the same
|
|
1281
|
+
* absolute-path comparison once the token carries its own drive letter).
|
|
1282
|
+
*
|
|
1283
|
+
* `text` is NFC-normalised but NOT ASCII-folded — the caller passes the
|
|
1284
|
+
* un-folded copy on purpose. Only the basename
|
|
1285
|
+
* (the full filename, extension included) is documented as ASCII
|
|
1286
|
+
* case-insensitive; a directory component is not, because its actual case
|
|
1287
|
+
* sensitivity depends on the volume, which this function cannot ask
|
|
1288
|
+
* without a filesystem call. `foldFinalPathSegment` (above) folds only the
|
|
1289
|
+
* final path segment and any Windows drive letter on BOTH the extracted
|
|
1290
|
+
* token and each `editedPath` candidate, leaving every directory component
|
|
1291
|
+
* as originally written on both sides — so `docs/CLAUDE.md` vs
|
|
1292
|
+
* `DOCS/CLAUDE.md` compares unequal (a deliberate false negative — the
|
|
1293
|
+
* memory is still reachable by a relative or bare mention), while
|
|
1294
|
+
* `docs/CLAUDE.md` vs `docs/claude.MD` still compares equal.
|
|
1295
|
+
*
|
|
1296
|
+
* A token containing a `..` segment is rejected outright — this function has
|
|
1297
|
+
* no way to resolve it without knowing the mention's OWN base directory,
|
|
1298
|
+
* and guessing which file it would resolve to is worse than declining.
|
|
1299
|
+
*/
|
|
1300
|
+
function pathMentionMatches(text, matchStart, matchEnd, editedPath) {
|
|
1301
|
+
if (!editedPath) return false;
|
|
1302
|
+
let start = matchStart;
|
|
1303
|
+
while (start > 0 && PATH_TOKEN_CHAR.test(text[start - 1])) start--;
|
|
1304
|
+
// Windows drive letter: "C:" immediately precedes where the walk stopped.
|
|
1305
|
+
if (
|
|
1306
|
+
start >= 2 &&
|
|
1307
|
+
text[start - 1] === ':' &&
|
|
1308
|
+
DRIVE_LETTER.test(text[start - 2]) &&
|
|
1309
|
+
(start < 3 || !PATH_TOKEN_CHAR.test(text[start - 3]))
|
|
1310
|
+
) {
|
|
1311
|
+
start -= 2;
|
|
1312
|
+
}
|
|
1313
|
+
let token = text.slice(start, matchEnd).replace(/\\/g, '/');
|
|
1314
|
+
if (token.split('/').includes('..')) return false;
|
|
1315
|
+
while (token.startsWith('./')) token = token.slice(2);
|
|
1316
|
+
if (token.length === 0) return false;
|
|
1317
|
+
token = foldFinalPathSegment(token);
|
|
1318
|
+
|
|
1319
|
+
const candidates = [editedPath.relPath, editedPath.absPath, editedPath.absPathAsGiven];
|
|
1320
|
+
for (let candidate of candidates) {
|
|
1321
|
+
if (candidate == null) continue;
|
|
1322
|
+
candidate = foldFinalPathSegment(candidate);
|
|
1323
|
+
if (candidate === token || candidate.endsWith(`/${token}`)) return true;
|
|
1324
|
+
}
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
537
1328
|
|
|
538
1329
|
|
|
539
1330
|
// Title cap + truncation live in src/core/title.ts, executed here via the
|
|
@@ -560,14 +1351,20 @@ export { truncateTitle } from './_generated/title.js';
|
|
|
560
1351
|
* hooks stay a cheap always-on capture path and core owns later enrichment.
|
|
561
1352
|
*
|
|
562
1353
|
* @param {import('./_generated/sqlite.js').MemeshDatabase} db - an open hook DB handle
|
|
563
|
-
* @param {{name: string, type: string, observations?: string[], tags?: string[], title?: string | null, metadata?: Record<string, unknown
|
|
1354
|
+
* @param {{name: string, type: string, observations?: string[], tags?: string[], title?: string | null, metadata?: Record<string, unknown>, replace?: boolean}} entity
|
|
564
1355
|
* `metadata` is extra INSERT-only metadata (e.g. post-commit's session_id +
|
|
565
1356
|
* files). It cannot override the provenance/title_source stamps below, and
|
|
566
1357
|
* an OR IGNORE re-capture of an existing entity leaves it untouched — same
|
|
567
|
-
* first-writer-wins rule provenance already follows.
|
|
568
|
-
*
|
|
1358
|
+
* first-writer-wins rule provenance already follows. `replace` (#322)
|
|
1359
|
+
* restates the entity's observations and tags instead of adding to them —
|
|
1360
|
+
* for a caller whose entity is a per-turn SNAPSHOT, not an accumulating log.
|
|
1361
|
+
* Unlike `remember({ replace: true })` in core, this is a HARD delete: no
|
|
1362
|
+
* `replaced_history` is kept (see the comment at the DELETE below for why).
|
|
1363
|
+
* @returns {{ id: number, isNew: boolean, archived?: true } | null} null if the row
|
|
1364
|
+
* could not be resolved; `archived: true` if `replace` was requested on an
|
|
1365
|
+
* entity `forget` archived — nothing was written, by design
|
|
569
1366
|
*/
|
|
570
|
-
export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata }) {
|
|
1367
|
+
export function captureEntity(db, { name, type, observations = [], tags = [], title, metadata, replace = false }) {
|
|
571
1368
|
// One transaction, because this function performs six writes that only
|
|
572
1369
|
// mean anything together: the entity row, its observations, its tags, and
|
|
573
1370
|
// the contentless-FTS delete + insert that make them findable.
|
|
@@ -586,10 +1383,10 @@ export function captureEntity(db, { name, type, observations = [], tags = [], ti
|
|
|
586
1383
|
// could not be resolved. `observationsWritten` may be lower than
|
|
587
1384
|
// `observations.length`: an observation whose exact content is already on
|
|
588
1385
|
// the entity is not stored again (see the dedupe in captureEntityInner).
|
|
589
|
-
return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata }))();
|
|
1386
|
+
return db.transaction(() => captureEntityInner(db, { name, type, observations, tags, title, metadata, replace }))();
|
|
590
1387
|
}
|
|
591
1388
|
|
|
592
|
-
function captureEntityInner(db, { name, type, observations, tags, title, metadata }) {
|
|
1389
|
+
function captureEntityInner(db, { name, type, observations, tags, title, metadata, replace }) {
|
|
593
1390
|
// source_host provenance: these hooks only ever run under Claude Code (they
|
|
594
1391
|
// are wired into ~/.claude/settings.json), so a hook-captured entity is by
|
|
595
1392
|
// definition a claude-code capture. Stamped only on the INSERT — an OR
|
|
@@ -606,10 +1403,36 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
606
1403
|
.prepare('INSERT OR IGNORE INTO entities (name, type, metadata, title) VALUES (?, ?, ?, ?)')
|
|
607
1404
|
.run(name, type, JSON.stringify(insertMetadata), title ?? null);
|
|
608
1405
|
const isNew = insertResult.changes > 0;
|
|
609
|
-
const row = db.prepare('SELECT id, title FROM entities WHERE name = ?').get(name);
|
|
1406
|
+
const row = db.prepare('SELECT id, title, status, metadata FROM entities WHERE name = ?').get(name);
|
|
610
1407
|
if (!row) return null;
|
|
611
1408
|
const id = row.id;
|
|
612
1409
|
|
|
1410
|
+
// `replace` never touches an archived entity. src/core/operations.ts's
|
|
1411
|
+
// `remember({ replace: true })` REFUSES this case with a thrown error —
|
|
1412
|
+
// right for a rare, interactive call the user reads the response of, but
|
|
1413
|
+
// a hook must never throw (it would abort the OTHER two entities' writes
|
|
1414
|
+
// this Stop, and crash risk is exactly what this file exists to avoid).
|
|
1415
|
+
// So the hook path degrades to a silent no-op instead: the archived row,
|
|
1416
|
+
// its observations and its FTS absence are all left exactly as `forget`
|
|
1417
|
+
// left them.
|
|
1418
|
+
//
|
|
1419
|
+
// Without this, a whole-entity `forget` (archiveEntity: status flipped to
|
|
1420
|
+
// 'archived', its row removed from entities_fts) would come undone on the
|
|
1421
|
+
// next Stop — `replace` would overwrite the preserved observations with a
|
|
1422
|
+
// fresh derivation from the transcript and reinsert the entity into
|
|
1423
|
+
// entities_fts, un-hiding it from FTS keyword search even though its
|
|
1424
|
+
// status stays 'archived' (recall's default query filters status='active',
|
|
1425
|
+
// which caps but does not close that exposure). `removeFromFts` guards its
|
|
1426
|
+
// own delete on a rowid COUNT, so calling it on an already-removed row is
|
|
1427
|
+
// a safe no-op either way — this check is about not losing the user's
|
|
1428
|
+
// forgotten content, not about a contentless-FTS5 delete failure.
|
|
1429
|
+
//
|
|
1430
|
+
// Observation-level corrections remain active and are filtered below;
|
|
1431
|
+
// this branch preserves the separate whole-entity archive contract.
|
|
1432
|
+
if (replace && !isNew && row.status === 'archived') {
|
|
1433
|
+
return { id, isNew: false, archived: true };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
613
1436
|
// Title update on an EXISTING entity — INSERT OR IGNORE never touches
|
|
614
1437
|
// `title` when the row already exists, so mirror knowledge-graph.ts's
|
|
615
1438
|
// createEntity(): only an explicit, actually-different value writes
|
|
@@ -655,6 +1478,38 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
655
1478
|
// BY + the one join rule), via the generated fts-index copy.
|
|
656
1479
|
const prevObsText = isNew ? undefined : indexedObservationText(db, id);
|
|
657
1480
|
|
|
1481
|
+
// `replace`: the caller is restating the whole entity, not adding to it.
|
|
1482
|
+
//
|
|
1483
|
+
// Appending is right for a `commit-<sha>` or a `pre-compact-<id>`, where
|
|
1484
|
+
// each capture is a new fact about the same subject. It is wrong for a
|
|
1485
|
+
// session insight, whose three entities are a SNAPSHOT of one session: Stop
|
|
1486
|
+
// fires at the end of every turn, so appending stored the same sentences
|
|
1487
|
+
// over and over (measured: 56 observations, 16 unique) and the workaround —
|
|
1488
|
+
// capture once, then skip — froze a two-day session's memory at its first
|
|
1489
|
+
// turn (#322). Replacing is the third answer: the snapshot is rewritten, so
|
|
1490
|
+
// it is neither duplicated nor stale.
|
|
1491
|
+
//
|
|
1492
|
+
// The old rows go AFTER `prevObsText` was read above, so the contentless-FTS
|
|
1493
|
+
// delete still matches exactly what was indexed. The re-insert below must
|
|
1494
|
+
// then leave that text out.
|
|
1495
|
+
//
|
|
1496
|
+
// This is a HARD delete — no history kept. That is a deliberate difference
|
|
1497
|
+
// from `remember({ replace: true })` in src/core/operations.ts, which files
|
|
1498
|
+
// the old text into `metadata.replaced_history` before overwriting: that
|
|
1499
|
+
// path is a rare, user-invoked correction, where an audit trail is worth
|
|
1500
|
+
// the bytes. This path fires on every Stop, every turn, for a session that
|
|
1501
|
+
// can run for hours — keeping history here would mean growing metadata on
|
|
1502
|
+
// every single turn for content nobody asks to undo.
|
|
1503
|
+
if (replace && !isNew) {
|
|
1504
|
+
db.prepare('DELETE FROM observations WHERE entity_id = ?').run(id);
|
|
1505
|
+
// Tags get the same treatment, for the same reason: "restating the whole
|
|
1506
|
+
// entity" was true for observations and FTS but not for tags until this
|
|
1507
|
+
// line — a session-<id>-files entity that stopped mentioning file A kept
|
|
1508
|
+
// answering `file:a.ts` lookups (pre-edit-recall's Strategy 1) for a
|
|
1509
|
+
// snapshot that no longer said anything about that file.
|
|
1510
|
+
db.prepare('DELETE FROM tags WHERE entity_id = ?').run(id);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
658
1513
|
// Never store the same sentence twice on one entity (#240, widened).
|
|
659
1514
|
//
|
|
660
1515
|
// #240 was fixed in session-summary.js alone, with an EXISTENCE guard: "if
|
|
@@ -683,12 +1538,18 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
683
1538
|
// the "database disk image is malformed" failure this file warns about
|
|
684
1539
|
// above. The `seen` set also collapses repeats WITHIN one call.
|
|
685
1540
|
const seen = new Set(
|
|
686
|
-
|
|
1541
|
+
// `|| replace`: the rows a plain SELECT would find here were just
|
|
1542
|
+
// DELETEd above (same transaction), so this skips a query that would
|
|
1543
|
+
// only ever come back empty — not a second dedup path.
|
|
1544
|
+
isNew || replace
|
|
687
1545
|
? []
|
|
688
1546
|
: db.prepare('SELECT content FROM observations WHERE entity_id = ?').all(id).map((r) => r.content),
|
|
689
1547
|
);
|
|
690
1548
|
const freshObservations = [];
|
|
1549
|
+
const forgotten = parseEntityMetadata(row.metadata)?.forgotten_observation_hashes;
|
|
1550
|
+
const excluded = new Set(replace && Array.isArray(forgotten) ? forgotten : []);
|
|
691
1551
|
for (const obs of observations) {
|
|
1552
|
+
if (excluded.has(createHash('sha256').update(obs).digest('hex'))) continue;
|
|
692
1553
|
if (seen.has(obs)) continue;
|
|
693
1554
|
seen.add(obs);
|
|
694
1555
|
freshObservations.push(obs);
|
|
@@ -711,7 +1572,9 @@ function captureEntityInner(db, { name, type, observations, tags, title, metadat
|
|
|
711
1572
|
// Stop/PreCompact/PostToolUse capture, and the re-read grew with an
|
|
712
1573
|
// upserted entity's accumulated observation count.
|
|
713
1574
|
const obsParts = [];
|
|
714
|
-
|
|
1575
|
+
// Not after a `replace`: those rows were deleted above, and carrying their
|
|
1576
|
+
// text forward would index words the entity no longer holds.
|
|
1577
|
+
if (prevObsText && !replace) obsParts.push(prevObsText);
|
|
715
1578
|
if (freshObservations.length) obsParts.push(joinIndexedObservations(freshObservations));
|
|
716
1579
|
const allObsText = joinIndexedObservations(obsParts);
|
|
717
1580
|
// Current title is fully determined by the branches above — no re-read.
|
|
@@ -903,6 +1766,23 @@ export function finalizeUpdatePromptClaim(sessionId, currentVersion, latestVersi
|
|
|
903
1766
|
}
|
|
904
1767
|
}
|
|
905
1768
|
|
|
1769
|
+
/**
|
|
1770
|
+
* The owner answered this session's notice. After this, further words in the
|
|
1771
|
+
* same session ("no" to an unrelated question, a stray "never") are not
|
|
1772
|
+
* decisions about updates. Returns false when there was no claim to mark.
|
|
1773
|
+
*/
|
|
1774
|
+
export function markUpdatePromptAnswered(sessionId, currentVersion, latestVersion, decision) {
|
|
1775
|
+
const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
|
|
1776
|
+
if (!path || !existsSync(path)) return false;
|
|
1777
|
+
try {
|
|
1778
|
+
const value = JSON.parse(readFileSync(path, 'utf8'));
|
|
1779
|
+
writePrivateJson(path, { ...value, decision: 'answered', answer: decision, answeredAt: new Date().toISOString() });
|
|
1780
|
+
return true;
|
|
1781
|
+
} catch {
|
|
1782
|
+
return false;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
906
1786
|
export function readUpdatePromptClaim(sessionId, currentVersion, latestVersion) {
|
|
907
1787
|
const path = updatePromptClaimPath(sessionId, currentVersion, latestVersion);
|
|
908
1788
|
if (!path || !existsSync(path)) return null;
|
|
@@ -968,14 +1848,30 @@ export function writeAutoUpdateConsent(sessionId, currentVersion, latestVersion,
|
|
|
968
1848
|
}
|
|
969
1849
|
}
|
|
970
1850
|
|
|
1851
|
+
/**
|
|
1852
|
+
* Owner's answer to the first-use update notice.
|
|
1853
|
+
* 'approved' — install (npm-global only; other channels have no in-session installer)
|
|
1854
|
+
* 'declined' — "Not now": snooze this target (24h, then 48h, then 7d)
|
|
1855
|
+
* 'never' — "Never ask again": config.updateCheck = false
|
|
1856
|
+
*/
|
|
971
1857
|
export function parseAutoUpdateConsent(prompt) {
|
|
972
1858
|
if (typeof prompt !== 'string') return null;
|
|
973
1859
|
const value = prompt.trim().toLowerCase().replace(/[.!?。!?]+$/u, '');
|
|
1860
|
+
// "never" alone is NOT enough: it is an ordinary English word, and this
|
|
1861
|
+
// hook sees every prompt in a session that was shown the notice. The
|
|
1862
|
+
// phrases below are the ones the Settings hint tells users to type, in
|
|
1863
|
+
// each of the 11 dashboard locales.
|
|
1864
|
+
if (/^(?:never ask(?: me)?(?: again)?|don'?t ask(?: me)?(?: again)?|stop asking|不要再問|別再問|不再提醒|不要再问|今後は確認しない|다시 묻지 않기|não voltar a perguntar|ne plus demander|nicht mehr fragen|không hỏi lại|no volver a preguntar|ไม่ต้องถามอีก)$/.test(value)) return 'never';
|
|
974
1865
|
if (/^(?:yes|y|upgrade|update|install(?: it)?|go ahead|是|好|升級|更新|安裝)$/.test(value)) return 'approved';
|
|
975
|
-
if (/^(?:no|n|not now|later
|
|
1866
|
+
if (/^(?:no|n|not now|later|不要|不用|稍後|暫時不要|稍后|後で|나중에|agora não|pas maintenant|jetzt nicht|để sau|ahora no|ไว้ก่อน)$/.test(value)) return 'declined';
|
|
976
1867
|
return null;
|
|
977
1868
|
}
|
|
978
1869
|
|
|
1870
|
+
/** `config.updateCheck` — false means the owner said never ask again. */
|
|
1871
|
+
export function isUpdateCheckEnabled(env = process.env) {
|
|
1872
|
+
return readHookConfig(env).updateCheck !== false;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
979
1875
|
export function decideAutoUpdateHook(currentVersion, cache, policy) {
|
|
980
1876
|
if (!cache || cache.currentVersion !== currentVersion) return { run: false };
|
|
981
1877
|
const latest = cache.latestVersion;
|