@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
|
@@ -10,22 +10,29 @@
|
|
|
10
10
|
// about to repeat. Guards are NOT throttled — a dangerous edit is
|
|
11
11
|
// dangerous every time.
|
|
12
12
|
|
|
13
|
-
import { basename, join } from 'path';
|
|
14
|
-
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path';
|
|
14
|
+
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
15
15
|
import {
|
|
16
16
|
buildReferenceContext,
|
|
17
|
+
containsFileNameLiterally,
|
|
17
18
|
ensurePrivateDir,
|
|
18
19
|
getDbPath,
|
|
19
20
|
getMemeshDirFromDbPath,
|
|
20
21
|
getProjectName,
|
|
22
|
+
gitRepoRoot,
|
|
21
23
|
HOOK_BUSY_TIMEOUT_MS,
|
|
22
24
|
isTrustedForAutoContext,
|
|
23
25
|
writePrivateJson,
|
|
24
26
|
hookMatchExpression,
|
|
27
|
+
hookPhraseExpression,
|
|
25
28
|
loadActiveGuards,
|
|
26
29
|
matchingGuards,
|
|
27
30
|
guardWarningLines,
|
|
28
31
|
recordGuardFires,
|
|
32
|
+
hookErrorReason,
|
|
33
|
+
SKIP_REASONS,
|
|
34
|
+
SESSION_SNAPSHOT_TYPES,
|
|
35
|
+
recordHookOutcome,
|
|
29
36
|
} from './_shared.js';
|
|
30
37
|
import { MemeshDatabase } from './_generated/sqlite.js';
|
|
31
38
|
|
|
@@ -34,16 +41,40 @@ const memeshDir = getMemeshDirFromDbPath();
|
|
|
34
41
|
const THROTTLE_FILE = join(memeshDir, 'session-recalled-files.json');
|
|
35
42
|
const MAX_RESULTS = 3;
|
|
36
43
|
|
|
44
|
+
// #358 round 3 item 2: Strategy 2 fetches this many CANDIDATES before literal
|
|
45
|
+
// confirmation narrows them, then cuts to MAX_RESULTS. Confirmation cannot
|
|
46
|
+
// run inside SQL (it reads observations text, not just an index), so a
|
|
47
|
+
// candidate LIMIT that is too tight can starve it: 9 candidates that all
|
|
48
|
+
// fail confirmation hid a 10th, genuinely-matching row entirely (reported,
|
|
49
|
+
// reproduced through the real hook). The window costs one indexed
|
|
50
|
+
// `observations` lookup per candidate (idx_observations_entity); what the
|
|
51
|
+
// query costs is set by how many rows match, not by this LIMIT (#366).
|
|
52
|
+
const CANDIDATE_WINDOW = 50;
|
|
53
|
+
|
|
54
|
+
// #358 AC1: auto-captured session-snapshot rows never qualify as a RECALL
|
|
55
|
+
// match, in either strategy below — see SESSION_SNAPSHOT_TYPES for why.
|
|
56
|
+
const SESSION_SNAPSHOT_TYPE_LIST = [...SESSION_SNAPSHOT_TYPES];
|
|
57
|
+
const SESSION_SNAPSHOT_EXCLUSION_SQL =
|
|
58
|
+
`AND e.type NOT IN (${SESSION_SNAPSHOT_TYPE_LIST.map(() => '?').join(',')})`;
|
|
59
|
+
|
|
37
60
|
let input = '';
|
|
61
|
+
// See post-commit.js for why every exit path leaves a record (#327).
|
|
62
|
+
let payload = null;
|
|
63
|
+
function record(outcome, reason, entity) {
|
|
64
|
+
recordHookOutcome(process.env, { hook: 'pre-edit-recall', outcome, reason, entity, payload });
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
process.stdin.setEncoding('utf8');
|
|
39
68
|
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
40
69
|
process.stdin.on('end', () => {
|
|
41
70
|
try {
|
|
42
71
|
const data = JSON.parse(input);
|
|
72
|
+
payload = data;
|
|
43
73
|
if (!data.tool_input) {
|
|
44
74
|
// Schema-flip signal — Claude Code has renamed `tool_input` for
|
|
45
75
|
// transcript blocks before. Trace so the rename surfaces day-1.
|
|
46
76
|
try { process.stderr.write(`[memesh pre-edit-recall] tool_input absent (keys: ${Object.keys(data).join(',')}); skipping\n`); } catch {}
|
|
77
|
+
record('skipped', SKIP_REASONS.toolInputAbsent);
|
|
47
78
|
return pass();
|
|
48
79
|
}
|
|
49
80
|
const toolInput = data.tool_input;
|
|
@@ -51,8 +82,11 @@ process.stdin.on('end', () => {
|
|
|
51
82
|
|
|
52
83
|
// Only process if we have a file path
|
|
53
84
|
if (!filePath || typeof filePath !== 'string') {
|
|
85
|
+
record('skipped', SKIP_REASONS.noFilePath);
|
|
54
86
|
return pass();
|
|
55
87
|
}
|
|
88
|
+
const fileName = basename(filePath);
|
|
89
|
+
const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
|
|
56
90
|
|
|
57
91
|
// Throttle — the RECALL half runs once per file per session. The guard
|
|
58
92
|
// half is deliberately outside it, so a throttled call still opens the
|
|
@@ -69,15 +103,105 @@ process.stdin.on('end', () => {
|
|
|
69
103
|
}
|
|
70
104
|
const throttled = seenFiles.includes(fileKey);
|
|
71
105
|
|
|
72
|
-
if (!existsSync(dbPath))
|
|
106
|
+
if (!existsSync(dbPath)) {
|
|
107
|
+
record('skipped', SKIP_REASONS.noDatabaseForRecall);
|
|
108
|
+
return pass();
|
|
109
|
+
}
|
|
73
110
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
111
|
+
// Project used to scope RECALL — derived from the EDITED FILE's own
|
|
112
|
+
// location, not cwd (#358 AC3): a file living in another repository
|
|
113
|
+
// (this machine's `~/.claude` checkout, say, edited while cwd is some
|
|
114
|
+
// other project) used to have every one of ITS OWN project-tagged
|
|
115
|
+
// memories filtered out by a project tag it never carried, leaving only
|
|
116
|
+
// whatever cwd's unrelated project happened to contain. Falls back to
|
|
117
|
+
// cwd's project when the file's directory is not inside a git repo, or
|
|
118
|
+
// when resolving it throws: a plain-directory identity for a scratch /
|
|
119
|
+
// non-repo path is a worse proxy for "this file's project" than the
|
|
120
|
+
// session's own project context — which is the behaviour every edit had
|
|
121
|
+
// before this fix, so a non-repo file keeps it. When the file's project
|
|
122
|
+
// equals cwd's project (the common case: editing inside the session's
|
|
123
|
+
// own repo) this resolves to the same tag as before.
|
|
124
|
+
//
|
|
125
|
+
// Deliberately NOT widened to a global namespace or an "also search
|
|
126
|
+
// cwd's project" OR — out of scope for #358 and its own source of
|
|
127
|
+
// cross-project noise.
|
|
128
|
+
//
|
|
129
|
+
// Measured with a PATH-shim `git` wrapper against three fixtures (#358
|
|
130
|
+
// round 7), current code vs. HEAD (no per-file project scoping) run
|
|
131
|
+
// against the same three fixtures the same way:
|
|
132
|
+
// - repo with a remote: 2 now (gitRepoRoot's `rev-parse
|
|
133
|
+
// --show-toplevel`, then getProjectName's own `config --get
|
|
134
|
+
// remote.origin.url`, which finds the remote and stops) vs. 1 at
|
|
135
|
+
// HEAD (`getProjectName(cwd)` alone, the remote check only).
|
|
136
|
+
// - non-repo fallback: 3 now (gitRepoRoot's call finds no repo, so
|
|
137
|
+
// getProjectName runs again on cwd instead: `config --get
|
|
138
|
+
// remote.origin.url` then `rev-parse --show-toplevel`, both empty)
|
|
139
|
+
// vs. 2 at HEAD (the same two, minus gitRepoRoot's own call).
|
|
140
|
+
// - repo with no remote: 4 now (the with-remote two, plus
|
|
141
|
+
// getProjectName's own fallback `rev-parse --show-toplevel` and
|
|
142
|
+
// `rev-parse --git-common-dir` once the remote check is empty) vs.
|
|
143
|
+
// 3 at HEAD (the same three, minus gitRepoRoot's own call).
|
|
144
|
+
// Adds exactly one git process per unthrottled edit in every case. Each
|
|
145
|
+
// is capped at 2 s (`tryGit`), so where git itself stalls the hook's
|
|
146
|
+
// 5-second timeout is reached one call sooner than before (#366). The
|
|
147
|
+
// throttled path (every repeat edit of the same file) pays for none of it.
|
|
148
|
+
// Guards are global by design: a mistake recorded in one project is
|
|
149
|
+
// usually a mistake everywhere (the pipe-eats-exit-code shape), and the
|
|
150
|
+
// warning names its
|
|
151
|
+
// source lesson either way.
|
|
152
|
+
let projectName = null;
|
|
153
|
+
// The edited file's own path, forward-slash-normalised, for the literal
|
|
154
|
+
// confirmation's path-suffix check (#358 round 3 item 3b) — `relPath` is
|
|
155
|
+
// relative to the file's repo root, `absPath` is always set. `null`
|
|
156
|
+
// (throttled, or the try below threw) means Strategy 2 never runs, so no
|
|
157
|
+
// path info is ever needed.
|
|
158
|
+
let editedPath = null;
|
|
159
|
+
if (!throttled) {
|
|
160
|
+
try {
|
|
161
|
+
const rawDir = dirname(filePath);
|
|
162
|
+
const absoluteFileDir = isAbsolute(rawDir) ? rawDir : resolve(data.cwd || process.cwd(), rawDir);
|
|
163
|
+
// #358 F3: realpath the nearest EXISTING ancestor before asking for
|
|
164
|
+
// its project. A symlinked directory can make the git-plumbing
|
|
165
|
+
// identity resolution key off the symlink's own path instead of the
|
|
166
|
+
// real repository; feeding it an already-resolved directory
|
|
167
|
+
// sidesteps that without touching `resolveProjectIdentity` itself.
|
|
168
|
+
const { dir: fileDir, tail: missingTail } = realpathNearestExisting(absoluteFileDir);
|
|
169
|
+
// One call serves both project scoping and the path-suffix check
|
|
170
|
+
// below.
|
|
171
|
+
const repoRoot = gitRepoRoot(fileDir);
|
|
172
|
+
projectName = repoRoot ? getProjectName(fileDir) : getProjectName(data.cwd);
|
|
173
|
+
|
|
174
|
+
// `fileDir` is only the nearest ancestor that EXISTS — right for
|
|
175
|
+
// project identity, wrong as the file's own directory. A `Write`
|
|
176
|
+
// into `docs/new/deep/` would otherwise be checked as `docs/<file>`,
|
|
177
|
+
// a different, real file, and admit memories about it. The
|
|
178
|
+
// components the walk dropped go back on.
|
|
179
|
+
const resolvedFilePath = join(fileDir, missingTail, fileName);
|
|
180
|
+
const absPath = resolvedFilePath.split(sep).join('/');
|
|
181
|
+
// #358 round 4 finding 5: `fileDir` is realpath'd (F3, above), so a
|
|
182
|
+
// symlinked ancestor (macOS `/var` -> `/private/var`) makes `absPath`
|
|
183
|
+
// the CANONICAL form only — a memory naming the AS-GIVEN form (the
|
|
184
|
+
// symlink itself, e.g. `/var/...`) would then never match, even
|
|
185
|
+
// though it is the exact same file. `absoluteFileDir` (already in
|
|
186
|
+
// scope, computed above BEFORE realpath) gives that form for free —
|
|
187
|
+
// no extra filesystem call, just a string join, so this costs
|
|
188
|
+
// nothing extra on the hot path per candidate.
|
|
189
|
+
const asGivenPath = join(absoluteFileDir, fileName).split(sep).join('/');
|
|
190
|
+
const absPathAsGiven = asGivenPath === absPath ? undefined : asGivenPath;
|
|
191
|
+
let relPath = null;
|
|
192
|
+
if (repoRoot) {
|
|
193
|
+
const rel = relative(repoRoot, resolvedFilePath).split(sep).join('/');
|
|
194
|
+
// A `..`-leading result means the file is not under the root git
|
|
195
|
+
// reported (two spellings of one directory that realpath did not
|
|
196
|
+
// reconcile). That is not "this file's path within its repo" —
|
|
197
|
+
// treat it as unavailable rather than match against it.
|
|
198
|
+
if (!rel.startsWith('..')) relPath = rel;
|
|
199
|
+
}
|
|
200
|
+
editedPath = { relPath, absPath, absPathAsGiven };
|
|
201
|
+
} catch {
|
|
202
|
+
projectName = getProjectName(data.cwd);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
81
205
|
|
|
82
206
|
// `readOnly`, not `readonly`: node:sqlite ignores the lowercase spelling
|
|
83
207
|
// and hands back a WRITABLE handle. This hook only reads; the guard
|
|
@@ -102,10 +226,21 @@ process.stdin.on('end', () => {
|
|
|
102
226
|
db.prepare('SELECT 1').get();
|
|
103
227
|
} catch {
|
|
104
228
|
db.close();
|
|
229
|
+
record('error', 'the database stayed locked past the hook busy timeout');
|
|
105
230
|
return pass();
|
|
106
231
|
}
|
|
107
232
|
let guardMatches = [];
|
|
108
233
|
const recallLines = [];
|
|
234
|
+
// #358 round 3 item 2: true when Strategy 2's candidate query returned
|
|
235
|
+
// exactly CANDIDATE_WINDOW rows — evidence that MORE candidates may
|
|
236
|
+
// exist past what was fetched, so a subsequent "nothing to recall" would
|
|
237
|
+
// be a claim this run cannot back. Declared here (not inside the block
|
|
238
|
+
// that sets it) so the final skip-reason decision below can read it.
|
|
239
|
+
let candidateWindowTruncated = false;
|
|
240
|
+
// A fault while reading `observations` (confirmation or the display
|
|
241
|
+
// snippet). Held, not thrown: the guard half was evaluated first and its
|
|
242
|
+
// warning must still be emitted; the run is then recorded as an `error`.
|
|
243
|
+
let recallFault = null;
|
|
109
244
|
try {
|
|
110
245
|
// Guard pass (G1) — matched against the path plus the content about
|
|
111
246
|
// to land, which is what the mistake would be made OF.
|
|
@@ -134,69 +269,138 @@ process.stdin.on('end', () => {
|
|
|
134
269
|
.some(c => c.name === 'status');
|
|
135
270
|
const statusFilter = hasStatus ? "AND e.status = 'active'" : '';
|
|
136
271
|
|
|
137
|
-
// Search strategies
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
const fileNameNoExt = fileName.replace(/\.[^.]+$/, '');
|
|
143
|
-
|
|
272
|
+
// Search strategies (fileName/fileNameNoExt computed above, before
|
|
273
|
+
// the throttle check, so the project-resolution block can use them
|
|
274
|
+
// too):
|
|
275
|
+
// 1. Entities tagged with the exact full basename (e.g., "file:auth.ts")
|
|
276
|
+
// 2. FTS5 search on the basename, confirmed by a literal-text check
|
|
144
277
|
const results = [];
|
|
145
278
|
|
|
146
|
-
// Strategy 1: Tag-based search
|
|
279
|
+
// Strategy 1: Tag-based search — the EXACT full basename tag only
|
|
280
|
+
// (#358 AC2 round 2). The producer (session-insight.ts) writes
|
|
281
|
+
// BOTH `file:<full>` and `file:<stem>` per file, so matching the
|
|
282
|
+
// stem form too meant editing `auth.ts` matched a memory tagged
|
|
283
|
+
// only `file:auth` for a DIFFERENT file (`auth.py`, `auth.rs`,
|
|
284
|
+
// ...) — the stem is not unique to this file, the full basename is.
|
|
285
|
+
// When the edited file has no extension, `fileNameNoExt ===
|
|
286
|
+
// fileName`, so this already covers that case without a second arm.
|
|
287
|
+
//
|
|
147
288
|
// CRITICAL: Filter by project to prevent cross-project memory injection
|
|
289
|
+
// CRITICAL: Exclude auto-captured session-snapshot rows (#358 AC1) —
|
|
290
|
+
// session-insight's `-files`/`-fixes` entities carry a `file:<name>`
|
|
291
|
+
// tag for every file the session touched, so without this they
|
|
292
|
+
// satisfy this exact-tag strategy for any file ever edited that
|
|
293
|
+
// session. `commit` stays eligible: a commit can genuinely be about
|
|
294
|
+
// the file being edited.
|
|
148
295
|
const projectTag = `project:${projectName}`;
|
|
149
296
|
const tagResults = db.prepare(`
|
|
150
297
|
SELECT DISTINCT e.id, e.name, e.type, e.metadata
|
|
151
298
|
FROM entities e
|
|
152
299
|
JOIN tags t1 ON t1.entity_id = e.id
|
|
153
300
|
JOIN tags t2 ON t2.entity_id = e.id
|
|
154
|
-
WHERE
|
|
301
|
+
WHERE t1.tag = ?
|
|
155
302
|
AND t2.tag = ?
|
|
303
|
+
${SESSION_SNAPSHOT_EXCLUSION_SQL}
|
|
156
304
|
${statusFilter}
|
|
157
305
|
LIMIT ?
|
|
158
|
-
`).all(
|
|
306
|
+
`).all(
|
|
307
|
+
`file:${fileName}`, projectTag,
|
|
308
|
+
...SESSION_SNAPSHOT_TYPE_LIST, MAX_RESULTS * 3
|
|
309
|
+
);
|
|
159
310
|
results.push(...tagResults.filter((row) => isTrustedForAutoContext(row.metadata)));
|
|
160
311
|
|
|
161
312
|
// Strategy 2: FTS5 search on file name (if not enough results)
|
|
162
313
|
// CRITICAL: Filter by project to prevent cross-project memory injection
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
314
|
+
// CRITICAL: Exclude auto-captured session-snapshot rows (#358 AC1) —
|
|
315
|
+
// same reasoning as Strategy 1; their observation prose routinely
|
|
316
|
+
// NAMES other files the session touched, so an unqualified FTS
|
|
317
|
+
// match reaches them even without a matching tag.
|
|
318
|
+
//
|
|
319
|
+
// No minimum-length gate on the stem (#358 round 3 item 4 — this
|
|
320
|
+
// used to require `fileNameNoExt.length >= 4`, which made `設定.ts`,
|
|
321
|
+
// `c++.md` and `*.ts` unreachable even with an exact literal
|
|
322
|
+
// mention). Literal confirmation below is what keeps a short or
|
|
323
|
+
// symbol-heavy name safe now, not query length; the sole remaining
|
|
324
|
+
// gate is `hookPhraseExpression`/`hookMatchExpression` returning
|
|
325
|
+
// `null` when the basename tokenises to nothing at all to search
|
|
326
|
+
// for (handled where `matchExpr` is built, same as before).
|
|
327
|
+
if (hasFts && results.length < MAX_RESULTS) {
|
|
328
|
+
// An ASCII basename gets a PHRASE query — every one of its words,
|
|
329
|
+
// extension included, adjacent and in order ("CLAUDE.md" ->
|
|
330
|
+
// `"CLAUDE md"`). A non-ASCII basename (CJK, Thai,
|
|
331
|
+
// decomposed-Unicode, ...) keeps the OR-of-bigrams path instead:
|
|
332
|
+
// `hookMatchExpression` pre-segments such text into overlapping
|
|
333
|
+
// bigrams (built by the same function core uses, so this query
|
|
334
|
+
// asks for the tokens the index actually holds — quoting the raw
|
|
335
|
+
// basename instead meant a CJK or decomposed-Unicode filename
|
|
336
|
+
// matched nothing at all against the segmented index, and the
|
|
337
|
+
// catch below made that invisible), and a phrase built from an
|
|
338
|
+
// arbitrary subset of those bigrams is not the same question a
|
|
339
|
+
// phrase is meant to answer.
|
|
340
|
+
//
|
|
341
|
+
// Neither query proves the text is actually ABOUT this file —
|
|
342
|
+
// both are CANDIDATE GENERATORS, confirmed below. A phrase hit is
|
|
343
|
+
// not a filename hit: "05-CLAUDE-md.md" tokenizes to a "claude"
|
|
344
|
+
// token immediately followed by "md", and prose like
|
|
345
|
+
// "claude-md", "CLAUDE_MD" or "the Claude MD file" all satisfy a
|
|
346
|
+
// token-adjacency check without ever containing the literal
|
|
347
|
+
// string "CLAUDE.md" (#358 round 2). A stem that tokenizes to
|
|
348
|
+
// nothing is the same failure from the other side: editing
|
|
349
|
+
// `----.ts` degenerates the phrase to the single term `"ts"`,
|
|
350
|
+
// which then matches ANY text that merely mentions "ts".
|
|
351
|
+
// The full ASCII range (0x00-0x7F) is what "ASCII" means here,
|
|
352
|
+
// not a stray control character left in by mistake.
|
|
353
|
+
// eslint-disable-next-line no-control-regex
|
|
354
|
+
const isAsciiBasename = /^[\x00-\x7f]*$/.test(fileNameNoExt);
|
|
355
|
+
// The candidate generator is allowed to search on the STEM only
|
|
356
|
+
// (both paths above do, for the ASCII path only via the phrase
|
|
357
|
+
// still including the extension token) — but confirmation ALWAYS
|
|
358
|
+
// requires the FULL basename, extension included, for EVERY
|
|
359
|
+
// script (#358 round 3 item 1). A stem-only generator match is
|
|
360
|
+
// still just a candidate: editing `設定配置.ts`, a memory saying
|
|
361
|
+
// "See 設定配置.py before editing" or "See 設定配置 before
|
|
362
|
+
// editing" both satisfy the non-ASCII candidate generator (same
|
|
363
|
+
// stem bigrams) but neither literally names THIS file — the
|
|
364
|
+
// round-2 version confirmed only the stem and injected both.
|
|
365
|
+
const confirmNeedle = fileName;
|
|
366
|
+
const matchExpr = isAsciiBasename
|
|
367
|
+
? hookPhraseExpression(fileName)
|
|
368
|
+
: hookMatchExpression(fileNameNoExt);
|
|
369
|
+
let ftsResults = [];
|
|
169
370
|
try {
|
|
170
|
-
// ORDER BY rank
|
|
371
|
+
// ORDER BY rank still matters: literal confirmation runs AFTER
|
|
372
|
+
// this query's own LIMIT, so when more rows match the
|
|
373
|
+
// prefilter than the LIMIT allows, rank decides which subset
|
|
374
|
+
// is even offered to the confirmation step. `hookMatchExpression`
|
|
375
|
+
// emits `"knowledge" OR "graph"` for `knowledge-graph.ts` —
|
|
376
|
+
// necessary, because a CJK basename has to be reachable by its
|
|
377
|
+
// bigrams — which makes the match set large and unranked
|
|
378
|
+
// selection IS the result: editing that file in a project
|
|
379
|
+
// whose memories merely mention "graph" injected whatever the
|
|
380
|
+
// scan happened to reach first, where the old code correctly
|
|
381
|
+
// injected nothing. BM25 is what makes the OR safe; without it
|
|
382
|
+
// the fix trades a CJK miss for an ASCII false hit.
|
|
171
383
|
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
// editing that file in a project whose memories merely mention
|
|
179
|
-
// "graph" injected whatever the scan happened to reach first, where
|
|
180
|
-
// the old code correctly injected nothing. BM25 is what makes the OR
|
|
181
|
-
// safe; without it the fix trades a CJK miss for an ASCII false hit.
|
|
182
|
-
const ftsResults = matchExpr === null ? [] : db.prepare(`
|
|
384
|
+
// LIMIT is CANDIDATE_WINDOW, not a small multiple of the slots
|
|
385
|
+
// still open (#358 round 3 item 2): literal confirmation below
|
|
386
|
+
// can reject any of these, and a tight LIMIT applied BEFORE
|
|
387
|
+
// confirmation can starve it — 9 candidates that all fail
|
|
388
|
+
// confirmation hid a 10th, genuinely-matching row entirely.
|
|
389
|
+
ftsResults = matchExpr === null ? [] : db.prepare(`
|
|
183
390
|
SELECT DISTINCT e.id, e.name, e.type, e.metadata
|
|
184
391
|
FROM entities e
|
|
185
392
|
JOIN entities_fts fts ON fts.rowid = e.id
|
|
186
393
|
JOIN tags t ON t.entity_id = e.id
|
|
187
394
|
WHERE entities_fts MATCH ?
|
|
188
395
|
AND t.tag = ?
|
|
396
|
+
${SESSION_SNAPSHOT_EXCLUSION_SQL}
|
|
189
397
|
${statusFilter}
|
|
190
398
|
ORDER BY fts.rank, e.id DESC
|
|
191
399
|
LIMIT ?
|
|
192
|
-
`).all(
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (!results.some(existing => existing.id === r.id)) {
|
|
197
|
-
results.push(r);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
400
|
+
`).all(
|
|
401
|
+
matchExpr, projectTag,
|
|
402
|
+
...SESSION_SNAPSHOT_TYPE_LIST, CANDIDATE_WINDOW
|
|
403
|
+
);
|
|
200
404
|
} catch (err) {
|
|
201
405
|
// Never fail the user's edit over a recall miss, but do not pretend
|
|
202
406
|
// nothing happened either: a silently-skipped FTS query is how this
|
|
@@ -208,40 +412,128 @@ process.stdin.on('end', () => {
|
|
|
208
412
|
// be noticed without becoming noise the user learns to ignore.
|
|
209
413
|
reportOnce(`fts:${err?.message || err}`, `filename search failed: ${err?.message || err}`);
|
|
210
414
|
}
|
|
415
|
+
// Only the MATCH query sits in the throttled catch above. The
|
|
416
|
+
// confirmation below reads `observations`; a fault there is a
|
|
417
|
+
// broken database, not a recall miss, and is recorded as an
|
|
418
|
+
// `error` on every run (`recallFault`) — a throttled warning plus
|
|
419
|
+
// `skipped / nothing to recall` is what a healthy empty graph
|
|
420
|
+
// records, and doctor cannot tell the two apart.
|
|
421
|
+
// As many rows as the window allows means there may be MORE
|
|
422
|
+
// past it that were never fetched at all — recorded below so a
|
|
423
|
+
// resulting "nothing to recall" does not overclaim.
|
|
424
|
+
if (ftsResults.length === CANDIDATE_WINDOW) candidateWindowTruncated = true;
|
|
425
|
+
// Literal confirmation (#358 round 2, and round 3 item 3 for
|
|
426
|
+
// the boundary/path-suffix rules): `entities_fts` is
|
|
427
|
+
// contentless (it can only be MATCHed, never read from), so
|
|
428
|
+
// the text to confirm against comes from `entities.name` and
|
|
429
|
+
// every one of the entity's `observations.content` — not just
|
|
430
|
+
// the first observation the display snippet below uses, since
|
|
431
|
+
// the file name may be named in a later one.
|
|
432
|
+
try {
|
|
433
|
+
const getAllObs = db.prepare('SELECT content FROM observations WHERE entity_id = ?');
|
|
434
|
+
for (const r of ftsResults) {
|
|
435
|
+
if (!isTrustedForAutoContext(r.metadata)) continue;
|
|
436
|
+
if (results.some(existing => existing.id === r.id)) continue;
|
|
437
|
+
const candidateText = [r.name, ...getAllObs.all(r.id).map((o) => o.content)].join('\n');
|
|
438
|
+
if (!containsFileNameLiterally(candidateText, confirmNeedle, editedPath)) continue;
|
|
439
|
+
results.push(r);
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
recallFault = err;
|
|
443
|
+
}
|
|
211
444
|
}
|
|
212
445
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
446
|
+
// #358 round 4 finding 1: the truncation fact belongs on the
|
|
447
|
+
// outcome whenever the window filled AND fewer than MAX_RESULTS
|
|
448
|
+
// were confirmed overall — not only when NOTHING was confirmed.
|
|
449
|
+
// 1-2 genuinely-confirmed memories can still be hiding a 3rd (or a
|
|
450
|
+
// better-ranked) one past the window; injecting them without the
|
|
451
|
+
// caveat is the same overclaim `nothingToRecall` made, just
|
|
452
|
+
// wearing a happier outcome. Finalised here, once, after both
|
|
453
|
+
// strategies have had their say, so `results.length` is the true
|
|
454
|
+
// final count (Strategy 1's exact-tag hits count too — if it alone
|
|
455
|
+
// already filled MAX_RESULTS, Strategy 2 never ran and this stays
|
|
456
|
+
// false, correctly).
|
|
457
|
+
candidateWindowTruncated = candidateWindowTruncated && results.length < MAX_RESULTS;
|
|
458
|
+
|
|
459
|
+
if (!recallFault && results.length > 0) {
|
|
460
|
+
try {
|
|
461
|
+
// Fetch first observation for each result
|
|
462
|
+
const getObs = db.prepare(
|
|
463
|
+
'SELECT content FROM observations WHERE entity_id = ? ORDER BY id ASC LIMIT 1'
|
|
226
464
|
);
|
|
465
|
+
|
|
466
|
+
const snippetLines = [`Relevant memories for ${fileName}:`];
|
|
467
|
+
for (const r of results.slice(0, MAX_RESULTS)) {
|
|
468
|
+
const obs = getObs.get(r.id);
|
|
469
|
+
const snippet = obs ? obs.content.slice(0, 120) : '';
|
|
470
|
+
snippetLines.push(snippet
|
|
471
|
+
? `• ${r.name} (${r.type}): ${snippet}`
|
|
472
|
+
: `• ${r.name} (${r.type})`
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
recallLines.push(...snippetLines);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
recallFault = err;
|
|
227
478
|
}
|
|
228
479
|
}
|
|
229
480
|
|
|
230
|
-
// Record as seen either way (avoid re-querying a no-result file)
|
|
231
|
-
|
|
481
|
+
// Record as seen either way (avoid re-querying a no-result file) —
|
|
482
|
+
// but not after a fault: that run looked at nothing, and marking
|
|
483
|
+
// the file seen would switch recall off for the whole session.
|
|
484
|
+
if (!recallFault) recordSeen(seenFiles, fileKey);
|
|
232
485
|
}
|
|
233
486
|
}
|
|
234
487
|
} finally {
|
|
235
488
|
db.close();
|
|
236
489
|
}
|
|
237
490
|
|
|
491
|
+
if (recallFault) {
|
|
492
|
+
// Same trace and outcome the outer handler gives any other fault, on
|
|
493
|
+
// every run — but the guard half, already evaluated, is still emitted.
|
|
494
|
+
try { process.stderr.write(`[memesh pre-edit-recall] ${recallFault?.message || recallFault}\n`); } catch {}
|
|
495
|
+
if (guardMatches.length > 0) {
|
|
496
|
+
const faultToolLabel = data.tool_name === 'Write' ? 'Write' : 'Edit';
|
|
497
|
+
console.log(JSON.stringify({
|
|
498
|
+
hookSpecificOutput: {
|
|
499
|
+
hookEventName: 'PreToolUse',
|
|
500
|
+
additionalContext: buildReferenceContext(guardWarningLines(guardMatches, faultToolLabel)),
|
|
501
|
+
},
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
record('error', hookErrorReason(recallFault));
|
|
505
|
+
// Like the normal output path below, a run that wrote to stdout ends
|
|
506
|
+
// by returning, not `process.exit`, so the write is never cut short.
|
|
507
|
+
if (guardMatches.length === 0) pass();
|
|
508
|
+
// Last, as below: the counter must never stand between a match and
|
|
509
|
+
// its warning.
|
|
510
|
+
else recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
238
514
|
if (guardMatches.length === 0 && recallLines.length === 0) {
|
|
515
|
+
// #358 round 3 item 2: "nothing to recall" is only honest when the
|
|
516
|
+
// search actually covered everything there was to look at. When
|
|
517
|
+
// Strategy 2's candidate window filled up, say so instead — the
|
|
518
|
+
// review's reproduction: 9+ decoys failing confirmation hid a 10th,
|
|
519
|
+
// genuinely-matching row, and the old reason claimed there was
|
|
520
|
+
// nothing when there was something this run never got to examine.
|
|
521
|
+
record('skipped', candidateWindowTruncated
|
|
522
|
+
? SKIP_REASONS.candidateWindowTruncated
|
|
523
|
+
: SKIP_REASONS.nothingToRecall);
|
|
239
524
|
return pass();
|
|
240
525
|
}
|
|
241
526
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
527
|
+
// #358 round 4 finding 1: the SAME truncation caveat belongs on a
|
|
528
|
+
// `notified` outcome too — 1 or 2 confirmed memories injected while the
|
|
529
|
+
// window was full is not "the search found what there was to find",
|
|
530
|
+
// it is "the search found some of what there might be". Carrying the
|
|
531
|
+
// reason on a non-`skipped` outcome is safe: `isTriggeredRecord`
|
|
532
|
+
// (src/core/capture-liveness.ts) treats any outcome other than
|
|
533
|
+
// `skipped` as triggered regardless of `reason`, and `summarizeOne`'s
|
|
534
|
+
// `notified` branch never reads `r.reason` at all — this cannot be
|
|
535
|
+
// miscounted as a skip or make a healthy hook look silent.
|
|
536
|
+
const notifiedReason = candidateWindowTruncated ? SKIP_REASONS.candidateWindowTruncated : undefined;
|
|
245
537
|
|
|
246
538
|
// One fenced block, guards first — the warning about the edit at hand
|
|
247
539
|
// outranks background recall. Both halves are memory content, so both
|
|
@@ -257,6 +549,27 @@ process.stdin.on('end', () => {
|
|
|
257
549
|
additionalContext: buildReferenceContext(lines),
|
|
258
550
|
},
|
|
259
551
|
}));
|
|
552
|
+
// A recall hook produces an INJECTION, so it records `notified`, not
|
|
553
|
+
// `wrote`: doctor's `writes` answers "is memory capture still alive",
|
|
554
|
+
// and recalling a memory is not storing one.
|
|
555
|
+
//
|
|
556
|
+
// Not "this hook writes nothing" — it does, and the distinction is the
|
|
557
|
+
// whole point of the outcome. `recordGuardFires` above issues an
|
|
558
|
+
// `UPDATE entities SET metadata = json_set(… '$.guard.fires' …)`
|
|
559
|
+
// (_shared.js), bumping a counter on a memory that already exists. That
|
|
560
|
+
// is a write to the database and NOT a write of a memory, which is what
|
|
561
|
+
// `writes` counts. guard-check.js does the same thing for the same
|
|
562
|
+
// reason. A run of skips here is normal; a long run of them on a machine
|
|
563
|
+
// that edits files daily is not (#327).
|
|
564
|
+
record('notified', notifiedReason, `injected:${guardMatches.length}g+${recallLines.length}r`);
|
|
565
|
+
// The fire counter is written LAST, so nothing it does can stand between
|
|
566
|
+
// a matched guard and its warning. It opens its own writable handle and
|
|
567
|
+
// waits at most `GUARD_COUNTER_WAIT_MS` for the write lock: another writer
|
|
568
|
+
// still holding it after that skips the count (reported on stderr)
|
|
569
|
+
// instead of holding this process until the host's timeout kills it.
|
|
570
|
+
if (guardMatches.length > 0) {
|
|
571
|
+
recordGuardFires(dbPath, guardMatches.map((g) => g.lessonId));
|
|
572
|
+
}
|
|
260
573
|
} catch (err) {
|
|
261
574
|
// Never crash Claude Code, but trace — peer hooks (post-commit,
|
|
262
575
|
// pre-compact, session-summary) all stderr-trace their outer
|
|
@@ -264,6 +577,7 @@ process.stdin.on('end', () => {
|
|
|
264
577
|
// would silently break continuous recall on every Edit/Write
|
|
265
578
|
// tool call indefinitely.
|
|
266
579
|
try { process.stderr.write(`[memesh pre-edit-recall] ${err?.message || err}\n`); } catch {}
|
|
580
|
+
record('error', hookErrorReason(err));
|
|
267
581
|
pass();
|
|
268
582
|
}
|
|
269
583
|
});
|
|
@@ -304,6 +618,44 @@ function reportOnce(key, message) {
|
|
|
304
618
|
} catch { /* a warning must never break the user's edit */ }
|
|
305
619
|
}
|
|
306
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Resolve `dir` to its real (symlink-free) form, walking up to the nearest
|
|
623
|
+
* EXISTING ancestor first — a `Write` may target a directory that does not
|
|
624
|
+
* exist yet, and `realpathSync` throws on a missing path. Using an ancestor
|
|
625
|
+
* rather than `dir` itself is fine for project identity: any directory
|
|
626
|
+
* inside a repository resolves to the same repo root.
|
|
627
|
+
*
|
|
628
|
+
* Returns `{ dir, tail }`: `dir` is that resolved ancestor, `tail` the
|
|
629
|
+
* components the walk dropped to reach it ('' when `dir` itself exists), so
|
|
630
|
+
* the caller can rebuild the file's OWN directory instead of mistaking the
|
|
631
|
+
* ancestor for it.
|
|
632
|
+
*
|
|
633
|
+
* On any error (a path `realpathSync` still can't resolve, a walk that
|
|
634
|
+
* reaches the filesystem root with nothing existing) returns the original
|
|
635
|
+
* `dir` with an empty tail.
|
|
636
|
+
*/
|
|
637
|
+
function realpathNearestExisting(dir) {
|
|
638
|
+
let candidate = dir;
|
|
639
|
+
const dropped = [];
|
|
640
|
+
// Bounded, not `while (true)`: a pathological input (e.g. a cyclic
|
|
641
|
+
// symlink dirname loop, which should not happen but must not hang a
|
|
642
|
+
// PreToolUse hook if it somehow does) must still terminate.
|
|
643
|
+
for (let i = 0; i < 64; i++) {
|
|
644
|
+
if (existsSync(candidate)) {
|
|
645
|
+
try {
|
|
646
|
+
return { dir: realpathSync(candidate), tail: dropped.length ? join(...dropped) : '' };
|
|
647
|
+
} catch {
|
|
648
|
+
return { dir, tail: '' };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
const parent = dirname(candidate);
|
|
652
|
+
if (parent === candidate) break; // reached the filesystem root
|
|
653
|
+
dropped.unshift(basename(candidate));
|
|
654
|
+
candidate = parent;
|
|
655
|
+
}
|
|
656
|
+
return { dir, tail: '' };
|
|
657
|
+
}
|
|
658
|
+
|
|
307
659
|
function recordSeen(seenFiles, fileKey) {
|
|
308
660
|
try {
|
|
309
661
|
seenFiles.push(fileKey);
|