@mnemonik/shared 1.0.0 → 5.75.3

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.
@@ -3,13 +3,14 @@
3
3
  */
4
4
 
5
5
  import { readdir, readFile, stat, lstat, realpath } from 'fs/promises';
6
- import { join, relative, extname } from 'path';
6
+ import { join, relative, extname, sep } from 'path';
7
7
  import { createHash } from 'crypto';
8
8
  import { debug as logDebug } from './logger.js';
9
9
  import { withTimeout } from './asyncUtils.js';
10
+ import { scrubSecrets } from './secretPatterns.js';
10
11
 
11
12
  /**
12
- * v2.46: File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
13
+ * File operation timeout (5 seconds) to prevent hanging on slow/unresponsive filesystems
13
14
  */
14
15
  const FILE_OP_TIMEOUT_MS = 5000;
15
16
 
@@ -25,8 +26,8 @@ export interface CodeChunk {
25
26
  fileName: string;
26
27
  extension: string;
27
28
  size: number;
28
- signature?: string; // v3.3: Function/class signature (e.g. "function foo(bar: string): number")
29
- symbolName?: string; // v3.3: Symbol name (e.g. "foo")
29
+ signature?: string; // Function/class signature (e.g. "function foo(bar: string): number")
30
+ symbolName?: string; // Symbol name (e.g. "foo")
30
31
  };
31
32
  }
32
33
 
@@ -75,6 +76,14 @@ const DEFAULT_OPTIONS: Required<ScanOptions> = {
75
76
  '*.bundle.js',
76
77
  '*.legacy.js',
77
78
  '*.map',
79
+ // Agent-host transient checkouts. EnterWorktree creates isolated git
80
+ // worktree copies under `.claude/worktrees/{name}/`; indexing them
81
+ // duplicates the entire codebase under a path that misclassifies as
82
+ // `archive` doctype and polluted the dogfood corpus with 376 stale
83
+ // couplings. Glob form (with `*`) so the regex matcher applies it as a
84
+ // path predicate rather than a path-segment-equality predicate (which
85
+ // would not match the nested form).
86
+ '.claude/worktrees/*',
78
87
  ],
79
88
  includeExtensions: [
80
89
  '.ts',
@@ -97,6 +106,30 @@ const DEFAULT_OPTIONS: Required<ScanOptions> = {
97
106
  ],
98
107
  };
99
108
 
