moflo 4.11.5 → 4.11.7

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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Rule (MUST)
6
6
 
7
- When a search hit carries a non-null `navigation` field, you MUST traverse via `mcp__moflo__memory_get_neighbors` NOT bulk-retrieve every hit with `memory_retrieve`. The `navigation` crumb is the chunking architecture's contract; bulk-retrieving every search hit defeats it and is a protocol violation. Use `memory_retrieve` only for a single specific key you already hold, or for non-chunk entries where `navigation` is null.
7
+ When a search hit carries a non-null `navigation` field and you need adjacent context, prefer `memory_search` with `expand: 'neighbors'` — it inlines each chunk hit's prev/next content in the SAME call (one round-trip, no re-embedding). Otherwise traverse via `mcp__moflo__memory_get_neighbors`. NEVER bulk-retrieve every hit with `memory_retrieve` the `navigation` crumb is the chunking architecture's contract, and bulk-retrieving defeats it (protocol violation). NEVER `Read` a chunk's `parentPath` for surrounding context when `expand`/neighbors would do — a full-doc Read costs far more tokens. Use `memory_retrieve` only for a single specific key you already hold, or for non-chunk entries where `navigation` is null.
8
8
 
9
9
  ---
10
10
 
@@ -146,7 +146,8 @@ Once you have a search hit, pick the right follow-up call by what you need next.
146
146
  | You want | Use | Why |
147
147
  |----------|-----|-----|
148
148
  | Find an entry-point | `mcp__moflo__memory_search` | Returns chunk hits with `navigation` (parentDoc, prev/next, chunkTitle) |
149
- | Adjacent context (1 chunk over) | `mcp__moflo__memory_get_neighbors` `{ key, include: ['prev','next'] }` | One round-trip, shaped entries with full nav |
149
+ | Adjacent context AND you're still searching | `mcp__moflo__memory_search` `{ query, expand: 'neighbors' }` | Cheapest prev/next content inlined in the search call itself, zero extra round-trips |
150
+ | Adjacent context for a hit you already have | `mcp__moflo__memory_get_neighbors` `{ key, include: ['prev','next'] }` | One round-trip, shaped entries with full nav |
150
151
  | Same-section peers (h2/h3 family) | `memory_get_neighbors` `{ include: ['siblings'] }` or `['parent','children']` | Hierarchical traversal — cheaper than re-searching |
151
152
  | Full content of one specific chunk | `mcp__moflo__memory_retrieve` `{ key }` | For a key you already hold — never for bulk-retrieving search hits |
152
153
  | Whole source doc when truly needed | `Read` `parentPath` from any chunk's nav | Disk read is cheaper than re-indexed `doc-*` |
@@ -31,7 +31,7 @@ import { fileURLToPath } from 'url';
31
31
  import { execSync, execFileSync, spawn } from 'child_process';
32
32
  import { memoryDbPath, MOFLO_DIR, findProjectRoot } from './lib/moflo-paths.mjs';
33
33
  import { openBackend } from './lib/get-backend.mjs';
34
- import { applyIncrementalChunks, computeContentListHash } from './lib/incremental-write.mjs';
34
+ import { applyIncrementalChunks, schemeTaggedContentHash } from './lib/incremental-write.mjs';
35
35
  import { resolveMofloBin } from './lib/resolve-bin.mjs';
36
36
 
37
37
 
@@ -42,6 +42,16 @@ const NAMESPACE = 'code-map';
42
42
  const DB_PATH = memoryDbPath(projectRoot);
43
43
  const HASH_CACHE_PATH = resolve(projectRoot, MOFLO_DIR, 'code-map-hash.txt');
44
44
 
45
+ // Output-scheme version for the skip-cache (#1260). Bump whenever the emitted
46
+ // key scheme changes (chunk kinds, batch sizes, `file:` granularity) so a
47
+ // generator upgrade forces one rebuild + orphan sweep instead of silently
48
+ // skipping and leaving the previous scheme's rows orphaned. This is also the
49
+ // single source of truth for the code-map scheme now that `flo memory code-map`
50
+ // delegates here rather than shipping a second, divergent generator.
51
+ // v2: unified scheme — invalidates every pre-#1260 cache (bare hash or the
52
+ // old directory-level `flo memory code-map` output) exactly once on pickup.
53
+ const SCHEME_VERSION = 2;
54
+
45
55
  // Directories to exclude from indexing
