@commonlyai/cli 0.1.87 → 0.1.89
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/package.json +1 -1
- package/src/commands/agent.js +31 -1
- package/src/lib/enforcement.js +108 -32
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1594,7 +1594,35 @@ export const performRun = ({
|
|
|
1594
1594
|
uploadName: `${agentName}-reply-${event._id}.md`,
|
|
1595
1595
|
log: (line) => log(`[${event.type}] ${line}`),
|
|
1596
1596
|
});
|
|
1597
|
-
if (delivery.
|
|
1597
|
+
if (delivery.skipped) {
|
|
1598
|
+
// The server answered 200 and created nothing: a duplicate inside its
|
|
1599
|
+
// dedupe window, a silent body, a heartbeat it keeps. Nothing reached
|
|
1600
|
+
// the pod, so this is not a delivery and must not read as one in the
|
|
1601
|
+
// log — "posted 151 bytes as 1 message" over a skip is how a silent
|
|
1602
|
+
// seat looked like a talking one (hq-support, 2026-10-09 08:10Z). Ack
|
|
1603
|
+
// it like a refusal: re-posting the same text inside the window would
|
|
1604
|
+
// only be skipped again.
|
|
1605
|
+
deliveryRefusal = delivery;
|
|
1606
|
+
const windowMinutes = delivery.duplicate?.dedupeWindowMinutes;
|
|
1607
|
+
const detail = [
|
|
1608
|
+
windowMinutes ? `${windowMinutes} min window` : null,
|
|
1609
|
+
delivery.duplicate?.id ? `duplicate of ${delivery.duplicate.id}` : null,
|
|
1610
|
+
delivery.attemptedMessages > 1
|
|
1611
|
+
? `after ${delivery.messages}/${delivery.attemptedMessages} messages`
|
|
1612
|
+
: null,
|
|
1613
|
+
].filter(Boolean).join(', ');
|
|
1614
|
+
log(`[${event.type}] server skipped the post: ${delivery.reason}${detail ? ` (${detail})` : ''} — nothing reached the pod`);
|
|
1615
|
+
onError?.(Object.assign(
|
|
1616
|
+
new Error(`${event.type} server skipped the post: ${delivery.reason}`),
|
|
1617
|
+
{
|
|
1618
|
+
code: 'agent_delivery_skipped',
|
|
1619
|
+
reason: delivery.reason,
|
|
1620
|
+
eventId: event._id,
|
|
1621
|
+
postedMessages: delivery.messages,
|
|
1622
|
+
attemptedMessages: delivery.attemptedMessages,
|
|
1623
|
+
},
|
|
1624
|
+
));
|
|
1625
|
+
} else if (delivery.refused) {
|
|
1598
1626
|
// A run-cap refusal is a successful HTTP request but not a delivery.
|
|
1599
1627
|
// Ack it so the kernel does not replay the same text (the server
|
|
1600
1628
|
// guidance expressly says not to retry unchanged), while preserving
|
|
@@ -1640,6 +1668,8 @@ export const performRun = ({
|
|
|
1640
1668
|
if (deliveryRefusal) {
|
|
1641
1669
|
return {
|
|
1642
1670
|
outcome: 'no_action',
|
|
1671
|
+
// Kernel class, from REFUSAL_REASONS: a skip is the server declining to
|
|
1672
|
+
// deliver, same class as a refusal; the specific reason rides below.
|
|
1643
1673
|
refused: { reason: 'delivery-refused' },
|
|
1644
1674
|
reason: deliveryRefusal.reason,
|
|
1645
1675
|
details: {
|
package/src/lib/enforcement.js
CHANGED
|
@@ -483,12 +483,53 @@ export const createClaimKeeper = (client, {
|
|
|
483
483
|
|
|
484
484
|
// ── post-time length gate ───────────────────────────────────────────────────
|
|
485
485
|
|
|
486
|
+
// A sentence ends at [.!?] followed by whitespace, except when what precedes
|
|
487
|
+
// the whitespace is only a numbered list marker at the start of the line: in
|
|
488
|
+
// "3. Run it once." the first period belongs to the marker, not a sentence.
|
|
489
|
+
// Applied to one line at a time, so `^` is the line start.
|
|
490
|
+
const SENTENCE_END = /(?<=[.!?])(?<!^[ \t]*\d{1,3}\.)\s+/;
|
|
491
|
+
|
|
492
|
+
// Split one over-limit line at sentence boundaries, and a sentence that is
|
|
493
|
+
// itself over the limit at word boundaries. A single over-limit word (a URL)
|
|
494
|
+
// posts whole rather than being cut.
|
|
495
|
+
const splitLineForChat = (line, limit) => {
|
|
496
|
+
const units = [];
|
|
497
|
+
let piece = '';
|
|
498
|
+
const flushPiece = () => {
|
|
499
|
+
if (piece.trim()) units.push(piece.trim());
|
|
500
|
+
piece = '';
|
|
501
|
+
};
|
|
502
|
+
for (const sentence of line.split(SENTENCE_END)) {
|
|
503
|
+
if (sentence.length > limit) {
|
|
504
|
+
flushPiece();
|
|
505
|
+
let run = '';
|
|
506
|
+
for (const word of sentence.split(/\s+/)) {
|
|
507
|
+
if (run && `${run} ${word}`.length > limit) {
|
|
508
|
+
units.push(run);
|
|
509
|
+
run = word;
|
|
510
|
+
} else {
|
|
511
|
+
run = run ? `${run} ${word}` : word;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (run) units.push(run);
|
|
515
|
+
} else if (piece && `${piece} ${sentence}`.length > limit) {
|
|
516
|
+
flushPiece();
|
|
517
|
+
piece = sentence;
|
|
518
|
+
} else {
|
|
519
|
+
piece = piece ? `${piece} ${sentence}` : sentence;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
flushPiece();
|
|
523
|
+
return units;
|
|
524
|
+
};
|
|
525
|
+
|
|
486
526
|
/**
|
|
487
527
|
* Split chat text into tone-contract-sized messages without ever cutting
|
|
488
528
|
* content. Boundaries in preference order: fenced code blocks stay whole
|
|
489
529
|
* (atomic — an oversized fence becomes one oversized message rather than a
|
|
490
|
-
* broken pair), then paragraphs, then
|
|
491
|
-
*
|
|
530
|
+
* broken pair), then paragraphs, then lines (a list item never leaves its
|
|
531
|
+
* marker), then sentences, then words. Greedy packing rejoins small pieces so
|
|
532
|
+
* two short paragraphs share one message.
|
|
492
533
|
*/
|
|
493
534
|
export const splitForChat = (text, { limit = 400 } = {}) => {
|
|
494
535
|
const trimmed = String(text || '').trim();
|
|
@@ -529,39 +570,39 @@ export const splitForChat = (text, { limit = 400 } = {}) => {
|
|
|
529
570
|
}
|
|
530
571
|
flushProse();
|
|
531
572
|
|
|
532
|
-
// Pass 2: split oversized prose blocks at sentence then word
|
|
573
|
+
// Pass 2: split oversized prose blocks at line, then sentence, then word
|
|
574
|
+
// boundaries. Lines come first because a list is one paragraph block whose
|
|
575
|
+
// items are separated by single newlines: splitting it by sentence read a
|
|
576
|
+
// numbered marker ("3.") as a sentence end and packed it onto the previous
|
|
577
|
+
// piece, so a support reply posted one message ending in "3." and the next
|
|
578
|
+
// opening with step 3's body (hq-support, 2026-10-09). Lines pack greedily
|
|
579
|
+
// with single newlines, so a list stays a list inside a message; only a line
|
|
580
|
+
// that is itself over the limit goes on to sentences and words.
|
|
533
581
|
const units = [];
|
|
534
582
|
for (const block of blocks) {
|
|
535
583
|
if (block.atomic || block.text.length <= limit) {
|
|
536
584
|
units.push(block.text);
|
|
537
585
|
continue;
|
|
538
586
|
}
|
|
539
|
-
let
|
|
540
|
-
const
|
|
541
|
-
if (
|
|
542
|
-
|
|
587
|
+
let group = '';
|
|
588
|
+
const flushGroup = () => {
|
|
589
|
+
if (group) units.push(group);
|
|
590
|
+
group = '';
|
|
543
591
|
};
|
|
544
|
-
for (const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
run = run ? `${run} ${word}` : word;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
if (run) units.push(run); // a single over-limit word (URL) posts whole
|
|
557
|
-
} else if (piece && `${piece} ${sentence}`.length > limit) {
|
|
558
|
-
flushPiece();
|
|
559
|
-
piece = sentence;
|
|
592
|
+
for (const rawLine of block.text.split('\n')) {
|
|
593
|
+
const line = rawLine.trimEnd(); // keep a nested item's indentation
|
|
594
|
+
if (!line.trim()) continue;
|
|
595
|
+
if (line.length > limit) {
|
|
596
|
+
flushGroup();
|
|
597
|
+
units.push(...splitLineForChat(line, limit));
|
|
598
|
+
} else if (group && `${group}\n${line}`.length > limit) {
|
|
599
|
+
flushGroup();
|
|
600
|
+
group = line;
|
|
560
601
|
} else {
|
|
561
|
-
|
|
602
|
+
group = group ? `${group}\n${line}` : line;
|
|
562
603
|
}
|
|
563
604
|
}
|
|
564
|
-
|
|
605
|
+
flushGroup();
|
|
565
606
|
}
|
|
566
607
|
|
|
567
608
|
// Pass 3: greedy packing back up to the limit.
|
|
@@ -646,6 +687,41 @@ export const deliverChatReply = async ({
|
|
|
646
687
|
? { consecutive: response.consecutive }
|
|
647
688
|
: {}),
|
|
648
689
|
});
|
|
690
|
+
// The same route also answers HTTP 200 with `{ success: true, skipped: true,
|
|
691
|
+
// reason }` when it decided not to create a row: a duplicate inside the dedupe
|
|
692
|
+
// window, a silent or empty body, a heartbeat it keeps to itself. The wrapper
|
|
693
|
+
// used to read that as a delivery and log "posted … (single)" while nothing
|
|
694
|
+
// reached the pod (hq-support, 2026-10-09 08:10Z), so a silent seat could not
|
|
695
|
+
// be read from its own log. A skip is a completed request and not a delivery,
|
|
696
|
+
// exactly like a refusal, and it stops a sequence the same way.
|
|
697
|
+
const skipped = (response, messages, attemptedMessages) => {
|
|
698
|
+
const duplicate = response.duplicate && typeof response.duplicate === 'object'
|
|
699
|
+
? response.duplicate
|
|
700
|
+
: null;
|
|
701
|
+
return {
|
|
702
|
+
mode: 'skipped',
|
|
703
|
+
messages,
|
|
704
|
+
attemptedMessages,
|
|
705
|
+
skipped: true,
|
|
706
|
+
reason: response.reason || 'message_skipped',
|
|
707
|
+
...(duplicate
|
|
708
|
+
? {
|
|
709
|
+
duplicate: {
|
|
710
|
+
...(duplicate.id ? { id: String(duplicate.id) } : {}),
|
|
711
|
+
...(typeof duplicate.dedupeWindowMinutes === 'number'
|
|
712
|
+
? { dedupeWindowMinutes: duplicate.dedupeWindowMinutes }
|
|
713
|
+
: {}),
|
|
714
|
+
},
|
|
715
|
+
}
|
|
716
|
+
: {}),
|
|
717
|
+
};
|
|
718
|
+
};
|
|
719
|
+
const undelivered = (response) => response?.refused === true || response?.skipped === true;
|
|
720
|
+
const nonDelivery = (response, messages, attemptedMessages) => (
|
|
721
|
+
response?.refused === true
|
|
722
|
+
? refused(response, messages, attemptedMessages)
|
|
723
|
+
: skipped(response, messages, attemptedMessages)
|
|
724
|
+
);
|
|
649
725
|
// An atomic unit (a fenced block, an unbreakable word-run) can exceed the
|
|
650
726
|
// limit by construction — splitForChat keeps it whole rather than breaking
|
|
651
727
|
// its rendering. The tone contract's own rule covers it: over ~800 chars of
|
|
@@ -655,7 +731,7 @@ export const deliverChatReply = async ({
|
|
|
655
731
|
const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
|
|
656
732
|
if (chunks.length <= 1 && !hasIndivisibleOversize) {
|
|
657
733
|
const response = await postMessage({ content: chunks[0] ?? text });
|
|
658
|
-
if (response
|
|
734
|
+
if (undelivered(response)) return nonDelivery(response, 0, 1);
|
|
659
735
|
return { mode: 'single', messages: 1 };
|
|
660
736
|
}
|
|
661
737
|
if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
|
|
@@ -663,7 +739,7 @@ export const deliverChatReply = async ({
|
|
|
663
739
|
for (const chunk of chunks) {
|
|
664
740
|
// eslint-disable-next-line no-await-in-loop
|
|
665
741
|
const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
|
|
666
|
-
if (response
|
|
742
|
+
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
|
|
667
743
|
messages += 1;
|
|
668
744
|
}
|
|
669
745
|
return { mode: 'split', messages };
|
|
@@ -683,7 +759,7 @@ export const deliverChatReply = async ({
|
|
|
683
759
|
let posted = 0;
|
|
684
760
|
try {
|
|
685
761
|
const rootRes = await postMessage({ content: chunks[0] });
|
|
686
|
-
if (rootRes
|
|
762
|
+
if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
|
|
687
763
|
posted = 1;
|
|
688
764
|
// The runtime route answers `res.json(result)` with the created row on
|
|
689
765
|
// `result.message`. Accept either id field; refuse to guess if neither
|
|
@@ -695,7 +771,7 @@ export const deliverChatReply = async ({
|
|
|
695
771
|
for (const chunk of chunks.slice(1)) {
|
|
696
772
|
// eslint-disable-next-line no-await-in-loop
|
|
697
773
|
const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
|
|
698
|
-
if (response
|
|
774
|
+
if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
|
|
699
775
|
posted += 1;
|
|
700
776
|
}
|
|
701
777
|
return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
|
|
@@ -710,7 +786,7 @@ export const deliverChatReply = async ({
|
|
|
710
786
|
for (const chunk of chunks.slice(posted)) {
|
|
711
787
|
// eslint-disable-next-line no-await-in-loop
|
|
712
788
|
const response = await postMessage({ content: chunk });
|
|
713
|
-
if (response
|
|
789
|
+
if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
|
|
714
790
|
posted += 1;
|
|
715
791
|
}
|
|
716
792
|
return { mode: 'thread-fallback', messages: chunks.length };
|
|
@@ -736,7 +812,7 @@ export const deliverChatReply = async ({
|
|
|
736
812
|
? chunks[0]
|
|
737
813
|
: '(reply too large for chat — attached in full)';
|
|
738
814
|
const response = await postMessage({ content: `${lead}\n\n${directive}` });
|
|
739
|
-
if (response
|
|
815
|
+
if (undelivered(response)) return nonDelivery(response, 0, 1);
|
|
740
816
|
return { mode: 'attach', messages: 1 };
|
|
741
817
|
} catch (err) {
|
|
742
818
|
log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
|
|
@@ -744,7 +820,7 @@ export const deliverChatReply = async ({
|
|
|
744
820
|
for (const chunk of chunks) {
|
|
745
821
|
// eslint-disable-next-line no-await-in-loop
|
|
746
822
|
const response = await postMessage({ content: chunk });
|
|
747
|
-
if (response
|
|
823
|
+
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
|
|
748
824
|
messages += 1;
|
|
749
825
|
}
|
|
750
826
|
return { mode: 'split-fallback', messages };
|