moflo 4.12.11 → 4.13.0
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-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/README.md +95 -1
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The filesystem half of the dead-path pass (#1479).
|
|
3
|
+
*
|
|
4
|
+
* `memory/learnings-dead-paths.ts` decides what counts as a dead path and never
|
|
5
|
+
* touches a disk; this decides what "resolves" means against a real checkout.
|
|
6
|
+
* Kept out of the command so the split the audit is built on — pure judgement,
|
|
7
|
+
* injected I/O — survives having a second kind of I/O in it.
|
|
8
|
+
*
|
|
9
|
+
* Cross-platform (Rule #1): a cited path arrives in the forward-slash form
|
|
10
|
+
* entries are authored with on every platform and is re-joined with `path.join`,
|
|
11
|
+
* so the only string that reaches the filesystem carries the host separator.
|
|
12
|
+
*
|
|
13
|
+
* @module memory/learnings-tree
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as pathModule from 'path';
|
|
17
|
+
import { COMMON_WALK_SKIP_NAMES } from '../services/moflo-paths.js';
|
|
18
|
+
/**
|
|
19
|
+
* Directories never offered as a workspace prefix.
|
|
20
|
+
*
|
|
21
|
+
* `COMMON_WALK_SKIP_NAMES` is exactly the right list and is shared rather than
|
|
22
|
+
* restated so a second copy cannot drift from it: `node_modules` is skipped for
|
|
23
|
+
* the same reason a `node_modules/` path is never scored — whether it resolves
|
|
24
|
+
* is a fact about the checkout, not the entry — and every build or vendor output
|
|
25
|
+
* in it (`dist`, `build`, `target`, `.next`, `vendor`, …) is skipped because a
|
|
26
|
+
* stale one would resolve a path whose source has been deleted, which is the one
|
|
27
|
+
* answer a dead-path pass must never give.
|
|
28
|
+
*
|
|
29
|
+
* Matched case-insensitively, as every other call site does: NTFS and APFS are
|
|
30
|
+
* case-insensitive by default, so `Dist/` is the same directory (Rule #1).
|
|
31
|
+
*/
|
|
32
|
+
const PREFIX_SCAN_SKIP = COMMON_WALK_SKIP_NAMES;
|
|
33
|
+
/**
|
|
34
|
+
* How deep below the project root a prefix may reach.
|
|
35
|
+
*
|
|
36
|
+
* One level is not enough in practice. Measured against moflo's own store, a
|
|
37
|
+
* top-level-only retry left five of twelve nominations citing paths that plainly
|
|
38
|
+
* exist — `commands/index.ts` for `src/cli/commands/index.ts`, `fl/phases.md`
|
|
39
|
+
* for `.claude/skills/fl/phases.md` — because the source root a learning writes
|
|
40
|
+
* relative to is `src/cli`, not `src`. Two levels reaches those; a third starts
|
|
41
|
+
* resolving paths by coincidence rather than by layout, which costs real
|
|
42
|
+
* findings.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_PREFIX_DEPTH = 2;
|
|
45
|
+
/**
|
|
46
|
+
* The widest a directory may be and still be descended into.
|
|
47
|
+
*
|
|
48
|
+
* This is what makes depth 2 affordable AND useful, and it is a statement about
|
|
49
|
+
* layout rather than a budget. A directory holding one or two entries —
|
|
50
|
+
* `src/` over `cli/`, `packages/` over its packages — is a container, and the
|
|
51
|
+
* thing learnings write relative to is inside it. A directory holding thirty is
|
|
52
|
+
* already the source root, and its children are ordinary code directories that
|
|
53
|
+
* no one writes paths relative to.
|
|
54
|
+
*
|
|
55
|
+
* Without it, one wide scratch directory starves the whole prefix budget:
|
|
56
|
+
* measured on this repo, a `tmp/` full of test fixtures took every slot after
|
|
57
|
+
* the top level.
|
|
58
|
+
*/
|
|
59
|
+
const MAX_CHILDREN_TO_DESCEND = 12;
|
|
60
|
+
/**
|
|
61
|
+
* Cap on workspace prefixes.
|
|
62
|
+
*
|
|
63
|
+
* Every unresolved path costs one `existsSync` per prefix. The list is built
|
|
64
|
+
* most-specific-first — declared workspaces, then depth 1, then depth 2 — so the
|
|
65
|
+
* cap truncates the least valuable end rather than an arbitrary one, and
|
|
66
|
+
* truncating only ever costs extra nominations, never a wrong archive.
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_WORKSPACE_PREFIXES = 80;
|
|
69
|
+
/** Immediate subdirectories of `dir`, sorted; unreadable reads as none. */
|
|
70
|
+
function subdirectoryNames(dir) {
|
|
71
|
+
try {
|
|
72
|
+
return fs
|
|
73
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
74
|
+
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !PREFIX_SCAN_SKIP.has(e.name.toLowerCase()))
|
|
75
|
+
.map((e) => e.name)
|
|
76
|
+
.sort();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Workspace directories an unresolved path is retried under.
|
|
84
|
+
*
|
|
85
|
+
* Learnings are authored from inside a workspace and routinely cite
|
|
86
|
+
* `src/routes/foo.ts` meaning `packages/api/src/routes/foo.ts`. Declared
|
|
87
|
+
* workspaces come first because `packages/api` is a more specific answer than
|
|
88
|
+
* the bare `packages` a directory walk offers; the walk then covers the flat
|
|
89
|
+
* repo, which has no manifest entry to read, and goes {@link MAX_PREFIX_DEPTH}
|
|
90
|
+
* deep because the directory a learning writes relative to is usually a source
|
|
91
|
+
* root nested inside a top-level one.
|
|
92
|
+
*
|
|
93
|
+
* Dot directories are included: `.claude/` and `.github/` hold files learnings
|
|
94
|
+
* cite constantly, and the only one worth skipping is named in
|
|
95
|
+
* {@link PREFIX_SCAN_SKIP}.
|
|
96
|
+
*
|
|
97
|
+
* Returned in forward-slash form: this is the wire format
|
|
98
|
+
* `memory/learnings-dead-paths.ts` composes with, not a host path.
|
|
99
|
+
*/
|
|
100
|
+
export function listWorkspacePrefixes(projectRoot) {
|
|
101
|
+
const prefixes = [];
|
|
102
|
+
const add = (value) => {
|
|
103
|
+
const clean = value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
104
|
+
if (clean && !prefixes.includes(clean))
|
|
105
|
+
prefixes.push(clean);
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
const manifest = JSON.parse(fs.readFileSync(pathModule.join(projectRoot, 'package.json'), 'utf-8'));
|
|
109
|
+
const declared = Array.isArray(manifest?.workspaces)
|
|
110
|
+
? manifest.workspaces
|
|
111
|
+
: manifest?.workspaces?.packages;
|
|
112
|
+
for (const glob of Array.isArray(declared) ? declared : []) {
|
|
113
|
+
// `**` is left to the directory walk: expanding it means walking the whole
|
|
114
|
+
// tree, and the one level `*` covers is the shape every workspace uses.
|
|
115
|
+
if (typeof glob !== 'string' || glob.includes('**'))
|
|
116
|
+
continue;
|
|
117
|
+
const star = glob.indexOf('*');
|
|
118
|
+
if (star === -1) {
|
|
119
|
+
add(glob);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const base = glob.slice(0, star).replace(/\/+$/, '');
|
|
123
|
+
const baseDir = pathModule.join(projectRoot, ...base.split('/').filter(Boolean));
|
|
124
|
+
for (const child of subdirectoryNames(baseDir))
|
|
125
|
+
add(base ? pathModule.posix.join(base, child) : child);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* No manifest, or not JSON. The directory walk below still applies. */
|
|
130
|
+
}
|
|
131
|
+
// Breadth-first, so every depth-1 prefix is in the list before any depth-2 one
|
|
132
|
+
// and the cap below never trades a shallower prefix for a deeper one.
|
|
133
|
+
let frontier = subdirectoryNames(projectRoot);
|
|
134
|
+
for (const name of frontier)
|
|
135
|
+
add(name);
|
|
136
|
+
for (let depth = 2; depth <= MAX_PREFIX_DEPTH && frontier.length > 0; depth++) {
|
|
137
|
+
const next = [];
|
|
138
|
+
for (const parent of frontier) {
|
|
139
|
+
// Every descent past the cap costs a `readdirSync` for a prefix the slice
|
|
140
|
+
// below is about to discard.
|
|
141
|
+
if (prefixes.length >= MAX_WORKSPACE_PREFIXES)
|
|
142
|
+
break;
|
|
143
|
+
const children = subdirectoryNames(pathModule.join(projectRoot, ...parent.split('/')));
|
|
144
|
+
if (children.length > MAX_CHILDREN_TO_DESCEND)
|
|
145
|
+
continue;
|
|
146
|
+
for (const child of children) {
|
|
147
|
+
const nested = pathModule.posix.join(parent, child);
|
|
148
|
+
next.push(nested);
|
|
149
|
+
add(nested);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
frontier = next;
|
|
153
|
+
}
|
|
154
|
+
return prefixes.slice(0, MAX_WORKSPACE_PREFIXES);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Existence check for one repo-relative path cited by a learning.
|
|
158
|
+
*
|
|
159
|
+
* Rule #1: the path arrives in the forward-slash form entries are authored with
|
|
160
|
+
* on every platform, is split on that, and is re-joined with `path.join`, so the
|
|
161
|
+
* string reaching the filesystem carries the host separator and this file never
|
|
162
|
+
* writes one. A file OR a directory counts — a moved directory is the same
|
|
163
|
+
* finding as a moved file.
|
|
164
|
+
*
|
|
165
|
+
* Existence is the host's own answer, case-folding included, so a citation that
|
|
166
|
+
* differs from the real file only in case reads as alive on NTFS/APFS and dead
|
|
167
|
+
* on a case-sensitive filesystem. That is deliberate: matching the platform is
|
|
168
|
+
* the only defensible definition of "still in the tree", and the divergence can
|
|
169
|
+
* only change whether an entry is NOMINATED — never whether one is archived,
|
|
170
|
+
* which takes a model verdict either way.
|
|
171
|
+
*/
|
|
172
|
+
export function makeTreeResolver(projectRoot) {
|
|
173
|
+
return (relativePath) => {
|
|
174
|
+
const segments = relativePath.split('/').filter((s) => s.length > 0 && s !== '.');
|
|
175
|
+
// A traversal segment would resolve outside the project entirely, so it can
|
|
176
|
+
// say nothing about whether the repo still contains the cited file.
|
|
177
|
+
if (segments.length === 0 || segments.includes('..'))
|
|
178
|
+
return false;
|
|
179
|
+
try {
|
|
180
|
+
return fs.existsSync(pathModule.join(projectRoot, ...segments));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=learnings-tree.js.map
|
|
@@ -456,7 +456,35 @@ export async function bridgeConsolidate(_params) {
|
|
|
456
456
|
return { success: false, error: e.message };
|
|
457
457
|
}
|
|
458
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* Operations removed in #1465. Both wrote to the `episodes` store via a schema
|
|
461
|
+
* with no `namespace`, so neither could address a `memory_entries` row the
|
|
462
|
+
* caller had stored — yet both reported `success: true` with a count taken
|
|
463
|
+
* from the input array. Callers are redirected to the tools that work.
|
|
464
|
+
*
|
|
465
|
+
* A Map, not an object literal: `operation` is caller-controlled, and a plain
|
|
466
|
+
* object would resolve 'constructor'/'toString'/'__proto__' up the prototype
|
|
467
|
+
* chain to a truthy function.
|
|
468
|
+
*/
|
|
469
|
+
const BATCH_OPERATION_REDIRECTS = new Map([
|
|
470
|
+
[
|
|
471
|
+
'delete',
|
|
472
|
+
"moflodb_batch no longer supports 'delete' (#1465): it targeted the episodes store and could not address a namespaced memory entry, while reporting success. Use memory_delete with an explicit namespace.",
|
|
473
|
+
],
|
|
474
|
+
[
|
|
475
|
+
'update',
|
|
476
|
+
"moflodb_batch no longer supports 'update' (#1465): it targeted the episodes store and could not address a namespaced memory entry, while reporting success. Use memory_store to overwrite an entry.",
|
|
477
|
+
],
|
|
478
|
+
]);
|
|
459
479
|
export async function bridgeBatchOperation(params) {
|
|
480
|
+
// Rejected before the registry lookup so a removed operation reports the
|
|
481
|
+
// same error whether or not the bridge happens to be available.
|
|
482
|
+
const redirect = BATCH_OPERATION_REDIRECTS.get(params.operation);
|
|
483
|
+
if (redirect)
|
|
484
|
+
return { success: false, error: redirect };
|
|
485
|
+
if (params.operation !== 'insert') {
|
|
486
|
+
return { success: false, error: `Unknown operation: ${params.operation}` };
|
|
487
|
+
}
|
|
460
488
|
const registry = await getRegistry();
|
|
461
489
|
if (!registry)
|
|
462
490
|
return null;
|
|
@@ -464,33 +492,15 @@ export async function bridgeBatchOperation(params) {
|
|
|
464
492
|
const batch = registry.get('batchOperations');
|
|
465
493
|
if (!batch)
|
|
466
494
|
return { success: false, error: 'BatchOperations not available' };
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
case 'delete': {
|
|
478
|
-
const keys = params.entries.map((e) => e.key).filter(Boolean);
|
|
479
|
-
for (const key of keys)
|
|
480
|
-
await batch.bulkDelete('episodes', { key });
|
|
481
|
-
result = { deleted: keys.length };
|
|
482
|
-
break;
|
|
483
|
-
}
|
|
484
|
-
case 'update': {
|
|
485
|
-
for (const entry of params.entries) {
|
|
486
|
-
await batch.bulkUpdate('episodes', { content: entry.value || entry.content }, { key: entry.key });
|
|
487
|
-
}
|
|
488
|
-
result = { updated: params.entries.length };
|
|
489
|
-
break;
|
|
490
|
-
}
|
|
491
|
-
default: return { success: false, error: `Unknown operation: ${params.operation}` };
|
|
492
|
-
}
|
|
493
|
-
return { success: true, operation: params.operation, count: params.entries.length, result };
|
|
495
|
+
const episodes = params.entries.map((e) => ({
|
|
496
|
+
content: e.value || e.content || JSON.stringify(e),
|
|
497
|
+
metadata: e.metadata || { key: e.key },
|
|
498
|
+
}));
|
|
499
|
+
// `count` reports rows the store actually wrote, never `entries.length` —
|
|
500
|
+
// an input-derived count is how #1465's delete/update reported success
|
|
501
|
+
// while changing nothing.
|
|
502
|
+
const result = await batch.insertEpisodes(episodes);
|
|
503
|
+
return { success: true, operation: 'insert', count: result.inserted, result };
|
|
494
504
|
}
|
|
495
505
|
catch (e) {
|
|
496
506
|
return { success: false, error: e.message };
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect Claude Code tool-call markup captured into a memory value (#1467).
|
|
3
|
+
*
|
|
4
|
+
* A model emitting a `memory_store` call can have the harness' own parameter
|
|
5
|
+
* markup spill into the `value` string it is writing. The value then arrives at
|
|
6
|
+
* moflo already malformed, always as a fragment the text trails off into:
|
|
7
|
+
*
|
|
8
|
+
* ...the actual lesson text.",
|
|
9
|
+
* <parameter name="tags">["a","b","source:manual"]
|
|
10
|
+
*
|
|
11
|
+
* moflo does not cause this — the markup exists only in the model/harness layer —
|
|
12
|
+
* but it is the last component that can catch it. Left alone it is written to
|
|
13
|
+
* disk, embedded (so it degrades the vector for that entry and every search that
|
|
14
|
+
* would have matched it), and shared to the team artifact, at which point it is
|
|
15
|
+
* permanent and propagates to every machine that imports.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the detector is anchored rather than a bare marker match
|
|
18
|
+
*
|
|
19
|
+
* The naive test — "does the value contain `</value>` or `<parameter name=`" —
|
|
20
|
+
* rejects the lesson that documents this very bug, and every guidance doc that
|
|
21
|
+
* quotes the markup. So a marker only counts when it is *structurally broken*
|
|
22
|
+
* AND *trailing*:
|
|
23
|
+
*
|
|
24
|
+
* | Rule | Fires on | Passes |
|
|
25
|
+
* |---|---|---|
|
|
26
|
+
* | A — trailing unmatched closer | value ends on a `</value>` / `</parameter>` that never had an opener | balanced `<value>x</value>` prose; a closer mentioned mid-text |
|
|
27
|
+
* | B — unterminated trailing opener | an opener with no closer, on its own line, within the last {@link TRAILING_WINDOW} chars | an opener quoted inline in a sentence; one explained in the 200+ chars that follow |
|
|
28
|
+
*
|
|
29
|
+
* A value that discusses the markup explains it, and the explanation is the
|
|
30
|
+
* anchor. A value that was *cut off by* the markup has nothing after it.
|
|
31
|
+
*
|
|
32
|
+
* ## Accepted blind spots
|
|
33
|
+
*
|
|
34
|
+
* The detector is deliberately tuned to miss rather than over-reject, because a
|
|
35
|
+
* false reject blocks a legitimate write in every consumer while a miss only
|
|
36
|
+
* leaves one entry as bad as it is today:
|
|
37
|
+
*
|
|
38
|
+
* - A captured fragment longer than {@link TRAILING_WINDOW}. Every fragment
|
|
39
|
+
* observed in #1467 is under 60 characters; widening the window would start
|
|
40
|
+
* rejecting docs that quote the markup.
|
|
41
|
+
* - A value that quotes `<value>` earlier and is *then* truncated by a stray
|
|
42
|
+
* `</value>`: the quotation's opener absorbs the closer and both rules go
|
|
43
|
+
* quiet. Matching by tag name is what makes the balanced-prose cases pass.
|
|
44
|
+
* - A value that begins with markup, having lost all of its own text.
|
|
45
|
+
*
|
|
46
|
+
* Rule A's one accepted false positive is the mirror image: a truncated XML
|
|
47
|
+
* snippet that happens to end on an unmatched `</value>`. It is refusable by
|
|
48
|
+
* design — the ticket asks for that shape — and {@link MARKUP_OVERRIDE_ENV} is
|
|
49
|
+
* the way through. Bulk writes (`storeEntries`, whose only caller is the
|
|
50
|
+
* pattern pre-trainer) are not checked at all, which is where machine-generated
|
|
51
|
+
* XML-bearing content actually arrives.
|
|
52
|
+
*
|
|
53
|
+
* Pure string logic — no fs, no db, no platform surface (Rule #1).
|
|
54
|
+
*
|
|
55
|
+
* @module memory/tool-call-markup
|
|
56
|
+
*/
|
|
57
|
+
/**
|
|
58
|
+
* How close to the end of the value an unterminated opener must sit to count as
|
|
59
|
+
* a captured fragment rather than a quotation. Observed fragments are short —
|
|
60
|
+
* `<parameter name="tags">["a","b","source:manual"]` is 47 characters — while
|
|
61
|
+
* prose that quotes an opener goes on to say something about it.
|
|
62
|
+
*/
|
|
63
|
+
export const TRAILING_WINDOW = 200;
|
|
64
|
+
/** Escape hatch for a value that is genuinely shaped like the corruption. */
|
|
65
|
+
export const MARKUP_OVERRIDE_ENV = 'MOFLO_ALLOW_TOOL_CALL_MARKUP';
|
|
66
|
+
/** How much of the offending tail the error message quotes back. */
|
|
67
|
+
const EXCERPT_LIMIT = 120;
|
|
68
|
+
/**
|
|
69
|
+
* Matches the four token shapes the harness emits. `[^"]*` for the attribute
|
|
70
|
+
* keeps the pattern linear — no nested quantifier, so no backtracking blow-up
|
|
71
|
+
* on a long value. Built per scan rather than shared, so no `lastIndex` state
|
|
72
|
+
* outlives a call.
|
|
73
|
+
*/
|
|
74
|
+
function tokenPattern() {
|
|
75
|
+
return /<(\/?)(value|parameter)(\s+name="[^"]*")?\s*>/g;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Visit every token in `text` at or after `from`. Streaming rather than
|
|
79
|
+
* array-returning: Rule A has to see the whole value, and a 1 MB value of
|
|
80
|
+
* repeated `<value>` would otherwise build a token object per match.
|
|
81
|
+
*/
|
|
82
|
+
function scanTokens(text, from, visit) {
|
|
83
|
+
const re = tokenPattern();
|
|
84
|
+
re.lastIndex = from;
|
|
85
|
+
let match;
|
|
86
|
+
while ((match = re.exec(text)) !== null) {
|
|
87
|
+
const [full, slash, name, attr] = match;
|
|
88
|
+
// `<parameter>` without a name, or `<value name="x">`, is neither shape the
|
|
89
|
+
// harness emits; ignoring them keeps the detector to what it can justify.
|
|
90
|
+
if (name === 'parameter' && !slash && !attr)
|
|
91
|
+
continue;
|
|
92
|
+
if (name === 'value' && attr)
|
|
93
|
+
continue;
|
|
94
|
+
visit({
|
|
95
|
+
name: name,
|
|
96
|
+
kind: slash ? 'close' : 'open',
|
|
97
|
+
text: full,
|
|
98
|
+
index: match.index,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* True when `index` starts a line that already had text before it.
|
|
104
|
+
*
|
|
105
|
+
* Both halves matter. The harness always puts the captured fragment on its own
|
|
106
|
+
* line, so prose that quotes an opener mid-sentence is excluded by the line
|
|
107
|
+
* break. And a value that *starts* at index 0 with markup never had text to be
|
|
108
|
+
* cut off from, so it is not the shape this detects — without the second half
|
|
109
|
+
* every value shorter than {@link TRAILING_WINDOW} that opens with markup would
|
|
110
|
+
* be rejected no matter what followed it.
|
|
111
|
+
*/
|
|
112
|
+
function startsOwnLineAfterText(text, index) {
|
|
113
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
114
|
+
const ch = text[i];
|
|
115
|
+
if (ch === '\n' || ch === '\r')
|
|
116
|
+
return text.slice(0, i).trim().length > 0;
|
|
117
|
+
if (ch !== ' ' && ch !== '\t')
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return false; // reached the start of the value with no text before the markup
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Return the captured-markup fragment in `value`, or `null` when the value is
|
|
124
|
+
* clean. See the module header for what "captured" means and why a value that
|
|
125
|
+
* merely quotes the markup is not.
|
|
126
|
+
*/
|
|
127
|
+
export function detectToolCallMarkup(value) {
|
|
128
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
129
|
+
return null;
|
|
130
|
+
if (!value.includes('<'))
|
|
131
|
+
return null;
|
|
132
|
+
const trimmed = value.trimEnd();
|
|
133
|
+
// Rule A — the value ends on a closing tag that never had an opener. This is
|
|
134
|
+
// the `</value>` shape: the harness closed the parameter and the closer
|
|
135
|
+
// landed inside the text it was closing. Two counters, no allocation, so the
|
|
136
|
+
// cost of a pathological value is time only.
|
|
137
|
+
const depth = { value: 0, parameter: 0 };
|
|
138
|
+
let trailingCloser = null;
|
|
139
|
+
scanTokens(trimmed, 0, (token) => {
|
|
140
|
+
if (token.kind === 'open') {
|
|
141
|
+
depth[token.name]++;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (depth[token.name] > 0) {
|
|
145
|
+
depth[token.name]--;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
trailingCloser = token.index + token.text.length === trimmed.length ? token : null;
|
|
149
|
+
});
|
|
150
|
+
if (trailingCloser) {
|
|
151
|
+
// The cast is for the narrowing only: TypeScript cannot see that the
|
|
152
|
+
// callback above assigns, so it holds `trailingCloser` at its initial
|
|
153
|
+
// `null` type. `scanTokens` is fully synchronous, so the assignment has
|
|
154
|
+
// certainly happened by here.
|
|
155
|
+
const hit = trailingCloser;
|
|
156
|
+
return { marker: hit.text, index: hit.index, reason: 'trailing-closer' };
|
|
157
|
+
}
|
|
158
|
+
// Rule B — an opener that is never closed, on its own line, near the end.
|
|
159
|
+
// This is the `<parameter name="tags">` shape: the value was cut off and the
|
|
160
|
+
// next parameter of the same call was appended to it.
|
|
161
|
+
//
|
|
162
|
+
// Scanned over the trailing window alone: nothing follows the end of the
|
|
163
|
+
// value, so an opener inside the window can only be closed inside it, and the
|
|
164
|
+
// cost is bounded no matter how large the value is.
|
|
165
|
+
//
|
|
166
|
+
// Not quite identical to a whole-value scan, in one direction only. A tag
|
|
167
|
+
// that straddles `windowStart` is invisible to this scan, so if its closer
|
|
168
|
+
// falls inside the window that closer reads as unmatched and can absorb a
|
|
169
|
+
// genuinely unterminated opener found later in the backward walk. That is a
|
|
170
|
+
// miss, never a false reject — the same direction every trade-off in this
|
|
171
|
+
// module leans.
|
|
172
|
+
const windowStart = Math.max(0, trimmed.length - TRAILING_WINDOW);
|
|
173
|
+
const windowTokens = [];
|
|
174
|
+
scanTokens(trimmed, windowStart, (token) => { windowTokens.push(token); });
|
|
175
|
+
const pendingClosers = { value: 0, parameter: 0 };
|
|
176
|
+
for (let i = windowTokens.length - 1; i >= 0; i--) {
|
|
177
|
+
const token = windowTokens[i];
|
|
178
|
+
if (token.kind === 'close') {
|
|
179
|
+
pendingClosers[token.name]++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (pendingClosers[token.name] > 0) {
|
|
183
|
+
pendingClosers[token.name]--;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// First unmatched opener found scanning backwards — the last one in the
|
|
187
|
+
// value, and the only one that can be the captured fragment: a fragment
|
|
188
|
+
// runs to the end, so anything after it is part of it. Stopping here rather
|
|
189
|
+
// than continuing to earlier openers is the miss-biased choice on purpose —
|
|
190
|
+
// if the text running to the end started mid-sentence, it is prose.
|
|
191
|
+
if (startsOwnLineAfterText(trimmed, token.index)) {
|
|
192
|
+
return { marker: token.text, index: token.index, reason: 'unterminated-opener' };
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/** True when the operator has explicitly opted this process out of the check. */
|
|
199
|
+
export function markupCheckDisabled() {
|
|
200
|
+
return process.env[MARKUP_OVERRIDE_ENV] === '1';
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The rejection message. It quotes the offending tail back so the caller can
|
|
204
|
+
* see exactly what leaked and re-send the write — the silent `{success: true}`
|
|
205
|
+
* this replaces is why 68 corrupted entries accumulated unnoticed.
|
|
206
|
+
*/
|
|
207
|
+
export function toolCallMarkupError(hit, value, namespace, key) {
|
|
208
|
+
const tail = value.trimEnd().slice(hit.index);
|
|
209
|
+
const excerpt = tail.length > EXCERPT_LIMIT ? `${tail.slice(0, EXCERPT_LIMIT)}…` : tail;
|
|
210
|
+
const shape = hit.reason === 'trailing-closer'
|
|
211
|
+
? 'the value ends on a closing tag that was never opened'
|
|
212
|
+
: 'the value trails off into an unclosed opening tag';
|
|
213
|
+
return (`Refusing to store ${namespace}/${key}: the value contains captured tool-call markup — `
|
|
214
|
+
+ `${shape}. Offending text at offset ${hit.index}: ${JSON.stringify(excerpt)}. `
|
|
215
|
+
+ `Re-send the write with the markup removed. `
|
|
216
|
+
+ `(Set ${MARKUP_OVERRIDE_ENV}=1 to store it anyway.)`);
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=tool-call-markup.js.map
|
package/dist/src/cli/parser.js
CHANGED
|
@@ -59,10 +59,14 @@ export class CommandParser {
|
|
|
59
59
|
choices: ['text', 'json', 'table']
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// Declared POSITIVELY. The long-flag branch below turns `--no-<x>` into
|
|
63
|
+
// `<x> = false`, so an option named `no-color` can never produce a
|
|
64
|
+
// `noColor` key and every reader of one is dead (#1474). `--no-color`
|
|
65
|
+
// still works — that spelling IS the negation this parser performs.
|
|
66
|
+
name: 'color',
|
|
67
|
+
description: 'Colored output (--no-color to disable)',
|
|
64
68
|
type: 'boolean',
|
|
65
|
-
default:
|
|
69
|
+
default: true
|
|
66
70
|
},
|
|
67
71
|
{
|
|
68
72
|
name: 'interactive',
|
|
@@ -65,6 +65,14 @@ export function isDurableNamespace(namespace) {
|
|
|
65
65
|
* between the two writers would silently mis-bind or drop rows (INSERT OR IGNORE
|
|
66
66
|
* swallows the constraint violation). Single source of truth for both.
|
|
67
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* The 14 durable-row columns, in the order {@link DURABLE_INSERT_OR_IGNORE_SQL}
|
|
70
|
+
* binds them. Exported so every reader SELECTs exactly what the writer expects
|
|
71
|
+
* — the read half used to be retyped per call site, which is the same drift
|
|
72
|
+
* hazard the shared INSERT exists to prevent.
|
|
73
|
+
*/
|
|
74
|
+
export const DURABLE_ROW_COLUMNS = `id, key, namespace, content, type, embedding, embedding_model, ` +
|
|
75
|
+
`embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status`;
|
|
68
76
|
export const DURABLE_INSERT_OR_IGNORE_SQL = `INSERT OR IGNORE INTO memory_entries ` +
|
|
69
77
|
`(id, key, namespace, content, type, embedding, embedding_model, ` +
|
|
70
78
|
` embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status) ` +
|
|
@@ -151,9 +159,7 @@ export async function cherryPickLearningsFromLegacy(options = {}) {
|
|
|
151
159
|
try {
|
|
152
160
|
targetDb.run(MEMORY_SCHEMA_V3);
|
|
153
161
|
const placeholders = namespaces.map(() => '?').join(',');
|
|
154
|
-
const selectSql = `SELECT
|
|
155
|
-
`embedding_dimensions, tags, metadata, owner_id, created_at, updated_at, status ` +
|
|
156
|
-
`FROM memory_entries WHERE namespace IN (${placeholders})`;
|
|
162
|
+
const selectSql = `SELECT ${DURABLE_ROW_COLUMNS} FROM memory_entries WHERE namespace IN (${placeholders})`;
|
|
157
163
|
// Hoisted prepare — avoids re-parsing the SQL for every INSERT. Matters
|
|
158
164
|
// for legacy DBs with hundreds of learnings rows.
|
|
159
165
|
insertStmt = targetDb.prepare(DURABLE_INSERT_OR_IGNORE_SQL);
|