Skip to content

Commit 49d16b8

Browse files
committed
fix(diagrams): #104 review — a repair is known by what it says, a replaced drawing is not handed back, and a replay keeps the order
Three findings from the Copilot review. All three are right. agent.js:1041 — "this discards parsed.value and passes the raw string … the original is degraded and a second card is drawn". The service decides whether a call is the waiting diagram's repair by its title and its node ids, and it read them off the arguments as they arrived. Arguments that are loose JSON arrive as text — no title, no nodes — so a repair written with a trailing comma was a "different diagram": the first attempt was settled as a degraded picture of its own and the correction was drawn as a second. The same went for a first attempt in loose JSON (its repair never found it), for a spec inside a `{"spec": …}` envelope, and for one that says `name` for `title`. The identity is now read from the spec as the ladder reads it — normalize(), the lossless rung — whatever form it came in. agent.js is unchanged: it still hands over the text, so the loose JSON is still counted as the auto-fix it was. The placeholder's title comes from the same reading, so a call that arrived as text has one too. And a second drawing of the same title in one run is recognised as a redraw when it arrives as text. service.js:260 — "this emits stubs for records that a later drawing replaced". A redraw replaces the earlier drawing in the chat and a reopened session leaves it out, but at compaction the model was handed a stub for each — so it could see, fetch and edit a diagram the user no longer has. A replaced drawing now gets no stub, wherever its replacement is in the conversation; it is not among the known ids; and get_diagram, asked for it by an id the model may still remember, answers with the id of the drawing the chat shows — the end of the chain when it was redrawn more than once. A record that names itself, or two that name each other, is a damaged file and not a reason to hang. sessionEvents.js:149 — "all text blocks are collapsed and emitted before the diagram". A message that says something, draws, and says more was replayed as all of its prose and then the picture. It is now walked block by block: prose, picture, prose, as it was on screen. Every piece after the first carries `cont: true`, so a reader of the turns can still tell where a message ends (the chat page does not need it: it already gives a run of answers one speaker label). A message with no picture in it comes out exactly as it did, one turn of all its text — and so does every caller that passes no records. The Markdown export is built on those turns, and getting its order right showed two more things wrong with it. A picture with no prose before it was written under whoever spoke last — after a question, under "You". And once a message is split, the prose after its picture would have been a turn of its own. The export now works message by message: one rule and one speaker label per message that says something, its pictures where they stood; a message that is only a picture joins the answer above it, as before, unless there is none; a diagram that was never drawn is dropped only after the pieces are grouped, so the prose after it is not appended to the message before; and the count in the header is still the number of messages that said something. Tests, each failing on the code as reviewed. Through the real agent loop: a repair in loose JSON, a first attempt in loose JSON, three envelope and renamed-field shapes, a repair with the title reworded, and a redraw in loose JSON — one diagram on file each time — and a different diagram in loose JSON, which must NOT be taken for the same one. Three drawings of one diagram leave one stub, and none in a stretch that holds only a replaced one; the same after a reload, and in a file whose records name each other. A message of prose, picture, another tool's call, prose and picture replays in that order with the same words, and is exported as expected in five shapes.
1 parent e84b368 commit 49d16b8

4 files changed

Lines changed: 282 additions & 23 deletions

File tree

