moflo 4.11.6 → 4.11.8
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/guidance/shipped/moflo-memory-protocol.md +3 -2
- package/.claude/skills/fl/phases.md +8 -0
- package/.claude/skills/fl/ticket.md +33 -11
- package/dist/src/cli/commands/epic.js +41 -6
- package/dist/src/cli/epic/detection.js +56 -1
- package/dist/src/cli/epic/index.js +1 -1
- package/dist/src/cli/mcp-tools/memory-tools.js +88 -6
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
## Rule (MUST)
|
|
6
6
|
|
|
7
|
-
When a search hit carries a non-null `navigation` field
|
|
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
|
|
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-*` |
|
|
@@ -224,6 +224,14 @@ gh issue edit <issue-number> --remove-label "in-progress" --add-label "ready-for
|
|
|
224
224
|
gh issue comment <issue-number> --body "PR created: <pr-url>"
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
+
If the story body carries an `Epic: #<n>` back-reference (decomposition writes it — `./ticket.md`) **and** `--epic-branch` is not set, sync the parent epic — check this story's box off and close the epic if it was the last:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
flo epic checkoff <epic-number> <story-number>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Idempotent and safe to skip when there is no back-reference. `--epic-branch` runs skip it — the epic orchestrator owns checklist state there. The box flips when the work is delivered (PR opened), matching the orchestrator; if a PR is later rejected, reopen the epic manually.
|
|
234
|
+
|
|
227
235
|
### 5.5 Finalize run record (Flo Runs dashboard)
|
|
228
236
|
|
|
229
237
|
Update the tasklist row written in Phase 0 with the terminal status. Same `runId`, `upsert: true`. On success:
|
|
@@ -35,25 +35,47 @@ When promoting to epic:
|
|
|
35
35
|
2. Each story should be completable in a single PR.
|
|
36
36
|
3. Stories should have clear boundaries (one concern per story).
|
|
37
37
|
4. Order stories by dependency (independent ones first).
|
|
38
|
-
5.
|
|
39
|
-
6.
|
|
38
|
+
5. Establish the epic issue **first** so its number exists before the stories do (either the promoted parent issue or a freshly created epic).
|
|
39
|
+
6. Create each story as a GitHub issue with its own Description, Acceptance Criteria, Test Cases, **and an `Epic: #<epic-number>` back-reference line at the top of the body**.
|
|
40
|
+
7. Fill in the epic's `## Stories` checklist with a `- [ ] #<story-number>` line for every story.
|
|
41
|
+
|
|
42
|
+
The **back-reference is load-bearing**, not decoration: a story is usually worked on later via a standalone `/flo <story>` run, not through `flo epic run`. That standalone run reads the `Epic: #<n>` line to find its parent, check its box off, and close the epic when it is the last story. Without the back-reference the epic silently drifts out of sync — the exact miss this step exists to prevent. See `./phases.md` Phase 5.6.
|
|
40
43
|
|
|
41
44
|
## Epic decomposition (score >= 7)
|
|
42
45
|
|
|
46
|
+
The order matters: the epic must exist before the stories so each story can carry the `Epic: #<n>` back-reference, and the epic's checklist is filled in last.
|
|
47
|
+
|
|
48
|
+
Pass multi-line bodies as a single literal `--body "..."` string (portable — no `printf`/heredoc, which are not guaranteed on every consumer OS; Rule #1). For very large bodies write the text to a file with the Write tool and use `--body-file <path>`.
|
|
49
|
+
|
|
43
50
|
```bash
|
|
44
|
-
# Step 1:
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
# Step 1: establish the epic issue and capture its number (EPIC).
|
|
52
|
+
# Promoting an existing issue — it *is* the epic; add the label now:
|
|
53
|
+
gh issue edit <parent-number> --add-label "epic" # EPIC = <parent-number>
|
|
54
|
+
# Or create a fresh epic with an empty Stories section:
|
|
55
|
+
gh issue create --title "Epic: <title>" --label "epic" --body "<## Overview + empty ## Stories section>"
|
|
56
|
+
# capture EPIC = the epic issue number from whichever path above
|
|
57
|
+
|
|
58
|
+
# Step 2: create each story WITH the Epic back-reference as the first line; capture each number.
|
|
59
|
+
# (repeat for all stories, typically 2–6)
|
|
60
|
+
gh issue create --title "Story: <story-title>" --label "story" --body "<Epic: #<EPIC> + ## Description + ## Acceptance Criteria + ## Suggested Test Cases>"
|
|
61
|
+
|
|
62
|
+
# Step 3: fill the epic's ## Stories checklist with a - [ ] #<story> line per story.
|
|
63
|
+
gh issue edit <EPIC> --body "<epic body with the ## Stories checklist filled in>"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Story body format** — the first line is the back-reference; the standalone `/flo <story>` run (`./phases.md` Phase 5.6) parses `Epic: #<n>` from it:
|
|
47
67
|
|
|
48
|
-
|
|
68
|
+
```markdown
|
|
69
|
+
Epic: #<epic-number>
|
|
49
70
|
|
|
50
|
-
|
|
71
|
+
## Description
|
|
72
|
+
<what and why>
|
|
51
73
|
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
## Acceptance Criteria
|
|
75
|
+
- [ ] ...
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
|
|
77
|
+
## Suggested Test Cases
|
|
78
|
+
- ...
|
|
57
79
|
```
|
|
58
80
|
|
|
59
81
|
**Epic body format** — the `## Stories` checklist with `- [ ] #<number>` is what enables epic detection, story extraction, and progress tracking:
|
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* flo epic run 42 --verbose Echo each step's stdout/stderr after it completes
|
|
13
13
|
* flo epic status <epic-number> Check progress via memory
|
|
14
14
|
* flo epic reset <epic-number> Clear epic memory state
|
|
15
|
+
* flo epic checkoff <epic> <story> Check a story off; close the epic if it was the last
|
|
15
16
|
*/
|
|
16
17
|
import { readFileSync } from 'node:fs';
|
|
17
18
|
import { execSync } from 'node:child_process';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
|
-
import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, } from '../epic/index.js';
|
|
20
|
+
import { isEpicIssue, fetchEpicIssue, extractStories, enrichStoryNames, checkOffStoryInEpic, } from '../epic/index.js';
|
|
20
21
|
import { runEpicSpell, } from '../epic/runner-adapter.js';
|
|
21
22
|
import { locateMofloModulePath } from '../services/moflo-require.js';
|
|
22
23
|
import { select } from '../prompt.js';
|
|
@@ -362,6 +363,36 @@ async function resetEpic(epicNumber) {
|
|
|
362
363
|
return { success: true };
|
|
363
364
|
}
|
|
364
365
|
// ============================================================================
|
|
366
|
+
// Subcommand: checkoff
|
|
367
|
+
// ============================================================================
|
|
368
|
+
async function checkOffStory(epicArg, storyArg) {
|
|
369
|
+
const epic = parseInt(epicArg, 10);
|
|
370
|
+
const story = parseInt(storyArg, 10);
|
|
371
|
+
if (isNaN(epic) || isNaN(story)) {
|
|
372
|
+
return { success: false, message: 'Usage: flo epic checkoff <epic-number> <story-number>' };
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const { checked, epicClosed, alreadyClosed } = await checkOffStoryInEpic(epic, story);
|
|
376
|
+
const parts = [];
|
|
377
|
+
parts.push(checked
|
|
378
|
+
? `Checked off story #${story} in epic #${epic}.`
|
|
379
|
+
: `Story #${story} was already checked (or not listed) in epic #${epic}.`);
|
|
380
|
+
if (epicClosed)
|
|
381
|
+
parts.push(`All stories complete — closed epic #${epic}.`);
|
|
382
|
+
else if (alreadyClosed)
|
|
383
|
+
parts.push(`Epic #${epic} was already closed.`);
|
|
384
|
+
const message = parts.join(' ');
|
|
385
|
+
console.log(`[epic] ${message}`);
|
|
386
|
+
return { success: true, message };
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
return {
|
|
390
|
+
success: false,
|
|
391
|
+
message: buildFailureSummary(err.message, { stepId: 'checkoff' }),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// ============================================================================
|
|
365
396
|
// Preflight warning interactive resolver
|
|
366
397
|
// ============================================================================
|
|
367
398
|
function isInteractive() {
|
|
@@ -461,6 +492,7 @@ const epicCommand = {
|
|
|
461
492
|
{ command: 'flo epic run 42', description: 'Explicit run subcommand (same as above)' },
|
|
462
493
|
{ command: 'flo epic status 42', description: 'Check epic progress' },
|
|
463
494
|
{ command: 'flo epic reset 42', description: 'Reset epic state for re-run' },
|
|
495
|
+
{ command: 'flo epic checkoff 42 43', description: 'Check story #43 off in epic #42; close #42 if it was the last story' },
|
|
464
496
|
],
|
|
465
497
|
action: async (ctx) => {
|
|
466
498
|
const subcommand = ctx.args?.[0];
|
|
@@ -469,10 +501,11 @@ const epicCommand = {
|
|
|
469
501
|
console.log(' flo epic <command> [args] [flags]');
|
|
470
502
|
console.log('');
|
|
471
503
|
console.log('Commands:');
|
|
472
|
-
console.log(' <issue-number>
|
|
473
|
-
console.log(' run <issue-number>
|
|
474
|
-
console.log(' status <epic-number>
|
|
475
|
-
console.log(' reset <epic-number>
|
|
504
|
+
console.log(' <issue-number> Execute epic (shorthand for "run")');
|
|
505
|
+
console.log(' run <issue-number> Execute a GitHub epic via spell engine');
|
|
506
|
+
console.log(' status <epic-number> Check epic progress');
|
|
507
|
+
console.log(' reset <epic-number> Reset epic state for re-run');
|
|
508
|
+
console.log(' checkoff <epic> <story> Check a story off in its epic; close the epic if it was the last');
|
|
476
509
|
console.log('');
|
|
477
510
|
console.log('Flags:');
|
|
478
511
|
console.log(' --strategy <name> single-branch (default) or auto-merge');
|
|
@@ -518,8 +551,10 @@ const epicCommand = {
|
|
|
518
551
|
return showStatus(commandArgs[0] || '');
|
|
519
552
|
case 'reset':
|
|
520
553
|
return resetEpic(commandArgs[0] || '');
|
|
554
|
+
case 'checkoff':
|
|
555
|
+
return checkOffStory(commandArgs[0] || '', commandArgs[1] || '');
|
|
521
556
|
default:
|
|
522
|
-
return { success: false, message: `Unknown subcommand: ${subcommand}. Available: run, status, reset` };
|
|
557
|
+
return { success: false, message: `Unknown subcommand: ${subcommand}. Available: run, status, reset, checkoff` };
|
|
523
558
|
}
|
|
524
559
|
},
|
|
525
560
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Story #195: Shared epic detection & extraction module.
|
|
8
8
|
*/
|
|
9
|
-
import { exec } from 'node:child_process';
|
|
9
|
+
import { exec, spawnSync } from 'node:child_process';
|
|
10
10
|
import { promisify } from 'node:util';
|
|
11
11
|
const execAsync = promisify(exec);
|
|
12
12
|
// ============================================================================
|
|
@@ -138,4 +138,59 @@ export async function findPrForIssue(issueNumber) {
|
|
|
138
138
|
return null;
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// Story checkoff (standalone `/flo <story>` runs)
|
|
143
|
+
// ============================================================================
|
|
144
|
+
/**
|
|
145
|
+
* Pure core of the story-checkoff operation — no I/O, unit-testable.
|
|
146
|
+
*
|
|
147
|
+
* Flips a story's `- [ ] #<n>` checkbox to `- [x] #<n>` in an epic body and
|
|
148
|
+
* reports whether the epic is now complete (it has story checkboxes and none
|
|
149
|
+
* remain unchecked). Word-boundary guarded so #12 never matches inside #123.
|
|
150
|
+
*/
|
|
151
|
+
export function computeEpicCheckoff(body, storyNumber) {
|
|
152
|
+
const pattern = new RegExp(`- \\[ \\] #${storyNumber}(?!\\d)`);
|
|
153
|
+
const updated = body.replace(pattern, `- [x] #${storyNumber}`);
|
|
154
|
+
const checked = updated !== body;
|
|
155
|
+
const hasStoryCheckboxes = /- \[[ x]\] #\d+/.test(updated);
|
|
156
|
+
const hasUnchecked = /- \[ \] #\d+/.test(updated);
|
|
157
|
+
const allComplete = hasStoryCheckboxes && !hasUnchecked;
|
|
158
|
+
return { updated, checked, allComplete };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Check a story off in its parent epic's checklist, and close the epic when no
|
|
162
|
+
* unchecked stories remain. Used by standalone `/flo <story>` runs via
|
|
163
|
+
* `flo epic checkoff`; the orchestrated `flo epic run` path does its own
|
|
164
|
+
* checkoff inside the spell (epic-single-branch.yaml).
|
|
165
|
+
*
|
|
166
|
+
* Cross-platform (Rule #1): drives `gh` through `spawnSync` arg arrays (no
|
|
167
|
+
* shell string to escape) and pipes the rewritten body over stdin
|
|
168
|
+
* (`--body-file -`), the same pattern proven on Windows CI in the spell.
|
|
169
|
+
*/
|
|
170
|
+
export async function checkOffStoryInEpic(epicNumber, storyNumber) {
|
|
171
|
+
const issue = await fetchEpicIssue(epicNumber);
|
|
172
|
+
const { updated, checked, allComplete } = computeEpicCheckoff(issue.body || '', storyNumber);
|
|
173
|
+
if (checked) {
|
|
174
|
+
const edit = spawnSync('gh', ['issue', 'edit', String(epicNumber), '--body-file', '-'], {
|
|
175
|
+
input: updated,
|
|
176
|
+
encoding: 'utf8',
|
|
177
|
+
});
|
|
178
|
+
if (edit.status !== 0) {
|
|
179
|
+
throw new Error(`gh issue edit failed: ${(edit.stderr || edit.error?.message || 'unknown').toString().trim()}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const alreadyClosed = issue.state.toUpperCase() === 'CLOSED';
|
|
183
|
+
const epicClosed = allComplete && !alreadyClosed;
|
|
184
|
+
if (epicClosed) {
|
|
185
|
+
const close = spawnSync('gh', [
|
|
186
|
+
'issue', 'close', String(epicNumber),
|
|
187
|
+
'--reason', 'completed',
|
|
188
|
+
'--comment', `All stories complete — closed automatically after story #${storyNumber}.`,
|
|
189
|
+
], { encoding: 'utf8' });
|
|
190
|
+
if (close.status !== 0) {
|
|
191
|
+
throw new Error(`gh issue close failed: ${(close.stderr || close.error?.message || 'unknown').toString().trim()}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { checked, epicClosed, alreadyClosed };
|
|
195
|
+
}
|
|
141
196
|
//# sourceMappingURL=detection.js.map
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Story #195: Shared epic detection & extraction module.
|
|
5
5
|
*/
|
|
6
|
-
export { isEpicIssue, extractStories, extractUncheckedStories, fetchEpicIssue, enrichStoryNames, findPrForIssue, } from './detection.js';
|
|
6
|
+
export { isEpicIssue, extractStories, extractUncheckedStories, fetchEpicIssue, enrichStoryNames, findPrForIssue, computeEpicCheckoff, checkOffStoryInEpic, } from './detection.js';
|
|
7
7
|
//# sourceMappingURL=index.js.map
|
|
@@ -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).
|
|
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:
|
|
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
|
|
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
|
}
|
package/dist/src/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moflo",
|
|
3
|
-
"version": "4.11.
|
|
3
|
+
"version": "4.11.8",
|
|
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.
|
|
97
|
+
"moflo": "^4.11.7",
|
|
98
98
|
"tsx": "^4.21.0",
|
|
99
99
|
"typescript": "^5.9.3",
|
|
100
100
|
"vitest": "^4.0.0"
|