109
+ /**
110
+ * Matchers for authority manifest / config / CI files whose verbatim content
111
+ * is collected by `collectAuthorityFiles` and pushed to the server for
112
+ * doc-truth Fingerprint parsing. Operates on relative, forward-slash paths.
113
+ */
114
+ export const AUTHORITY_FILE_MATCHERS: Array<(relPath: string) => boolean> = [
115
+ (p) => p === 'package.json',
116
+ (p) => /^(packages|apps|services|tools)\/[^/]+\/package\.json$/.test(p),
117
+ (p) => p === 'tsconfig.json' || /^tsconfig\.[^/]+\.json$/.test(p),
118
+ (p) => p === 'pyproject.toml',
119
+ (p) => p === 'requirements.txt',
120
+ (p) => p === 'setup.py',
121
+ (p) => p === 'Cargo.toml',
122
+ (p) => p === 'Gemfile',
123
+ (p) => p === 'Makefile',
124
+ (p) => p === '.env.example',
125
+ (p) => /^\.github\/workflows\/[^/]+\.(yml|yaml)$/.test(p),
126
+ // SQL migrations: the schema_columns authority extractor reads every .sql
127
+ // under `migrations/` (listFiles('migrations/') -> LIKE 'migrations/%' then
128
+ // .endsWith('.sql')). Without collecting these, that authority is empty and
129
+ // every schema_table_enumeration claim falls to unverifiable.
130
+ (p) => /^migrations\/.*\.sql$/.test(p),
131
+ ];
132
+
100
133
  export class CodeScanner {
101
134
  private options: Required<ScanOptions>;
102
135
 
@@ -106,13 +139,13 @@ export class CodeScanner {
106
139
 
107
140
  /**
108
141
  * Maximum directory depth for recursive scanning
109
- * v2.43: Prevents runaway recursion on deep/symlinked structures
142
+ * Prevents runaway recursion on deep/symlinked structures
110
143
  */
111
144
  private static readonly MAX_DEPTH = 10;
112
145
 
113
146
  /**
114
147
  * Scan a directory recursively and extract code chunks
115
- * v2.43: Added max depth (10) to prevent infinite recursion
148
+ * Added max depth (10) to prevent infinite recursion
116
149
  */
117
150
  async scanDirectory(rootPath: string): Promise<CodeChunk[]> {
118
151
  const chunks: CodeChunk[] = [];
@@ -120,6 +153,73 @@ export class CodeScanner {
120
153
  return chunks;
121
154
  }
122
155
 
156
+ /**
157
+ * Enumerate scan-eligible relative file paths under `rootPath` without
158
+ * reading or chunking content. Same walk + ignorePatterns + extension
159
+ * filter as `scanDirectory`, but bounded to O(file count) directory ops
160
+ * — cheap enough to call on every periodic reconcile tick.
161
+ *
162
+ * Returns relative paths normalized against `rootPath`, matching the
163
+ * shape the server stores in `memories.metadata->>'filePath'`. Use this
164
+ * for the scanner reconciliation channel (`POST /api/v1/scan/reconcile`).
165
+ *
166
+ * Defensive: skips any path whose `relative()` result contains a `..`
167
+ * traversal segment (can happen when a symlink resolves under root but
168
+ * the readdir entry path doesn't normalize cleanly). The server's
169
+ * `safeScanPath` rejects such paths; filtering here keeps a single
170
+ * malformed entry from failing the entire reconcile push.
171
+ */
172
+ async listFiles(rootPath: string): Promise<string[]> {
173
+ const paths: string[] = [];
174
+ await this.traversePaths(rootPath, rootPath, paths, 0);
175
+ return paths.filter((p) => !/(^|[/\\])\.\.([/\\]|$)/.test(p));
176
+ }
177
+
178
+ private async traversePaths(
179
+ currentPath: string,
180
+ rootPath: string,
181
+ out: string[],
182
+ depth: number
183
+ ): Promise<void> {
184
+ if (depth >= CodeScanner.MAX_DEPTH) return;
185
+ try {
186
+ const entries = await withTimeout(
187
+ readdir(currentPath),
188
+ FILE_OP_TIMEOUT_MS,
189
+ `readdir timed out: ${currentPath}`
190
+ );
191
+ for (const entry of entries) {
192
+ const fullPath = join(currentPath, entry);
193
+ const relativePath = relative(rootPath, fullPath);
194
+ if (this.shouldIgnore(relativePath)) continue;
195
+
196
+ const lstats = await withTimeout(
197
+ lstat(fullPath),
198
+ FILE_OP_TIMEOUT_MS,
199
+ `lstat timed out: ${fullPath}`
200
+ );
201
+ if (lstats.isSymbolicLink()) {
202
+ const resolved = await realpath(fullPath);
203
+ const resolvedRoot = await realpath(rootPath);
204
+ if (!resolved.startsWith(resolvedRoot + '/') && resolved !== resolvedRoot) continue;
205
+ }
206
+ const stats = lstats.isSymbolicLink()
207
+ ? await withTimeout(stat(fullPath), FILE_OP_TIMEOUT_MS, `stat timed out: ${fullPath}`)
208
+ : lstats;
209
+
210
+ if (stats.isDirectory()) {
211
+ await this.traversePaths(fullPath, rootPath, out, depth + 1);
212
+ } else if (stats.isFile()) {
213
+ if (this.options.includeExtensions.includes(extname(fullPath))) {
214
+ out.push(relativePath);
215
+ }
216
+ }
217
+ }
218
+ } catch (error) {
219
+ logDebug('Error traversing directory (listFiles)', { path: currentPath, error });
220
+ }
221
+ }
222
+
123
223
  /**
124
224
  * Scan specific files and extract code chunks.
125
225
  * Pass rootPath to compute proper relative file paths in chunk metadata.
@@ -143,12 +243,26 @@ export class CodeScanner {
143
243
  }
144
244
  }
145
245
 
146
- return chunks;
246
+ // Daemon-side secret redaction: scrub credentials from chunk content
247
+ // before they leave this process. contentHash is recomputed from the
248
+ // scrubbed content so the server-side dedup cache (which keys on
249
+ // contentHash) hits when team members push the same scrubbed text.
250
+ // Server still re-applies scrubSecrets in the /scan/push handler as
251
+ // defense in depth (idempotent).
252
+ return chunks.map((chunk) => {
253
+ const scrubbed = scrubSecrets(chunk.content);
254
+ if (scrubbed === chunk.content) return chunk;
255
+ return {
256
+ ...chunk,
257
+ content: scrubbed,
258
+ contentHash: this.hash(scrubbed),
259
+ };
260
+ });
147
261
  }
148
262
 
149
263
  /**
150
264
  * Recursively traverse directory
151
- * v2.43: Added depth parameter with max limit
265
+ * Added depth parameter with max limit
152
266
  */
153
267
  private async traverseDirectory(
154
268
  currentPath: string,
@@ -156,14 +270,14 @@ export class CodeScanner {
156
270
  chunks: CodeChunk[],
157
271
  depth: number
158
272
  ): Promise<void> {
159
- // v2.43: Prevent infinite recursion
273
+ // Prevent infinite recursion
160
274
  if (depth >= CodeScanner.MAX_DEPTH) {
161
275
  logDebug('Max directory depth reached, skipping', { path: currentPath, depth });
162
276
  return;
163
277
  }
164
278
 
165
279
  try {
166
- // v2.46: Wrap readdir with timeout to prevent hanging
280
+ // Wrap readdir with timeout to prevent hanging
167
281
  const entries = await withTimeout(
168
282
  readdir(currentPath),
169
283
  FILE_OP_TIMEOUT_MS,
@@ -215,7 +329,7 @@ export class CodeScanner {
215
329
 
216
330
  /**
217
331
  * Check if path should be ignored
218
- * v2.71: Fixed glob-to-regex conversion and substring matching.
332
+ * Fixed glob-to-regex conversion and substring matching.
219
333
  * - Escape regex special chars before replacing * with .*
220
334
  * - Replace ALL * occurrences (not just the first)
221
335
  * - For non-glob patterns, match on path segments to avoid false positives
@@ -238,14 +352,14 @@ export class CodeScanner {
238
352
 
239
353
  /**
240
354
  * Parse a file and extract code chunks
241
- * v2.43: Added 10MB file size limit
355
+ * Added 10MB file size limit
242
356
  */
243
357
  private static readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
244
358
 
245
359
  private async parseFile(filePath: string, rootPath: string): Promise<CodeChunk[]> {
246
360
  try {
247
- // v2.43: Check file size before reading to avoid memory issues
248
- // v2.46: Wrap stat with timeout
361
+ // Check file size before reading to avoid memory issues
362
+ // Wrap stat with timeout
249
363
  const stats = await withTimeout(
250
364
  stat(filePath),
251
365
  FILE_OP_TIMEOUT_MS,
@@ -260,7 +374,7 @@ export class CodeScanner {
260
374
  return [];
261
375
  }
262
376
 
263
- // v2.46: Wrap readFile with timeout
377
+ // Wrap readFile with timeout
264
378
  const content = await withTimeout(
265
379
  readFile(filePath, 'utf-8'),
266
380
  FILE_OP_TIMEOUT_MS,
@@ -558,7 +672,7 @@ export class CodeScanner {
558
672
 
559
673
  /**
560
674
  * Extract structured chunks (functions, classes)
561
- * v2.76: Uses brace-matching for TS/JS/Rust so nested braces are not truncated at first \n}
675
+ * Uses brace-matching for TS/JS/Rust so nested braces are not truncated at first \n}
562
676
  */
563
677
  private extractStructuredChunks(
564
678
  content: string,
@@ -604,7 +718,7 @@ export class CodeScanner {
604
718
  matchContent.length >= this.options.minChunkSize &&
605
719
  matchContent.length <= this.options.maxChunkSize
606
720
  ) {
607
- // v3.3: Extract function/class signature and symbol name
721
+ // Extract function/class signature and symbol name
608
722
  const firstLine = matchContent.split('\n')[0].trim();
609
723
  const signature = firstLine.replace(/\{$/, '').trim() || undefined;
610
724
  const nameMatch = firstLine.match(
@@ -685,34 +799,106 @@ export class CodeScanner {
685
799
  }
686
800
 
687
801
  /**
688
- * Fall back to raw chunking with overlap
802
+ * Raw chunking with overlap, bounded by character count (not line count).
803
+ *
804
+ * The previous implementation took `floor(maxChunkSize / 80)` lines per
805
+ * chunk on the assumption of ~80 chars/line. Long-line files (minified
806
+ * JS, JSON blobs, generated code) produced chunks many times larger than
807
+ * `maxChunkSize`, which then exceeded OpenAI's 8191-token embedding
808
+ * limit and surfaced as 400s on /scan/push (Sentry MNEMONIK-58).
809
+ *
810
+ * Now: walk lines and accumulate character length; emit when the next
811
+ * line would push the running total past `maxChunkSize`. Single lines
812
+ * longer than `maxChunkSize` are force-split into char-based segments.
813
+ * 10% overlap is carried by character count from the tail of the
814
+ * just-emitted chunk.
689
815
  */
690
816
  private chunkRaw(content: string, filePath: string, language: string, size: number): CodeChunk[] {
691
817
  const chunks: CodeChunk[] = [];
692
818
  const lines = content.split('\n');
693
- const chunkSizeLines = Math.floor(this.options.maxChunkSize / 80); // Assume ~80 chars per line
694
- const overlapLines = Math.floor(chunkSizeLines * 0.1); // 10% overlap
819
+ const maxBytes = this.options.maxChunkSize;
820
+ const minBytes = this.options.minChunkSize;
821
+ const overlapBytes = Math.floor(maxBytes * 0.1);
822
+ const fileName = filePath.split('/').pop() || '';
823
+ const extension = extname(filePath);
824
+
825
+ let currentLines: string[] = [];
826
+ let currentLen = 0;
827
+ let chunkStartIdx = 0;
828
+
829
+ const emit = (linesArr: string[], startIdx: number) => {
830
+ const text = linesArr.join('\n');
831
+ if (text.length < minBytes) return;
832
+ chunks.push({
833
+ content: text.trim(),
834
+ filePath,
835
+ language,
836
+ startLine: startIdx + 1,
837
+ endLine: startIdx + linesArr.length,
838
+ chunkType: 'raw',
839
+ contentHash: this.hash(text),
840
+ metadata: { fileName, extension, size },
841
+ });
842
+ };
695
843
 
696
- for (let i = 0; i < lines.length; i += chunkSizeLines - overlapLines) {
697
- const chunkLines = lines.slice(i, i + chunkSizeLines);
698
- const chunkContent = chunkLines.join('\n');
844
+ const flushWithOverlap = () => {
845
+ if (currentLines.length === 0) return;
846
+ emit(currentLines, chunkStartIdx);
847
+
848
+ const overlapTail: string[] = [];
849
+ let overlapLen = 0;
850
+ for (let j = currentLines.length - 1; j >= 0; j--) {
851
+ const lineLen = currentLines[j].length + 1;
852
+ if (overlapLen + lineLen > overlapBytes) break;
853
+ overlapTail.unshift(currentLines[j]);
854
+ overlapLen += lineLen;
855
+ }
699
856
 
700
- if (chunkContent.length >= this.options.minChunkSize) {
701
- chunks.push({
702
- content: chunkContent.trim(),
703
- filePath,
704
- language,
705
- startLine: i + 1,
706
- endLine: i + chunkLines.length,
707
- chunkType: 'raw',
708
- contentHash: this.hash(chunkContent),
709
- metadata: {
710
- fileName: filePath.split('/').pop() || '',
711
- extension: extname(filePath),
712
- size,
713
- },
714
- });
857
+ chunkStartIdx = chunkStartIdx + currentLines.length - overlapTail.length;
858
+ currentLines = overlapTail;
859
+ currentLen = overlapLen;
860
+ };
861
+
862
+ for (let i = 0; i < lines.length; i++) {
863
+ const line = lines[i];
864
+
865
+ if (line.length >= maxBytes) {
866
+ if (currentLines.length > 0) {
867
+ emit(currentLines, chunkStartIdx);
868
+ currentLines = [];
869
+ currentLen = 0;
870
+ }
871
+ for (let offset = 0; offset < line.length; offset += maxBytes) {
872
+ const segment = line.slice(offset, offset + maxBytes);
873
+ if (segment.length < minBytes) continue;
874
+ chunks.push({
875
+ content: segment.trim(),
876
+ filePath,
877
+ language,
878
+ startLine: i + 1,
879
+ endLine: i + 1,
880
+ chunkType: 'raw',
881
+ contentHash: this.hash(segment),
882
+ metadata: { fileName, extension, size },
883
+ });
884
+ }
885
+ continue;
886
+ }
887
+
888
+ const lineLen = line.length + 1;
889
+ if (currentLen + lineLen > maxBytes && currentLen >= minBytes) {
890
+ flushWithOverlap();
715
891
  }
892
+
893
+ if (currentLines.length === 0) {
894
+ chunkStartIdx = i;
895
+ }
896
+ currentLines.push(line);
897
+ currentLen += lineLen;
898
+ }
899
+
900
+ if (currentLines.length > 0) {
901
+ emit(currentLines, chunkStartIdx);
716
902
  }
717
903
 
718
904
  return chunks;
@@ -724,4 +910,48 @@ export class CodeScanner {
724
910
  private hash(content: string): string {
725
911
  return createHash('sha256').update(content).digest('hex').substring(0, 16);
726
912
  }
913
+
914
+ /**
915
+ * Walk the project for authority manifest/config/CI files and return their
916
+ * verbatim content. Reuses ignorePatterns; matches AUTHORITY_FILE_MATCHERS
917
+ * (not includeExtensions). Content-only — no chunking, no embeddings.
918
+ */
919
+ async collectAuthorityFiles(
920
+ projectRoot: string
921
+ ): Promise<Array<{ path: string; content: string; hash: string }>> {
922
+ const out: Array<{ path: string; content: string; hash: string }> = [];
923
+
924
+ // M2: cap walk depth to match the scanner's MAX_DEPTH (10)
925
+ const walk = async (dir: string, depth: number): Promise<void> => {
926
+ if (depth >= CodeScanner.MAX_DEPTH) return;
927
+ let entries;
928
+ try {
929
+ entries = await readdir(dir, { withFileTypes: true });
930
+ } catch {
931
+ return;
932
+ }
933
+ for (const ent of entries) {
934
+ const full = join(dir, ent.name);
935
+ const rel = relative(projectRoot, full).split(sep).join('/');
936
+ if (this.shouldIgnore(rel)) continue;
937
+ if (ent.isDirectory()) {
938
+ await walk(full, depth + 1);
939
+ } else if (AUTHORITY_FILE_MATCHERS.some((m) => m(rel))) {
940
+ try {
941
+ const content = await readFile(full, 'utf-8');
942
+ // C2: skip files whose content exceeds the server's 5MB cap —
943
+ // manifests are tiny; an oversized one is anomalous.
944
+ if (content.length > 5_000_000) continue;
945
+ const hash = createHash('sha256').update(content).digest('hex');
946
+ out.push({ path: rel, content, hash });
947
+ } catch {
948
+ /* unreadable — skip */
949
+ }
950
+ }
951
+ }
952
+ };
953
+
954
+ await walk(projectRoot, 0);
955
+ return out;
956
+ }
727
957
  }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Hook dispatcher HTTP timeout budgets + AbortSignal helper.
3
+ *
4
+ * Single source of truth for the timeouts used by the three host-side
5
+ * hook dispatcher packages (`@mnemonik/claude-code-hooks`,
6
+ * `@mnemonik/codex-hooks`, `@mnemonik/cursor-hooks`). Before the
7
+ * 2026-05-16 audit Finding #7 cross-cutting cleanup, each package
8
+ * declared its own copy of these constants and a near-identical
9
+ * `withTimeout` helper — coordinating a budget change required three
10
+ * synchronised edits with no enforcement that the values matched.
11
+ *
12
+ * Surface is intentionally minimal: small constants + a single helper
13
+ * function. No fetch wrappers here — request shaping stays per-package
14
+ * because each host expresses its hook payloads differently.
15
+ */
16
+
17
+ /** Snapshot / file-context / policy-reminder / injections fetch budget. Critical-path. */
18
+ export const FETCH_TIMEOUT_MS = 2000;
19
+
20
+ /** Telemetry fan-out budget. Drop the metric rather than hold the user. */
21
+ export const TELEMETRY_TIMEOUT_MS = 500;
22
+
23
+ /** PostToolUse / track-ide-edit budget. Faster than FETCH because it's fire-and-forget. */
24
+ export const POST_TOOL_TIMEOUT_MS = 1500;
25
+
26
+ /** beforeMCPExecution gate budget. Same as FETCH today; documented separately so it can move independently. */
27
+ export const MCP_PRECHECK_TIMEOUT_MS = 2000;
28
+
29
+ /**
30
+ * Spawn an `AbortController` tied to a timeout. Returns the signal plus a
31
+ * `cleanup` function the caller MUST invoke (in `finally`) to clear the
32
+ * timer when the request finishes naturally — otherwise the timer leaks
33
+ * for the timeout duration.
34
+ *
35
+ * Identical signature to the inlined `withTimeout` that each hook package
36
+ * used before this consolidation; call sites swap their local import for
37
+ * `import { withHookTimeout } from '@mnemonik/shared'` and nothing else
38
+ * changes.
39
+ */
40
+ export function withHookTimeout(ms: number): { signal: AbortSignal; cleanup: () => void } {
41
+ const ac = new AbortController();
42
+ const timer = setTimeout(() => ac.abort(), ms);
43
+ return { signal: ac.signal, cleanup: () => clearTimeout(timer) };
44
+ }
package/src/index.ts CHANGED
@@ -7,7 +7,12 @@
7
7
 
8
8
  export { MCP_INSTRUCTIONS, MCP_INSTRUCTIONS_RAW, getMcpInstructions } from './instructions.js';
9
9
  export { USAGE_GUIDE } from './usageGuide.js';
10
- export { CodeScanner, type CodeChunk, type ScanOptions } from './codeScanner.js';
10
+ export {
11
+ CodeScanner,
12
+ AUTHORITY_FILE_MATCHERS,
13
+ type CodeChunk,
14
+ type ScanOptions,
15
+ } from './codeScanner.js';
11
16
  export {
12
17
  FileSystemReader,
13
18
  getFileSystemReader,
@@ -15,3 +20,11 @@ export {
15
20
  type ChangedFilesResult,
16
21
  type FileData,
17
22
  } from './FileSystemReader.js';
23
+ export { SECRET_PATTERNS, SECRET_REDACTION_PLACEHOLDER, scrubSecrets } from './secretPatterns.js';
24
+ export {
25
+ FETCH_TIMEOUT_MS,
26
+ TELEMETRY_TIMEOUT_MS,
27
+ POST_TOOL_TIMEOUT_MS,
28
+ MCP_PRECHECK_TIMEOUT_MS,
29
+ withHookTimeout,
30
+ } from './hookTimeouts.js';
@@ -4,17 +4,31 @@
4
4
  * This is the SINGLE SOURCE OF TRUTH for MCP instructions.
5
5
  * Shared instruction content imported by the server.
6
6
  *
7
- * Version: 2.92
8
- * Updated: 2026-02-24
7
+ * Version: 2.98
8
+ * Updated: 2026-05-27
9
9
  *
10
- * v2.93: Code mode permanent all memory operations via memory_tools sandbox.
10
+ * v2.98 Doc truth contracts are the normal drift surface. linkedDocs and
11
+ * doc_code_couplings remain legacy diagnostics only; docs drift
12
+ * defaults to truth findings and legacy:true is explicit debug.
13
+ * v2.97 — Dropped legacy doc-coupling action guidance. Coupling rows are
14
+ * not truth findings (plan §1).
15
+ * v2.96 — Replace conditional memory_discover guidance with structural fix:
16
+ * bootstrap now includes _methodCatalog (discoverMemoryTools({})) so
17
+ * agents have the memory_tools calling convention from turn one.
18
+ * Instruction updated to reference _methodCatalog directly.
19
+ * v2.95 — Drop `augments` from JIT directive verdict list per plan §5 default
20
+ * (augments downgrades to ambient via the parallel recall gate, not
21
+ * the directive lane). See jit-knowledge-injector.md §5 decision note.
22
+ * v2.94 — Add JIT directive teaching (docs/development/jit-knowledge-injector.md §2.3).
23
+ *
24
+ * Code mode permanent — all memory operations via memory_tools sandbox.
11
25
  * memory_add, file_context etc. are now mnemonik.* methods, not standalone tools.
12
26
  *
13
- * v2.92: Zero-cooperation rewrite. Context auto-loads if session_bootstrap is skipped.
27
+ * Zero-cooperation rewrite. Context auto-loads if session_bootstrap is skipped.
14
28
  * Session summaries are auto-saved if agent doesn't call mnemonik.memory_add().
15
29
  * Instructions drastically simplified — the server handles the workflow now.
16
30
  *
17
- * v2.80: Token-optimised rewrite (superseded by v2.92).
31
+ * Token-optimised rewrite (superseded by later instruction rewrites).
18
32
  */
19
33
 
20
34
  const INSTRUCTIONS_CONTENT = `You have Mnemonik, a persistent memory system for this project.
@@ -22,11 +36,16 @@ const INSTRUCTIONS_CONTENT = `You have Mnemonik, a persistent memory system for
22
36
  First call, every session: session_bootstrap. Read the mnemonik skill (from available_skills) for the full workflow.
23
37
  After bootstrap: execute _directive.message actions immediately (scanner daemon check is mandatory).
24
38
 
39
+ The bootstrap response includes _methodCatalog — the full list of mnemonik methods and the memory_tools calling convention. Read it before making any memory_tools calls. Use memory_discover({ method }) for the full schema and a copy-pasteable example of a specific method or action.
25
40
  Proactively call memory_search before starting new work — avoids rediscovering known patterns and contradicting past decisions.
26
41
  Proactively call file_context before editing any file — loads past bugs, decisions, and gotchas for that file.
27
42
  Proactively call checkpoint after making changes or decisions worth keeping — your context is ephemeral and checkpoint is the only way decisions survive across sessions and context compaction. Do not wait for the user to say "done" or "thanks".
28
43
 
29
- When mnemonik.file_context({ filePaths, cwd }) returns linkedDocs with driftStatus 'stale', update docs then call mnemonik.docs({ action: 'resolve', docPath, cwd }).
44
+ Documentation drift uses explicit doc-truth contracts. Treat _docDrift/docTruthFindings as actionable only when each finding includes a concrete assertion, named authority, observed mismatch, and agentAction='update_doc'. _docHealth, linkedDocs, stale-coupling counts, and docs({ action: 'drift', legacy: true }) are diagnostics only, not instructions to edit or resolve docs.
45
+
46
+ Ambient envelopes contain background memories surfaced because they may be relevant to your current turn. Treat them as information, not directive. Weight them lower than your own reasoning unless they directly answer the question. They are advisory recall, not authoritative evidence.
47
+
48
+ Directive envelopes with signal="jit_required" are different from ambient. They surface memories that the server has classified as contradicting or materially refining the answer you were about to give to the current prompt. Each memory carries a verdict (contradicts | refines) and a one-line reason. Treat these as authoritative for the current response — comply with the surfaced fact unless it is clearly outdated or wrong. Do not silently route around them. If you disagree with a surfaced memory, say so explicitly and explain why; do not just ignore it.
30
49
 
31
50
  Skip: formatting-only, trivial one-line, mechanical refactors, git ops, tests.
32
51
  Save: architectural decisions, bug root causes, user preferences, discovered patterns, multi-file changes.`;
@@ -34,9 +53,16 @@ Save: architectural decisions, bug root causes, user preferences, discovered pat
34
53
  /**
35
54
  * Get MCP instructions, respecting MNEMONIK_INSTRUCTIONS_ENABLED env var.
36
55
  * Set MNEMONIK_INSTRUCTIONS_ENABLED=false to disable for testing.
56
+ *
57
+ * Reads env through globalThis so this package compiles cleanly without
58
+ * `@types/node` (shared package's tsconfig doesn't include it, which made
59
+ * IDEs flag `process` as an unknown global even though the workspace
60
+ * tsc resolution found it).
37
61
  */
38
62
  export function getMcpInstructions(): string {
39
- if (typeof process !== 'undefined' && process.env?.MNEMONIK_INSTRUCTIONS_ENABLED === 'false') {
63
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
64
+ ?.env;
65
+ if (env?.MNEMONIK_INSTRUCTIONS_ENABLED === 'false') {
40
66
  return '';
41
67
  }
42
68
  return INSTRUCTIONS_CONTENT;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Single source of truth for secret-redaction patterns.
3
+ *
4
+ * Used by:
5
+ * - packages/shared CodeScanner — scrubs chunk content before computing
6
+ * contentHash, so daemon ships scrubbed content (correct hash for
7
+ * server-side cache dedup).
8
+ * - server /api/v1/scan/push handler — re-applies scrub as defense in
9
+ * depth (idempotent — already-scrubbed content stays the same), so
10
+ * older daemons or compromised daemons can't leak secrets through us.
11
+ * - server GitMiner — scrubs commit messages before storing as memories.
12
+ *
13
+ * Patterns target high-confidence credential shapes:
14
+ * 1. key=value style: api_key, secret, token, password, credential, auth
15
+ * 2. Stripe-style sk_live_/pk_test_ keys
16
+ * 3. GitHub personal access tokens (ghp_ prefix, exact 36 chars)
17
+ * 4. GitLab personal access tokens (glpat- prefix, 20+ chars)
18
+ * 5. PEM-style private key headers
19
+ *
20
+ * False-positive cost: a few legitimate strings get replaced with the
21
+ * placeholder. False-negative cost: a credential ships to the server and
22
+ * gets stored in a memory. The patterns are deliberately tight (require
23
+ * specific prefixes, length minimums) to keep the false-positive rate low
24
+ * while catching the common credential leak vectors.
25
+ */
26
+
27
+ export const SECRET_REDACTION_PLACEHOLDER = '[REDACTED]';
28
+
29
+ export const SECRET_PATTERNS: ReadonlyArray<RegExp> = [
30
+ /(?:api[_-]?key|secret|token|password|credential|auth)\s*[:=]\s*\S+/gi,
31
+ // Stripe-shape: (sk|pk)_(live|test)_<24+ alphanumerics>. Catches modern
32
+ // Stripe keys whose body is split by an environment underscore that
33
+ // breaks the contiguous-alphanum pattern below. Required `live|test`
34
+ // literal prevents false-positives on snake_case identifiers like
35
+ // pkg_install_helper_function_xyz_abc_def.
36
+ /(?:sk|pk)_(?:live|test)_[a-zA-Z0-9]{24,}/g,
37
+ /(?:sk|pk)[-_][a-zA-Z0-9]{20,}/g,
38
+ /ghp_[a-zA-Z0-9]{36}/g,
39
+ /glpat-[a-zA-Z0-9-]{20,}/g,
40
+ /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
41
+ ];
42
+
43
+ /**
44
+ * Replace recognized secret shapes in `text` with the redaction
45
+ * placeholder. Returns the input unchanged when no patterns match.
46
+ *
47
+ * Idempotent: scrubbing already-scrubbed text returns the same text
48
+ * (the placeholder itself doesn't match any pattern).
49
+ */
50
+ export function scrubSecrets(text: string): string {
51
+ if (!text) return text;
52
+ let result = text;
53
+ for (const pattern of SECRET_PATTERNS) {
54
+ result = result.replace(pattern, SECRET_REDACTION_PLACEHOLDER);
55
+ }
56
+ return result;
57
+ }