‎extensions/levelcode-ai/diagram/service.js‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ const idsOf = (input) => {
3535
return nodes.map((n) => norm(n && typeof n === 'object' ? (n.id != null ? n.id : n.label) : n)).filter(Boolean);
3636
};
3737
const titleOf = (input) => norm(input && typeof input === 'object' ? input.title : '');
38+
/**
39+
* What a call is a drawing OF: its title and its node ids, as the ladder READS them — not as they
40+
* happened to arrive. A spec is the same diagram whether it came as an object, as JSON text with a
41+
* trailing comma, inside a `{"spec": …}` envelope or with `name` for `title`; told apart by the raw
42+
* arguments, a repair in any of those forms has no title and no nodes, and is taken for a new diagram.
43+
* `shown` is the title for the placeholder.
44+
*/
45+
function identityOf(input) {
46+
let spec = null;
47+
try { spec = repair.normalize(input).spec; } catch (e) { spec = null; }
48+
return { title: titleOf(spec), ids: idsOf(spec), shown: titleFrom(spec) };
49+
}
3850
/** Is this call another go at the same diagram? Same title, or mostly the same nodes. */
3951
function sameDiagram(a, b) {
4052
if (a.title && a.title === b.title) { return true; }
@@ -150,7 +162,7 @@ function createDiagrams(opts) {
150162
*/
151163
function render(input, meta) {
152164
const m = meta || { key: '' };
153-
const me = { title: titleOf(input), ids: idsOf(input) };
165+
const me = identityOf(input);
154166
const post = [];
155167
let pending = run.pending;
156168
// Something else was sent back for repair and the model has moved on to a different diagram:
@@ -172,7 +184,7 @@ function createDiagrams(opts) {
172184
run.pending = { key: String(m.key), input, model: m.model, title: me.title, ids: me.ids };
173185
run.bounces++;
174186
stat(Object.assign({ type: 'call', model: m.model, format: 'graph', outcome: 'bounced', tokens }, classes));
175-
post.push({ type: 'diagramPending', key: String(m.key), state: 'repairing', title: typeof (input && input.title) === 'string' ? repair.cleanText(input.title).slice(0, 120) : '' });
187+
post.push({ type: 'diagramPending', key: String(m.key), state: 'repairing', title: me.shown });
176188
return { result: tool.result(prepared), post };
177189
}
178190

@@ -230,30 +242,53 @@ function createDiagrams(opts) {
230242
}
231243
function reset() { records.clear(); byKey.clear(); fresh = []; seq = 0; beginRun(); }
232244

245+
/**
246+
* The drawings a later one took the place of: id → the id of the drawing that replaced it. The chat
247+
* shows the later one where the earlier one stood, and a reopened session leaves the earlier one out
248+
* (sessionEvents.toDisplayTurns) — so the model is not handed it back either: no stub at compaction,
249+
* and get_diagram answers with the one that is on screen.
250+
*/
251+
function replacedBy() {
252+
const next = new Map();
253+
for (const r of records.values()) { if (r.replaces && String(r.replaces) !== r.id) { next.set(String(r.replaces), r.id); } }
254+
return next;
255+
}
256+
/** The end of the chain of redraws that began with `id` — what the chat shows now. */
257+
function shownFor(id, next) {
258+
const seen = new Set([id]);
259+
let cur = id;
260+
while (next.has(cur) && !seen.has(next.get(cur))) { cur = next.get(cur); seen.add(cur); } // (a damaged file may loop)
261+
return cur;
262+
}
263+
233264
/** get_diagram: the spec as the model would write it, or a plain reason there is none. */
234265
function fetch(id) {
235266
const want = String(id == null ? '' : id).trim();
236267
const r = get(want);
268+
const gone = replacedBy();
237269
if (!r) {
238-
const known = list().filter((x) => x.spec).map((x) => x.id);
270+
const known = list().filter((x) => x.spec && !gone.has(x.id)).map((x) => x.id);
239271
return 'ERROR: no diagram with id "' + want.slice(0, 40) + '" in this session.' + (known.length ? ' Known ids: ' + validator.listIds(known) + '.' : ' None has been drawn yet.');
240272
}
273+
if (gone.has(r.id)) { const now = shownFor(r.id, gone); return 'ERROR: diagram ' + r.id + ' was replaced by ' + now + ' — the chat shows ' + now + '. Call get_diagram with "' + now + '".'; }
241274
if (!r.spec) { return 'ERROR: diagram ' + r.id + ' was never drawn (its spec was invalid). Draw it again with render_diagram.'; }
242275
return text.toSource(r.spec);
243276
}
244277

245278
/**
246279
* The one-line stubs for every diagram whose spec is in `messages` — what replaces those specs
247-
* when that stretch of the conversation is compacted. Nothing here runs turn by turn.
280+
* when that stretch of the conversation is compacted. Nothing here runs turn by turn. A drawing
281+
* that a later one replaced gets none: the user no longer sees it, wherever its replacement is.
248282
*/
249283
function stubsFor(messages) {
250284
const out = [];
285+
const gone = replacedBy();
251286
for (const msg of (Array.isArray(messages) ? messages : [])) {
252287
if (!msg || msg.role !== 'assistant' || !Array.isArray(msg.content)) { continue; }
253288
for (const b of msg.content) {
254289
if (!b || b.type !== 'tool_use' || b.name !== tool.RENDER_DIAGRAM.name) { continue; }
255290
const r = byToolUse(b.id);
256-
if (r && r.spec) { out.push(text.stub(r)); }
291+
if (r && r.spec && !gone.has(r.id)) { out.push(text.stub(r)); }
257292
}
258293
}
259294
return out;

‎extensions/levelcode-ai/sessionEvents.js‎

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -127,27 +127,42 @@ function messageText(content) {
127127
* chat did, not like the raw transcript. Pure, so the webview never has to know provider message shapes.
128128
*
129129
* With `diagrams` (the session's stored records), each diagram comes back as a turn of its own —
130-
* `{ role: 'diagram', key, record }` — at the place in the answer where the model drew it. A drawing
131-
* that a later one replaced is left out, as it was on screen. Without `diagrams` the result is exactly
132-
* what it always was: prose only.
130+
* `{ role: 'diagram', key, record }` — at the place in the answer where the model drew it: a message
131+
* that says something, draws, and says more comes back as prose, picture, prose, the way it was on
132+
* screen. Every piece of such a message after its first is marked `cont: true` — the same message
133+
* going on, not the next one — so a reader of the turns can still tell where a message ends. A
134+
* drawing that a later one replaced is left out, as it was on screen. Without `diagrams` the result
135+
* is exactly what it always was: prose only, one turn per message.
133136
*/
134137
function toDisplayTurns(messages, diagrams) {
135138
const out = [];
136139
const records = Array.isArray(diagrams) ? diagrams : [];
137140
const byKey = new Map(records.filter((r) => r && r.key).map((r) => [String(r.key), r]));
138-
const replaced = new Set(records.filter((r) => r && r.replaces).map((r) => String(r.replaces)));
141+
const replaced = new Set(records.filter((r) => r && r.replaces && String(r.replaces) !== String(r.id)).map((r) => String(r.replaces)));
142+
/** The diagram this block drew — if it drew one, and that one is still what the chat shows. */
143+
const drawnBy = (b) => {
144+
const r = b && b.type === 'tool_use' && b.name === DIAGRAM_TOOL ? byKey.get(String(b.id)) : null;
145+
return r && !replaced.has(String(r.id)) ? r : null;
146+
};
139147
for (const m of (Array.isArray(messages) ? messages : [])) {
140148
if (!m || (m.role !== 'user' && m.role !== 'assistant')) { continue; }
141149
// a user message whose content is purely tool_result blocks is plumbing, not something the user typed
142150
if (m.role === 'user' && Array.isArray(m.content) && m.content.length && m.content.every((b) => b && b.type === 'tool_result')) { continue; }
143-
const text = messageText(m.content);
144-
if (text.trim()) { out.push({ role: m.role, text }); } // (an assistant turn that was only tool calls has none)
145-
if (m.role === 'assistant' && byKey.size && Array.isArray(m.content)) {
151+
if (m.role === 'assistant' && Array.isArray(m.content)) {
152+
// An answer, block by block: the prose so far, then a picture, then the rest. With no picture
153+
// in it — the usual case — that is one piece: all of its text, as it always was.
154+
let prose = [], pieces = 0;
155+
const piece = (turn) => { out.push(pieces++ ? Object.assign(turn, { cont: true }) : turn); };
156+
const flush = () => { const text = messageText(prose); prose = []; if (text.trim()) { piece({ role: 'assistant', text }); } };
146157
for (const b of m.content) {
147-
const r = b && b.type === 'tool_use' && b.name === DIAGRAM_TOOL ? byKey.get(String(b.id)) : null;
148-
if (r && !replaced.has(String(r.id))) { out.push({ role: 'diagram', key: String(r.key), record: r }); }
158+
const r = drawnBy(b);
159+
if (r) { flush(); piece({ role: 'diagram', key: String(r.key), record: r }); } else { prose.push(b); }
149160
}
161+
flush();
162+
continue;
150163
}
164+
const text = messageText(m.content);
165+
if (text.trim()) { out.push({ role: m.role, text }); } // (an assistant turn that was only tool calls has none)
151166
}
152167
return out;
153168
}
@@ -190,7 +205,14 @@ function toMarkdown(meta, messages, opts) {
190205
const m = meta || {};
191206
const o = opts || {};
192207
const scrub = typeof o.redact === 'function' ? o.redact : (s) => s;
193-
const turns = toDisplayTurns(messages, o.diagrams).filter((t) => t.role !== 'diagram' || (t.record && t.record.spec));
208+
// Message by message: the pieces of one message (prose, pictures, more prose) stay together. A
209+
// diagram that was never drawn has nothing to export, and is dropped only AFTER the pieces are
210+
// grouped — it may be the first piece of its message, and the prose after it is not the tail of
211+
// the message before.
212+
const answers = [];
213+
for (const t of toDisplayTurns(messages, o.diagrams)) { if (t.cont && answers.length) { answers[answers.length - 1].push(t); } else { answers.push([t]); } }
214+
const said = (pieces) => pieces.some((t) => t.role !== 'diagram');
215+
const shown = answers.map((pieces) => pieces.filter((t) => t.role !== 'diagram' || (t.record && t.record.spec))).filter((pieces) => pieces.length);
194216

195217
const title = scrub(String(m.title || 'Untitled session')).trim() || 'Untitled session';
196218
const when = String(m.updatedAt || m.createdAt || '').slice(0, 10);
@@ -200,17 +222,20 @@ function toMarkdown(meta, messages, opts) {
200222
// never named a model or touched a file should read as a transcript, not as a form with blanks.
201223
const bits = [];
202224
if (when) { bits.push(when); }
203-
const spoken = turns.filter((t) => t.role !== 'diagram').length; // a diagram is part of an answer, not a turn of its own
225+
const spoken = shown.filter(said).length; // a turn is a message that says something: a diagram is part of an answer, not a turn of its own
204226
bits.push(spoken + ' turn' + (spoken === 1 ? '' : 's'));
205227
if (m.model) { bits.push('`' + scrub(String(m.model)) + '`'); }
206228
if (files.length) { bits.push(files.slice(0, 6).map((f) => '`' + f + '`').join(', ')); }
207229

208230
let md = '# ' + title + '\n\n';
209231
md += '_LevelCode session · ' + bits.join(' · ') + '_\n';
210232

211-
for (const t of turns) {
233+
// One rule and one speaker label per message that says something; its pictures stand in it where
234+
// they stood. A message that is ONLY a picture joins the answer above it — no rule, no second
235+
// label — unless there is none: under the question, it would read as part of the question.
236+
let speaker = '';
237+
const piece = (t) => {
212238
if (t.role === 'diagram') {
213-
// Under the answer it belongs to — no rule, no second speaker label.
214239
const title = scrub(String((t.record.spec && t.record.spec.title) || 'Diagram')).replace(/\s+/g, ' ').trim();
215240
let block = null;
216241
try { block = typeof o.diagram === 'function' ? o.diagram(t.record) : null; } catch (e) { block = null; }
@@ -223,13 +248,17 @@ function toMarkdown(meta, messages, opts) {
223248
} else {
224249
md += '\n_[diagram: ' + title + ']_\n';
225250
}
226-
continue;
251+
return;
227252
}
228-
md += '\n---\n\n**' + (t.role === 'user' ? 'You' : 'LevelCode') + '**\n\n';
229253
// Verbatim aside from redaction — NOT trimmed. Trimming changes Markdown semantics: it de-indents a
230-
// leading 4-space (indented code block) and eats trailing " " (a hard line break). The `**role**\n\n`
231-
// above already supplies the blank line an indented block needs after it.
232-
md += scrub(String(t.text)) + '\n';
254+
// leading 4-space (indented code block) and eats trailing " " (a hard line break). The blank line
255+
// before it — after the `**role**` label, or after a picture — is the one an indented block needs.
256+
md += '\n' + scrub(String(t.text)) + '\n';
257+
};
258+
for (const pieces of shown) {
259+
const role = pieces[0].role === 'user' ? 'user' : 'assistant';
260+
if (said(pieces) || speaker !== 'assistant') { md += '\n---\n\n**' + (role === 'user' ? 'You' : 'LevelCode') + '**\n'; speaker = role; }
261+
pieces.forEach(piece);
233262
}
234263
// An empty session still exports — a file with a header and no turns is a truthful answer, and
235264
// silently producing nothing would read as a broken button.

‎extensions/levelcode-ai/test/diagramAgent.test.js‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const jev = () => ({
5353
const broken = () => { const s = jev(); s.edges[1].to = 'billing'; s.nodes[2].accent = true; return s; };
5454
const call = (id, input) => ({ stop_reason: 'tool_use', content: [{ type: 'text', text: 'Here is the flow.' }, { type: 'tool_use', id, name: 'render_diagram', input }] });
5555
const say = (text) => ({ stop_reason: 'end_turn', content: [{ type: 'text', text }] });
56+
/** The same call when its arguments did not parse as JSON: the provider hands over the text it received. */
57+
const sloppyCall = (id, argsText) => ({ stop_reason: 'tool_use', content: [{ type: 'text', text: 'Here is the flow.' }, { type: 'tool_use', id, name: 'render_diagram', input: {} }], malformed: new Set([id]), raw: new Map([[id, argsText]]) });
58+
/** A spec as a model writes it on a bad day: every list and the object itself end in a comma. */
59+
const loose = (spec) => JSON.stringify(spec).replace(/\]/g, ',]').replace(/\}$/, ',}');
5660

5761
/** Run one goal against a script of turns. Returns what was posted, what the model was sent, and the service. */
5862
async function run(turns, overrides) {
@@ -237,6 +241,60 @@ async function run(turns, overrides) {
237241
assert.strictEqual(r.shown[r.shown.length - 1].replacesKey, 'toolu_1');
238242
});
239243

244+
// The call that corrects a diagram is that diagram's repair in whatever FORM it arrives. Told apart
245+
// by the raw arguments, a repair written as loose JSON (or wrapped in an envelope) had no title and
246+
// no nodes: the first attempt was settled as a picture of its own, and the repair drew a second.
247+
await testAsync('LADDER: a repair that arrives as loosely written JSON is still the repair — one picture, in the placeholder', async () => {
248+
const r = await run([call('toolu_1', broken()), sloppyCall('toolu_2', loose(jev())), say('Done: drawn.')]);
249+
assert.match(r.results[0].content, /^ERROR: the diagram was not drawn/);
250+
assert.deepStrictEqual(r.shown.map((m) => m.type + ':' + (m.state || m.record.status)), ['diagramPending:repairing', 'diagram:ok'], 'the first attempt is not drawn on its own');
251+
assert.strictEqual(r.shown[1].replacesKey, 'toolu_1', 'the corrected picture lands where the failed attempt was waiting');
252+
assert.strictEqual(r.shown[1].record.repaired, true);
253+
assert.strictEqual(r.shown[1].record.spec.edges.length, 3, 'and it is the corrected spec, whole');
254+
assert.strictEqual(r.diagrams.list().length, 1, 'one diagram on file, so one card on replay too');
255+
assert.deepStrictEqual(r.events.filter((e) => e.type === 'call').map((e) => e.outcome), ['bounced', 'repaired']);
256+
assert.strictEqual(r.events.filter((e) => e.type === 'call')[1].fixClasses['lenient-json'], 1, 'and the loose JSON is still counted as the auto-fix it was');
257+
// with the title reworded too, it is still told by its nodes — read out of the text, like the title
258+
const reworded = jev(); reworded.title = 'Routing by confidence';
259+
const byNodes = await run([call('toolu_1', broken()), sloppyCall('toolu_2', loose(reworded)), say('Done: drawn.')]);
260+
assert.deepStrictEqual(byNodes.shown.map((m) => m.type + ':' + (m.state || m.record.status) + ':' + (m.replacesKey || '-')), ['diagramPending:repairing:-', 'diagram:ok:toolu_1']);
261+
assert.strictEqual(byNodes.diagrams.list().length, 1);
262+
});
263+
264+
await testAsync('LADDER: a FIRST attempt in loose JSON is remembered by what it says, so its repair finds it', async () => {
265+
const r = await run([sloppyCall('toolu_1', loose(broken())), call('toolu_2', jev()), say('Done: drawn.')]);
266+
assert.deepStrictEqual(r.shown.map((m) => m.type + ':' + (m.state || m.record.status)), ['diagramPending:repairing', 'diagram:ok']);
267+
assert.strictEqual(r.shown[0].title, 'Jev classifies; your code decides the action', 'the placeholder is titled from the text, too');
268+
assert.strictEqual(r.shown[1].replacesKey, 'toolu_1');
269+
assert.strictEqual(r.shown[1].record.repaired, true);
270+
assert.strictEqual(r.diagrams.list().length, 1);
271+
// still broken on the second go: this is the one repair pass, so it degrades — it does not bounce again
272+
const twice = await run([sloppyCall('toolu_1', loose(broken())), sloppyCall('toolu_2', loose(broken())), say('Done: as far as it goes.')]);
273+
assert.deepStrictEqual(twice.results.map((b) => (/^ERROR/.test(b.content) ? 'bounced' : 'drawn')), ['bounced', 'drawn'], '"no automatic second repair", whatever the JSON looked like');
274+
assert.deepStrictEqual(twice.shown.filter((m) => m.type === 'diagram').map((m) => m.record.status + ':' + m.replacesKey), ['degraded:toolu_1']);
275+
});
276+
277+
await testAsync('LADDER: a spec inside an envelope, or with its fields renamed, is told by what the ladder reads in it', async () => {
278+
const renamed = () => { const s = jev(); return { name: s.title, nodes: s.nodes.map((x) => ({ key: x.id, text: x.label })), edges: s.edges }; };
279+
for (const [first, second] of [[{ diagram: broken() }, { spec: JSON.stringify(jev()) }], [broken(), { graph: jev() }], [{ input: broken() }, renamed()]]) {
280+
const r = await run([call('toolu_1', first), call('toolu_2', second), say('Done: drawn.')]);
281+
const shape = JSON.stringify([Object.keys(first), Object.keys(second)]);
282+
assert.deepStrictEqual(r.shown.map((m) => m.type + ':' + (m.state || m.record.status)), ['diagramPending:repairing', 'diagram:ok'], shape);
283+
assert.strictEqual(r.shown[0].title, 'Jev classifies; your code decides the action', shape);
284+
assert.strictEqual(r.shown[1].replacesKey, 'toolu_1', shape);
285+
assert.strictEqual(r.diagrams.list().length, 1, shape);
286+
}
287+
});
288+
289+
await testAsync('LADDER: reading the text does not make every call the same diagram — a different one in loose JSON is still a different one', async () => {
290+
const other = { title: 'A second, unrelated picture', nodes: [{ id: 'x', label: 'X' }, { id: 'y', label: 'Y' }], edges: [{ from: 'x', to: 'y' }] };
291+
const r = await run([sloppyCall('toolu_1', loose(broken())), sloppyCall('toolu_2', loose(other)), say('Done: two diagrams.')]);
292+
const drawn = r.shown.filter((m) => m.type === 'diagram');
293+
assert.deepStrictEqual(drawn.map((m) => m.key + ':' + m.record.status), ['toolu_1:degraded', 'toolu_2:ok']);
294+
assert.strictEqual(drawn[1].replacesKey, undefined);
295+
assert.strictEqual(drawn[1].record.repaired, undefined, 'a first attempt of its own, not somebody else\'s repair');
296+
});
297+
240298
await testAsync('REDRAW: drawing the same diagram again in one run replaces the first instead of stacking a second', async () => {
241299
const again = jev(); again.direction = 'down';
242300
const r = await run([call('toolu_1', jev()), call('toolu_2', again), say('Done: redrawn top to bottom.')]);
@@ -245,6 +303,10 @@ async function run(turns, overrides) {
245303
assert.strictEqual(drawn[1].record.replaces, 'd-1');
246304
assert.strictEqual(drawn[1].record.id, 'd-2');
247305
assert.strictEqual(drawn[1].record.spec.direction, 'down');
306+
// …and it is the same diagram again when the second drawing arrives as loose JSON
307+
const text = await run([call('toolu_1', jev()), sloppyCall('toolu_2', loose(again)), say('Done: redrawn.')]);
308+
const shown = text.shown.filter((m) => m.type === 'diagram');
309+
assert.deepStrictEqual(shown.map((m) => m.record.id + ':' + (m.record.replaces || '-') + ':' + (m.replacesKey || '-')), ['d-1:-:-', 'd-2:d-1:toolu_1']);
248310
});
249311

250312
// ---- extraction: truncated vs merely sloppy -------------------------------------------------------

0 commit comments

Comments
 (0)