46
56
  const EXCLUDE_DIRS = [
47
57
  'node_modules', 'dist', 'build', '.next', 'coverage',
@@ -786,15 +796,16 @@ async function main() {
786
796
  return;
787
797
  }
788
798
 
789
- // 2. Check hash for incremental skip
790
- const currentHash = computeContentListHash(files);
799
+ // 2. Check hash for incremental skip — scheme-versioned so a scheme change
800
+ // invalidates the cache and forces a rebuild + orphan sweep (#1260).
801
+ const currentHash = schemeTaggedContentHash(files, SCHEME_VERSION);
791
802
 
792
803
  if (statsOnly) {
793
804
  const db = await getDb();
794
805
  const count = countNamespace(db);
795
806
  db.close();
796
807
  log(`Stats: ${files.length} source files, ${count} chunks in code-map namespace`);
797
- log(`File list hash: ${currentHash.slice(0, 12)}...`);
808
+ log(`Scheme v${SCHEME_VERSION}, file list hash: ${currentHash.slice(0, 20)}...`);
798
809
  return;
799
810
  }
800
811
 
@@ -65,6 +65,29 @@ export function computeContentListHash(files) {
65
65
  return hasher.digest('hex');
66
66
  }
67
67
 
68
+ /**
69
+ * Scheme-versioned wrapper around {@link computeContentListHash}. Prefixes the
70
+ * content hash with `v<schemeVersion>:` so the indexer's skip-cache encodes the
71
+ * OUTPUT scheme, not just the input file list (#1260).
72
+ *
73
+ * Why this is load-bearing: the skip gate short-circuits the whole
74
+ * extract+write pipeline (including the orphan sweep) whenever the stored hash
75
+ * matches. If a generator upgrade changes the emitted key scheme (e.g. drops
76
+ * `file:` entries, or two entry points write divergent shapes) while the source
77
+ * file list is unchanged, an unversioned hash still matches → the generator
78
+ * skips → the previous scheme's orphaned rows persist forever. Bumping
79
+ * `schemeVersion` invalidates every stored cache exactly once, forcing a
80
+ * rebuild + orphan sweep that reconciles the namespace. Legacy bare-hash caches
81
+ * (no `v<n>:` prefix) also mismatch, so upgrading consumers self-heal.
82
+ *
83
+ * @param {string[]} files - absolute paths
84
+ * @param {number|string} schemeVersion - monotonically-bumped scheme tag
85
+ * @returns {string} `v<schemeVersion>:<sha256>`
86
+ */
87
+ export function schemeTaggedContentHash(files, schemeVersion) {
88
+ return `v${schemeVersion}:${computeContentListHash(files)}`;
89
+ }
90
+
68
91
  /**
69
92
  * Load `key → content` for every active row in the namespace, optionally
70
93
  * scoped to keys starting with `keyPrefix` (one doc's chunks at a time —
@@ -10,7 +10,7 @@ import { select, confirm, input } from '../prompt.js';
10
10
  import { callMCPTool, MCPClientError } from '../mcp-client.js';
11
11
  import { openDaemonDatabase } from '../memory/daemon-backend.js';
12
12
  import { errorDetail } from '../shared/utils/error-detail.js';
13
- import { legacySwarmPath, runtimePath, memoryDbPath } from '../services/moflo-paths.js';
13
+ import { memoryDbPath } from '../services/moflo-paths.js';
14
14
  import { resolveBridgeDbPath } from '../memory/bridge-core.js';
15
15
  import { findProjectRoot } from '../services/project-root.js';
16
16
  // Memory backends
@@ -1974,129 +1974,43 @@ const rebuildIndexCommand = {
1974
1974
  }
1975
1975
  };
1976
1976
  // ============================================================================
1977
- // code-map subcommand
1977
+ // code-map subcommand — delegates to the single shipped generator (#1260)
1978
1978
  // ============================================================================
1979
- const EXCLUDE_DIRS = [
1980
- 'back-office-template', 'template', '.claude', 'node_modules',
1981
- 'dist', 'build', '.next', 'coverage',
1982
- ];
1983
- const DIR_DESCRIPTIONS = {
1984
- entities: 'MikroORM entity definitions',
1985
- services: 'business logic services',
1986
- routes: 'Fastify route handlers',
1987
- middleware: 'request middleware (auth, validation, tenancy)',
1988
- schemas: 'Zod validation schemas',
1989
- types: 'TypeScript type definitions',
1990
- utils: 'utility helpers',
1991
- config: 'configuration',
1992
- migrations: 'database migrations',
1993
- scripts: 'CLI scripts',
1994
- components: 'React components',
1995
- pages: 'route page components',
1996
- contexts: 'React context providers',
1997
- hooks: 'React custom hooks',
1998
- layout: 'app shell layout',
1999
- themes: 'MUI theme configuration',
2000
- api: 'API client layer',
2001
- locales: 'i18n translation files',
2002
- tests: 'test suites',
2003
- e2e: 'end-to-end tests',
2004
- };
2005
- const TS_PATTERNS = [
2006
- /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+(\w+)(?:\s+extends\s+([\w.]+))?(?:\s+implements\s+([\w,\s.]+))?/,
2007
- /^export\s+(?:default\s+)?interface\s+(\w+)(?:\s+extends\s+([\w,\s.]+))?/,
2008
- /^export\s+(?:default\s+)?type\s+(\w+)\s*[=<]/,
2009
- /^export\s+(?:const\s+)?enum\s+(\w+)/,
2010
- /^export\s+(?:default\s+)?(?:async\s+)?function\s+(\w+)/,
2011
- /^export\s+(?:default\s+)?const\s+(\w+)\s*[=:]/,
2012
- ];
2013
- const ENTITY_DECORATOR = /@Entity\s*\(/;
2014
- const IFACE_MAP_BATCH = 20;
2015
- const TYPE_INDEX_BATCH = 80;
2016
- function detectKind(line) {
2017
- if (/\bclass\b/.test(line))
2018
- return 'class';
2019
- if (/\binterface\b/.test(line))
2020
- return 'interface';
2021
- if (/\btype\b/.test(line))
2022
- return 'type';
2023
- if (/\benum\b/.test(line))
2024
- return 'enum';
2025
- if (/\bfunction\b/.test(line))
2026
- return 'function';
2027
- if (/\bconst\b/.test(line))
2028
- return 'const';
2029
- return 'export';
2030
- }
2031
- function extractTypesFromFile(filePath, projectRoot) {
2032
- const fullPath = pathModule.resolve(projectRoot, filePath);
2033
- if (!fs.existsSync(fullPath))
2034
- return [];
2035
- let content;
2036
- try {
2037
- content = fs.readFileSync(fullPath, 'utf-8');
2038
- }
2039
- catch {
2040
- return [];
2041
- }
2042
- const lines = content.split(/\r?\n/);
2043
- const types = [];
2044
- const seen = new Set();
2045
- let isEntityNext = false;
2046
- for (let i = 0; i < lines.length; i++) {
2047
- const line = lines[i].trim();
2048
- if (ENTITY_DECORATOR.test(line)) {
2049
- isEntityNext = true;
1979
+ /**
1980
+ * Resolve the file-level code-map generator (`bin/generate-code-map.mjs`) for
1981
+ * `cwd`, reusing moflo's canonical bin resolver so this dist CLI command and
1982
+ * the batch indexer (`.claude/scripts/index-all.mjs`) spawn the exact same
1983
+ * script — one generator, one key scheme (#1260).
1984
+ *
1985
+ * Cross-platform: the resolver is located via a cwd-relative path (never a
1986
+ * file-relative `../../../../` depth — that broke #1126) and imported as a
1987
+ * `file://` URL so Windows accepts the absolute path (Rule #1). Returns null
1988
+ * when no copy is found (broken install / not-yet-synced worktree).
1989
+ */
1990
+ async function resolveCodeMapGenerator(cwd) {
1991
+ const { pathToFileURL } = await import('url');
1992
+ const resolverCandidates = [
1993
+ pathModule.join(cwd, 'node_modules', 'moflo', 'bin', 'lib', 'resolve-bin.mjs'),
1994
+ pathModule.join(cwd, 'bin', 'lib', 'resolve-bin.mjs'), // dev / source tree
1995
+ ];
1996
+ for (const resolverPath of resolverCandidates) {
1997
+ if (!fs.existsSync(resolverPath))
2050
1998
  continue;
1999
+ try {
2000
+ const { resolveMofloBin } = await import(pathToFileURL(resolverPath).href);
2001
+ return (resolveMofloBin(cwd, 'flo-codemap', 'generate-code-map.mjs', {
2002
+ includeDevFallback: true,
2003
+ }) ?? null);
2051
2004
  }
2052
- for (const pattern of TS_PATTERNS) {
2053
- const m = line.match(pattern);
2054
- if (m && m[1] && !seen.has(m[1])) {
2055
- seen.add(m[1]);
2056
- const kind = detectKind(line);
2057
- const bases = (m[2] || '').trim();
2058
- const implements_ = (m[3] || '').trim();
2059
- types.push({
2060
- name: m[1],
2061
- kind,
2062
- bases: bases || null,
2063
- implements: implements_ || null,
2064
- isEntity: isEntityNext,
2065
- file: filePath,
2066
- });
2067
- isEntityNext = false;
2068
- break;
2069
- }
2070
- }
2071
- if (isEntityNext && !line.startsWith('@') && !line.startsWith('export') && line.length > 0) {
2072
- isEntityNext = false;
2005
+ catch {
2006
+ // Fall through to the next resolver candidate.
2073
2007
  }
2074
2008
  }
2075
- return types;
2076
- }
2077
- function getProjectName(filePath) {
2078
- const parts = filePath.split('/');
2079
- if (parts[0] === 'packages' && parts.length >= 2)
2080
- return `${parts[0]}/${parts[1]}`;
2081
- if (parts[0] === 'back-office' && parts.length >= 2)
2082
- return `${parts[0]}/${parts[1]}`;
2083
- if (parts[0] === 'customer-portal' && parts.length >= 2)
2084
- return `${parts[0]}/${parts[1]}`;
2085
- if (parts[0] === 'admin-console' && parts.length >= 2)
2086
- return `${parts[0]}/${parts[1]}`;
2087
- if (parts[0] === 'webhooks' && parts.length >= 2)
2088
- return `${parts[0]}/${parts[1]}`;
2089
- if (parts[0] === 'mobile-app')
2090
- return 'mobile-app';
2091
- if (parts[0] === 'tests')
2092
- return 'tests';
2093
- if (parts[0] === 'scripts')
2094
- return 'scripts';
2095
- return parts[0];
2009
+ return null;
2096
2010
  }
2097
2011
  const codeMapCommand = {
2098
2012
  name: 'code-map',
2099
- description: 'Generate structural code map (project overviews, directory details, type indexes) into code-map namespace',
2013
+ description: 'Generate the structural code map (project overviews, directory details, type + file indexes) into the code-map namespace',
2100
2014
  options: [
2101
2015
  {
2102
2016
  name: 'force',
@@ -2134,284 +2048,49 @@ const codeMapCommand = {
2134
2048
  const verbose = ctx.flags.verbose;
2135
2049
  const statsOnly = ctx.flags.stats;
2136
2050
  const skipEmbeddings = ctx.flags.noEmbeddings;
2137
- const NAMESPACE = 'code-map';
2138
- const fs = await import('fs');
2139
- const pathMod = await import('path');
2140
- const { execSync } = await import('child_process');
2141
- const { createHash } = await import('crypto');
2142
2051
  const cwd = ctx.cwd || process.cwd();
2143
- // Post-#1168: canonical at `.moflo/memory/code-map-hash.txt`. Legacy
2144
- // `.swarm/code-map-hash.txt` is read-only fallback for upgrade scenarios.
2145
- const hashCachePath = runtimePath('memory', 'code-map-hash.txt');
2146
- const legacyHashCachePath = legacySwarmPath('code-map-hash.txt');
2147
2052
  output.writeln();
2148
2053
  output.writeln(output.bold('Generating Code Map'));
2149
2054
  output.writeln(output.dim('─'.repeat(50)));
2150
- // 1. Get source files via git
2151
- let raw;
2152
- try {
2153
- raw = execSync(`git ls-files -- "*.ts" "*.tsx" "*.js" "*.mjs" "*.jsx"`, { cwd, encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, windowsHide: true }).trim();
2154
- }
2155
- catch {
2156
- output.printError('Failed to list source files via git. Is this a git repository?');
2055
+ // Single source of truth (#1260): delegate to the one shipped generator —
2056
+ // the same `bin/generate-code-map.mjs` that `index-all.mjs` runs. This
2057
+ // command previously carried a SECOND, divergent generator (directory-level
2058
+ // scheme with no `file:` entries, a path-list-only skip hash at a different
2059
+ // cache path, and a DELETE+reinsert write that nulled embeddings). The two
2060
+ // fought over the `code-map` namespace and accumulated orphaned rows that
2061
+ // neither skip-cache could reconcile. Delegating here makes divergence
2062
+ // structurally impossible.
2063
+ const script = await resolveCodeMapGenerator(cwd);
2064
+ if (!script) {
2065
+ output.printError('Could not locate the code-map generator (bin/generate-code-map.mjs). Is the moflo package installed?');
2157
2066
  return { success: false, exitCode: 1 };
2158
2067
  }
2159
- const files = raw ? raw.split(/\r?\n/).filter((f) => {
2160
- for (const ex of EXCLUDE_DIRS) {
2161
- if (f.startsWith(ex + '/') || f.startsWith(ex + '\\'))
2162
- return false;
2163
- }
2164
- return true;
2165
- }) : [];
2166
- if (files.length === 0) {
2167
- output.writeln('No source files found.');
2168
- return { success: true };
2169
- }
2170
- output.writeln(`Found ${files.length} source files`);
2171
- // 2. Hash check
2172
- const sorted = [...files].sort();
2173
- const currentHash = createHash('sha256').update(sorted.join('\n')).digest('hex');
2174
- if (statsOnly) {
2175
- const { db } = await openDb(cwd);
2176
- const stmt = db.prepare(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = ?`);
2177
- stmt.bind([NAMESPACE]);
2178
- const count = stmt.step() ? stmt.getAsObject().cnt : 0;
2179
- stmt.free();
2180
- db.close();
2181
- output.writeln(`Stats: ${files.length} source files, ${count} chunks in code-map namespace`);
2182
- output.writeln(`File list hash: ${currentHash.slice(0, 12)}...`);
2183
- return { success: true };
2184
- }
2185
- // Check if unchanged — canonical first, then legacy `.swarm/` fallback.
2186
- const cachedReadPath = fs.existsSync(hashCachePath)
2187
- ? hashCachePath
2188
- : (fs.existsSync(legacyHashCachePath) ? legacyHashCachePath : null);
2189
- if (!forceRegen && cachedReadPath) {
2190
- const cached = fs.readFileSync(cachedReadPath, 'utf-8').trim();
2191
- if (cached === currentHash) {
2192
- const { db } = await openDb(cwd);
2193
- const stmt = db.prepare(`SELECT COUNT(*) as cnt FROM memory_entries WHERE namespace = ?`);
2194
- stmt.bind([NAMESPACE]);
2195
- const count = stmt.step() ? stmt.getAsObject().cnt : 0;
2196
- stmt.free();
2197
- db.close();
2198
- if (count > 0) {
2199
- output.writeln(output.dim(`Skipping -- file list unchanged (${count} chunks in DB)`));
2200
- return { success: true };
2201
- }
2202
- }
2203
- }
2204
- // 3. Extract types
2205
- output.writeln('Extracting type declarations...');
2206
- const allTypes = [];
2207
- const filesByProject = {};
2208
- const typesByProject = {};
2209
- const typesByDir = {};
2210
- for (const file of files) {
2211
- const project = getProjectName(file);
2212
- if (!filesByProject[project])
2213
- filesByProject[project] = [];
2214
- filesByProject[project].push(file);
2215
- const types = extractTypesFromFile(file, cwd);
2216
- for (const t of types) {
2217
- allTypes.push(t);
2218
- if (!typesByProject[project])
2219
- typesByProject[project] = [];
2220
- typesByProject[project].push(t);
2221
- const dir = pathModule.dirname(t.file).replace(/\\/g, '/');
2222
- if (!typesByDir[dir])
2223
- typesByDir[dir] = [];
2224
- typesByDir[dir].push(t);
2225
- }
2226
- }
2227
- output.writeln(`Extracted ${allTypes.length} types from ${Object.keys(filesByProject).length} projects`);
2228
- // 4. Generate chunks
2229
- const allChunks = [];
2230
- // Project overviews
2231
- for (const [project, projFiles] of Object.entries(filesByProject)) {
2232
- const types = typesByProject[project] || [];
2233
- const dirMap = {};
2234
- for (const t of types) {
2235
- const rel = pathModule.relative(project, pathModule.dirname(t.file)).replace(/\\/g, '/') || '(root)';
2236
- if (!dirMap[rel])
2237
- dirMap[rel] = [];
2238
- dirMap[rel].push(t.name);
2239
- }
2240
- // Detect primary language
2241
- let tsx = 0, ts = 0, js = 0;
2242
- for (const f of projFiles) {
2243
- const ext = pathModule.extname(f);
2244
- if (ext === '.tsx' || ext === '.jsx')
2245
- tsx++;
2246
- else if (ext === '.ts')
2247
- ts++;
2248
- else
2249
- js++;
2250
- }
2251
- const lang = tsx > ts && tsx > js ? 'React/TypeScript' : ts >= js ? 'TypeScript' : 'JavaScript';
2252
- let content = `# ${project} [${lang}, ${projFiles.length} files, ${types.length} types]\n\n`;
2253
- for (const dir of Object.keys(dirMap).sort()) {
2254
- const names = dirMap[dir];
2255
- const lastDir = dir.split('/').pop() || '';
2256
- const desc = DIR_DESCRIPTIONS[lastDir];
2257
- const descStr = desc ? ` -- ${desc}` : '';
2258
- const shown = names.slice(0, 8).join(', ');
2259
- const overflow = names.length > 8 ? `, ... (+${names.length - 8} more)` : '';
2260
- content += ` ${dir}${descStr}: ${shown}${overflow}\n`;
2261
- }
2262
- allChunks.push({
2263
- key: `project:${project}`,
2264
- content: content.trim(),
2265
- metadata: { kind: 'project-overview', project, language: lang, fileCount: projFiles.length, typeCount: types.length },
2266
- tags: ['project', project],
2267
- });
2268
- }
2269
- // Directory details
2270
- for (const [dir, types] of Object.entries(typesByDir)) {
2271
- if (types.length < 2)
2272
- continue;
2273
- const lastDir = dir.split('/').pop() || '';
2274
- const desc = DIR_DESCRIPTIONS[lastDir];
2275
- let content = `# ${dir} (${types.length} types)\n`;
2276
- if (desc)
2277
- content += `${desc}\n`;
2278
- content += '\n';
2279
- const sortedTypes = [...types].sort((a, b) => a.name.localeCompare(b.name));
2280
- for (const t of sortedTypes) {
2281
- const suffix = [];
2282
- if (t.bases)
2283
- suffix.push(`: ${t.bases}`);
2284
- if (t.implements)
2285
- suffix.push(`: ${t.implements}`);
2286
- const suffixStr = suffix.length ? ` ${suffix.join(' ')}` : '';
2287
- const fileName = pathModule.basename(t.file);
2288
- content += ` ${t.name}${suffixStr} (${fileName})\n`;
2289
- }
2290
- allChunks.push({
2291
- key: `dir:${dir}`,
2292
- content: content.trim(),
2293
- metadata: { kind: 'directory-detail', directory: dir, typeCount: types.length },
2294
- tags: ['directory', dir.split('/')[0]],
2295
- });
2296
- }
2297
- // Interface maps
2298
- const interfaces = new Map();
2299
- for (const t of allTypes) {
2300
- if (t.kind === 'interface' && !interfaces.has(t.name)) {
2301
- interfaces.set(t.name, { defined: t.file, implementations: [] });
2302
- }
2303
- }
2304
- for (const t of allTypes) {
2305
- if (t.kind !== 'class')
2306
- continue;
2307
- const impls = t.implements ? t.implements.split(',').map((s) => s.trim()) : [];
2308
- const bases = t.bases ? [t.bases.trim()] : [];
2309
- for (const iface of [...impls, ...bases]) {
2310
- if (interfaces.has(iface)) {
2311
- interfaces.get(iface).implementations.push({ name: t.name, project: getProjectName(t.file) });
2312
- }
2313
- }
2314
- }
2315
- const mapped = Array.from(interfaces.entries())
2316
- .filter(([, v]) => v.implementations.length > 0)
2317
- .sort(([a], [b]) => a.localeCompare(b));
2318
- if (mapped.length > 0) {
2319
- const totalBatches = Math.ceil(mapped.length / IFACE_MAP_BATCH);
2320
- for (let i = 0; i < mapped.length; i += IFACE_MAP_BATCH) {
2321
- const batch = mapped.slice(i, i + IFACE_MAP_BATCH);
2322
- const batchNum = Math.floor(i / IFACE_MAP_BATCH) + 1;
2323
- let content = `# Interface-to-Implementation Map (${batchNum}/${totalBatches})\n\n`;
2324
- for (const [name, info] of batch) {
2325
- const implStr = info.implementations.map(impl => `${impl.name} (${impl.project})`).join(', ');
2326
- content += ` ${name} -> ${implStr}\n`;
2327
- }
2328
- allChunks.push({
2329
- key: `iface-map:${batchNum}`,
2330
- content: content.trim(),
2331
- metadata: { kind: 'interface-map', batch: batchNum, totalBatches, count: batch.length },
2332
- tags: ['interface-map'],
2333
- });
2334
- }
2335
- }
2336
- // Type index
2337
- const sortedAllTypes = [...allTypes].sort((a, b) => a.name.localeCompare(b.name));
2338
- const typeIdxTotalBatches = Math.ceil(sortedAllTypes.length / TYPE_INDEX_BATCH);
2339
- for (let i = 0; i < sortedAllTypes.length; i += TYPE_INDEX_BATCH) {
2340
- const batch = sortedAllTypes.slice(i, i + TYPE_INDEX_BATCH);
2341
- const batchNum = Math.floor(i / TYPE_INDEX_BATCH) + 1;
2342
- let content = `# Type Index (batch ${batchNum}, ${batch.length} types)\n\n`;
2343
- for (const t of batch) {
2344
- const ext = pathModule.extname(t.file);
2345
- const lang = (ext === '.tsx' || ext === '.jsx') ? 'tsx' : ext === '.ts' ? 'ts' : ext === '.mjs' ? 'esm' : 'js';
2346
- content += ` ${t.name} -> ${t.file} [${lang}]\n`;
2347
- }
2348
- allChunks.push({
2349
- key: `type-index:${batchNum}`,
2350
- content: content.trim(),
2351
- metadata: { kind: 'type-index', batch: batchNum, totalBatches: typeIdxTotalBatches, count: batch.length },
2352
- tags: ['type-index'],
2068
+ const scriptArgs = [];
2069
+ if (forceRegen)
2070
+ scriptArgs.push('--force');
2071
+ if (verbose)
2072
+ scriptArgs.push('--verbose');
2073
+ if (statsOnly)
2074
+ scriptArgs.push('--stats');
2075
+ if (skipEmbeddings)
2076
+ scriptArgs.push('--no-embeddings');
2077
+ const { execFileSync } = await import('child_process');
2078
+ try {
2079
+ // process.execPath (not bare 'node') so the child uses the exact same
2080
+ // interpreter with no PATH dependency — robust on Windows where the
2081
+ // launching node need not be on PATH (Rule #1). execFileSync passes argv
2082
+ // as an array, so spaces in the path (e.g. "Program Files") are safe.
2083
+ execFileSync(process.execPath, [script, ...scriptArgs], {
2084
+ cwd,
2085
+ stdio: 'inherit',
2086
+ windowsHide: true,
2087
+ timeout: 5 * 60_000,
2353
2088
  });
2354
2089
  }
2355
- output.writeln(`Generated ${allChunks.length} chunks`);
2356
- if (verbose) {
2357
- const projectCount = allChunks.filter(c => c.metadata.kind === 'project-overview').length;
2358
- const dirCount = allChunks.filter(c => c.metadata.kind === 'directory-detail').length;
2359
- const ifaceCount = allChunks.filter(c => c.metadata.kind === 'interface-map').length;
2360
- const typeIdxCount = allChunks.filter(c => c.metadata.kind === 'type-index').length;
2361
- output.writeln(` Project overviews: ${projectCount}`);
2362
- output.writeln(` Directory details: ${dirCount}`);
2363
- output.writeln(` Interface maps: ${ifaceCount}`);
2364
- output.writeln(` Type index: ${typeIdxCount}`);
2365
- }
2366
- // 5. Write to DB
2367
- output.writeln('Writing to memory database...');
2368
- const { db, dbPath } = await openDb(cwd);
2369
- // Clear old code-map entries
2370
- db.run(`DELETE FROM memory_entries WHERE namespace = ?`, [NAMESPACE]);
2371
- for (const chunk of allChunks) {
2372
- batchStoreEntry(db, chunk.key, NAMESPACE, chunk.content, chunk.metadata, chunk.tags);
2373
- }
2374
- saveAndCloseDb(db, dbPath);
2375
- // Save hash
2376
- const hashDir = pathModule.dirname(hashCachePath);
2377
- if (!fs.existsSync(hashDir)) {
2378
- fs.mkdirSync(hashDir, { recursive: true });
2379
- }
2380
- fs.writeFileSync(hashCachePath, currentHash, 'utf-8');
2381
- output.printSuccess(`${allChunks.length} chunks written to code-map namespace`);
2382
- // Generate embeddings unless skipped
2383
- if (!skipEmbeddings) {
2384
- output.writeln(output.dim('Generating embeddings for code-map entries...'));
2385
- try {
2386
- const { generateEmbedding } = await import('../memory/memory-initializer.js');
2387
- const { db: db2, dbPath: dbPath2 } = await openDb(cwd);
2388
- const stmt = db2.prepare(`SELECT id, content FROM memory_entries WHERE namespace = ? AND (embedding IS NULL OR embedding = '')`);
2389
- stmt.bind([NAMESPACE]);
2390
- const entries = [];
2391
- while (stmt.step())
2392
- entries.push(stmt.getAsObject());
2393
- stmt.free();
2394
- let embedded = 0;
2395
- for (const entry of entries) {
2396
- try {
2397
- const text = entry.content.substring(0, 1500);
2398
- const { embedding, dimensions, model } = await generateEmbedding(text);
2399
- db2.run(`UPDATE memory_entries SET embedding = ?, embedding_model = ?, embedding_dimensions = ?, updated_at = ? WHERE id = ?`, [JSON.stringify(embedding), model, dimensions, Date.now(), entry.id]);
2400
- embedded++;
2401
- }
2402
- catch { /* skip */ }
2403
- }
2404
- if (embedded > 0) {
2405
- saveAndCloseDb(db2, dbPath2);
2406
- output.printSuccess(`Generated ${embedded} embeddings`);
2407
- }
2408
- else {
2409
- db2.close();
2410
- }
2411
- }
2412
- catch (err) {
2413
- output.writeln(output.dim(` Embedding generation skipped: ${err.message}`));
2414
- }
2090
+ catch (err) {
2091
+ const code = err.status;
2092
+ output.printError(`Code map generation failed${typeof code === 'number' ? ` (exit ${code})` : ''}: ${errorDetail(err)}`);
2093
+ return { success: false, exitCode: typeof code === 'number' ? code : 1 };
2415
2094
  }
2416
2095
  return { success: true };
2417
2096
  }
@@ -90,6 +90,20 @@ function parseNavigation(metadataJson, mode) {
90
90
  headerLevel: meta.headerLevel,
91
91
  };
92
92
  }
93
+ // #1262 lever #1 (dedup-by-source): collapse multiple representations of the
94
+ // SAME underlying source to one result slot. Dogfooding showed a dense query's
95
+ // top-8 is routinely ~40% duplicates — e.g. one file indexed in code-map as
96
+ // `file:<path>` AND in patterns as `pattern:file:<path>`, or a test surfaced as
97
+ // both `file:<path>` and `test-file:<path>`. Path-bearing keys reduce to their
98
+ // path; symbol/chunk/learning keys keep their own identity (a `pattern:class:X`
99
+ // symbol view is NOT the same source as the file, so it survives separately).
100
+ // Operates purely on logical DB keys (always forward-slash), so platform-agnostic.
101
+ function sourceIdOf(key) {
102
+ const pathMatch = key.match(/^(?:pattern:)?(?:file|test-file|dir):(.+)$/);
103
+ if (pathMatch)
104
+ return `src:${pathMatch[1]}`;
105
+ return key.startsWith('pattern:') ? key.slice('pattern:'.length) : key;
106
+ }
93
107
  function shapeRetrievedEntry(entry) {
94
108
  let value = entry.content;
95
109
  try {
@@ -325,39 +339,53 @@ export const memoryTools = [
325
339
  },
326
340
  {
327
341
  name: 'memory_search',
328
- description: 'Semantic vector search using HNSW index (150x-12,500x faster than keyword search). When a result has a non-null `navigation` crumb, you MUST traverse via `memory_get_neighbors` — bulk `memory_retrieve` per hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.',
342
+ description: 'Semantic vector search using HNSW index (150x-12,500x faster than keyword search). On chunk hits (non-null `navigation`) you MUST get adjacent context via chunk traversal, never a full-doc `Read`: prefer `expand: "neighbors"` in THIS call (cheapest inlines prev/next), else `memory_get_neighbors`. Bulk `memory_retrieve` per hit is a protocol violation. See `.claude/guidance/moflo-memory-protocol.md`.',
329
343
  category: 'memory',
330
344
  inputSchema: {
331
345
  type: 'object',
332
346
  properties: {
333
347
  query: { type: 'string', description: 'Search query (semantic similarity)' },
334
348
  namespace: { type: 'string', description: 'Namespace to search (default: all namespaces)' },
335
- limit: { type: 'number', description: 'Maximum results (default: 8)' },
349
+ limit: { type: 'number', description: 'Maximum results (default: 8). Multiple representations of the same source (a file indexed in both code-map and patterns, a test surfaced as both file and test-file) are collapsed to one before this cap, so every slot is a distinct source.' },
336
350
  threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.5)' },
351
+ expand: {
352
+ type: 'string',
353
+ enum: ['neighbors'],
354
+ description: "Set to 'neighbors' to inline each chunk hit's adjacent (prev/next) chunk content in this single call. Use instead of a follow-up full-doc `Read` when you need surrounding context. Off by default to keep the envelope lean.",
355
+ },
337
356
  },
338
357
  required: ['query'],
339
358
  },
340
359
  handler: async (input) => {
341
360
  await ensureInitialized();
342
- const { searchEntries } = await getMemoryFunctions();
361
+ const { searchEntries, getEntry } = await getMemoryFunctions();
343
362
  const query = input.query;
344
363
  const namespace = input.namespace || 'all';
345
- // #1053 S6: tighter defaults fewer hits, higher relevance bar.
364
+ // #1053 S6: injected-token bar. Each returned hit is injected verbatim,
365
+ // so keep the cap tight — but #1262 dedup (below) makes every one of these
366
+ // slots a DISTINCT source rather than 8 slots holding ~5 uniques + dupes.
346
367
  const limit = input.limit || 8;
347
368
  // Falsiness check would coerce a caller-supplied 0 to default and silently
348
369
  // filter low-similarity matches; use a typeof guard so explicit zero
349
370
  // means "no threshold" (#837).
350
371
  const threshold = typeof input.threshold === 'number' ? input.threshold : 0.5;
372
+ // #1262 lever #3: opt-in single-call neighbor expansion.
373
+ const wantExpand = input.expand === 'neighbors' || input.expand === true;
374
+ // #1262 lever #1: over-fetch so dedup-by-source can still return a FULL
375
+ // `limit` distinct sources. Local HNSW makes the extra hits free (they're
376
+ // just not injected); 3x headroom clears the ~40% duplication observed in
377
+ // dogfooding. Capped so an explicit large limit can't blow up the fetch.
378
+ const overscan = Math.min(limit * 3, 50);
351
379
  validateMemoryInput(undefined, undefined, query);
352
380
  try {
353
381
  const result = await searchEntries({
354
382
  query,
355
383
  namespace,
356
- limit,
384
+ limit: overscan,
357
385
  threshold,
358
386
  });
359
387
  // Parse JSON values in results
360
- const results = result.results.map(r => {
388
+ const mapped = result.results.map(r => {
361
389
  let value = r.content;
362
390
  try {
363
391
  value = JSON.parse(r.content);
@@ -379,13 +407,67 @@ export const memoryTools = [
379
407
  navigation,
380
408
  };
381
409
  });
410
+ // #1262 lever #1: collapse duplicate representations of the same source,
411
+ // keeping the highest-scoring one (results arrive score-desc), then trim
412
+ // to `limit`. Net effect vs. the old code: `limit` DISTINCT sources
413
+ // instead of `limit` slots that were often ~40% near-duplicates.
414
+ const seenSources = new Set();
415
+ const results = [];
416
+ for (const hit of mapped) {
417
+ const id = sourceIdOf(hit.key);
418
+ if (seenSources.has(id))
419
+ continue;
420
+ seenSources.add(id);
421
+ results.push(hit);
422
+ if (results.length >= limit)
423
+ break;
424
+ }
425
+ // #1262 lever #3: when asked, fetch each chunk hit's prev/next content
426
+ // inline (one round-trip) so the model doesn't fall back to a full-doc
427
+ // `Read` for surrounding context. Opt-in keeps the default lean.
428
+ if (wantExpand) {
429
+ await Promise.all(results.map(async (hit) => {
430
+ const nav = hit.navigation;
431
+ if (!nav)
432
+ return; // non-chunk hit — nothing adjacent to fetch
433
+ const targets = [
434
+ { position: 'prev', key: nav.prevChunk },
435
+ { position: 'next', key: nav.nextChunk },
436
+ ].filter((t) => Boolean(t.key));
437
+ if (targets.length === 0)
438
+ return;
439
+ const fetched = await Promise.all(targets.map(async (t) => {
440
+ const res = await getEntry({ key: t.key, namespace: hit.namespace });
441
+ if (!res.found || !res.entry)
442
+ return null;
443
+ const entry = res.entry;
444
+ let value = entry.content;
445
+ try {
446
+ value = JSON.parse(entry.content);
447
+ }
448
+ catch { /* keep string */ }
449
+ return { position: t.position, key: t.key, value };
450
+ }));
451
+ const neighbors = fetched.filter((n) => n !== null);
452
+ if (neighbors.length > 0)
453
+ hit.expanded = neighbors;
454
+ }));
455
+ }
382
456
  notifyMemoryGate();
457
+ // #1262 lever #3: steer away from full-doc `Read` toward the cheap
458
+ // adjacent-context path — but only when it's actionable (chunk hits
459
+ // present and the caller didn't already expand), so the hint itself
460
+ // costs no tokens on the common case.
461
+ const nextStep = !wantExpand && results.some(r => r.navigation)
462
+ ? "Chunk hits present. For adjacent context, re-call memory_search with expand:'neighbors' (one call) rather than Read-ing parentPath — full-doc Read costs far more tokens."
463
+ : undefined;
383
464
  // #1053 S6: searchTime dropped from MCP envelope (CLI keeps it for
384
465
  // human reading); `backend` retained — doctor reads it (#1053 epic).
385
466
  return {
386
467
  query,
387
468
  results,
388
469
  total: results.length,
470
+ ...(nextStep ? { nextStep } : {}),
389
471
  backend: BACKEND_LABEL,
390
472
  };
391
473
  }
@@ -2,5 +2,5 @@
2
2
  * Auto-generated by build. Do not edit manually.
3
3
  * Source of truth: root package.json → scripts/sync-version.mjs
4
4
  */
5
- export const VERSION = '4.11.5';
5
+ export const VERSION = '4.11.7';
6
6
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moflo",
3
- "version": "4.11.5",
3
+ "version": "4.11.7",
4
4
  "description": "MoFlo — AI agent orchestration for Claude Code. A standalone, opinionated toolkit with semantic memory, learned routing, gates, spells, and the /flo issue-execution skill.",
5
5
  "main": "dist/src/cli/index.js",
6
6
  "type": "module",
@@ -94,7 +94,7 @@
94
94
  "@typescript-eslint/eslint-plugin": "^7.18.0",
95
95
  "@typescript-eslint/parser": "^7.18.0",
96
96
  "eslint": "^8.0.0",
97
- "moflo": "^4.11.4",
97
+ "moflo": "^4.11.6",
98
98
  "tsx": "^4.21.0",
99
99
  "typescript": "^5.9.3",
100
100
  "vitest": "^4.0.0"