Files
gstack/lib/ship-calls.ts
Garry Tanandcapy-ai[bot] b6cb70aea1 v1.91.355.0 ship: poll readers in-program, 32 KB returns, gated fix program, unattended push defers the guard offer, calls meter (GSTA-167 calls PR 1) (#3662)
* ship: poll readers in-program, never wait on wakes; 32 KB returns; gated fix program; unattended push defers the guard offer; calls meter (GSTA-167 calls PR 1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (inventory lib/ship-release.ts as a GSTACK_SESSION_KIND reader)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ship-budget): document calls and dotted --step notes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ship: judge treats a missing severity as invalid; Capy passes rendered briefs via cat (replay C1-34 breaks)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: keep the Capy skill under its context budget after the main merge

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 07:22:56 +00:00

41 lines
2.4 KiB
TypeScript

/**
* ship-calls — a driven /ship's parent calls, counted from the host's transcript events
* (Capy: tools.thread.get transcript items), so acceptance never rests on the agent's own
* count (GSTA-167 calls plan, C9). Every tool item is one parent call. A keep-alive is a
* wait, or a program that only reads status (subagent, operation or thread reads) plus at
* most one bash for its budget note; everything else is a decision call. A gap over 300 s
* between calls outlives the prompt cache, so the next call is billed fresh.
*/
export interface TranscriptItem { source?: string; tool?: string; calls?: Array<{ tool?: string }>; createdAt?: string }
export interface CallCount { total: number; decision: number; keepalive: number; idleGaps: number; maxGapS: number; first: string | null; last: string | null }
const STATUS_READS = new Set(['subagent_get', 'subagent_list', 'operation_get', 'operation_list', 'thread_get']);
export const CACHE_TTL_S = 300;
function isKeepalive(item: TranscriptItem): boolean {
if (item.tool === 'wait') return true;
if (item.tool !== 'execute') return false;
const calls = (item.calls ?? []).map(c => c.tool ?? '');
const reads = calls.filter(t => STATUS_READS.has(t)).length;
const bash = calls.filter(t => t === 'bash').length;
return reads > 0 && reads + bash === calls.length && bash <= 1;
}
export function countCalls(items: readonly TranscriptItem[], window: { since?: string; until?: string } = {}): CallCount {
const calls = items.filter(i => i.source === 'tool' && i.createdAt
&& (!window.since || i.createdAt >= window.since) && (!window.until || i.createdAt <= window.until))
.sort((a, b) => a.createdAt!.localeCompare(b.createdAt!));
let idleGaps = 0, maxGapS = 0;
for (let k = 1; k < calls.length; k++) {
const gap = Math.round((Date.parse(calls[k]!.createdAt!) - Date.parse(calls[k - 1]!.createdAt!)) / 1000);
if (gap > CACHE_TTL_S) idleGaps++;
maxGapS = Math.max(maxGapS, gap);
}
const keepalive = calls.filter(isKeepalive).length;
return { total: calls.length, decision: calls.length - keepalive, keepalive, idleGaps, maxGapS,
first: calls[0]?.createdAt ?? null, last: calls[calls.length - 1]?.createdAt ?? null };
}
export const callsLine = (c: CallCount, source: string) =>
`CALLS: total=${c.total} decision=${c.decision} keepalive=${c.keepalive} idle_gaps_over_300s=${c.idleGaps} max_gap_s=${c.maxGapS} first=${c.first ?? 'none'} last=${c.last ?? 'none'} source=${source}`;