9943a280db
- Added optional `/loop` `count|duration` command arguments and wired `command.args` into loop handling. - Implemented `loop-limit` parsing and runtime types/helpers for iteration and duration budget limits with validation. - Updated interactive mode to enforce loop limits per iteration, check duration expiry, and clear budget state on disable. - Added loop-limit parse/runtime tests and fixed `/loop` arg errors plus macOS `MallocStackLogging` environment leakage.
141 lines
3.9 KiB
TypeScript
141 lines
3.9 KiB
TypeScript
export type LoopLimitConfig =
|
|
| {
|
|
kind: "iterations";
|
|
iterations: number;
|
|
}
|
|
| {
|
|
kind: "duration";
|
|
durationMs: number;
|
|
};
|
|
|
|
export type LoopLimitRuntime =
|
|
| {
|
|
kind: "iterations";
|
|
initial: number;
|
|
remaining: number;
|
|
}
|
|
| {
|
|
kind: "duration";
|
|
durationMs: number;
|
|
deadlineMs: number;
|
|
};
|
|
|
|
const TIME_UNITS_MS = new Map<string, number>([
|
|
["s", 1_000],
|
|
["sec", 1_000],
|
|
["secs", 1_000],
|
|
["second", 1_000],
|
|
["seconds", 1_000],
|
|
["m", 60_000],
|
|
["min", 60_000],
|
|
["mins", 60_000],
|
|
["minute", 60_000],
|
|
["minutes", 60_000],
|
|
["h", 3_600_000],
|
|
["hr", 3_600_000],
|
|
["hrs", 3_600_000],
|
|
["hour", 3_600_000],
|
|
["hours", 3_600_000],
|
|
]);
|
|
|
|
export function parseLoopLimitArgs(args: string): LoopLimitConfig | undefined | string {
|
|
const trimmed = args.trim().toLowerCase();
|
|
if (!trimmed) return undefined;
|
|
|
|
const parts = trimmed.split(/\s+/);
|
|
if (parts.length > 2) {
|
|
return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min.";
|
|
}
|
|
|
|
if (parts.length === 2) {
|
|
return parseDurationParts(parts[0], parts[1]);
|
|
}
|
|
|
|
const token = parts[0];
|
|
const iterationMatch = /^(\d+)$/.exec(token);
|
|
if (iterationMatch) {
|
|
const iterations = Number(iterationMatch[1]);
|
|
if (!Number.isSafeInteger(iterations) || iterations <= 0) {
|
|
return "Loop count must be a positive integer.";
|
|
}
|
|
return { kind: "iterations", iterations };
|
|
}
|
|
|
|
const durationMatch = /^(\d+)([a-z]+)$/.exec(token);
|
|
if (durationMatch) {
|
|
return parseDurationParts(durationMatch[1], durationMatch[2]);
|
|
}
|
|
|
|
return "Usage: /loop [count|duration]. Examples: /loop 10, /loop 10m, /loop 10min.";
|
|
}
|
|
|
|
function parseDurationParts(amountText: string, unitText: string): LoopLimitConfig | string {
|
|
if (!/^\d+$/.test(amountText)) {
|
|
return "Loop duration must use a positive integer amount.";
|
|
}
|
|
|
|
const amount = Number(amountText);
|
|
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
|
return "Loop duration must be positive.";
|
|
}
|
|
|
|
const unitMs = TIME_UNITS_MS.get(unitText);
|
|
if (unitMs === undefined) {
|
|
return "Loop duration unit must be seconds, minutes, or hours.";
|
|
}
|
|
|
|
return { kind: "duration", durationMs: amount * unitMs };
|
|
}
|
|
|
|
export function createLoopLimitRuntime(
|
|
config: LoopLimitConfig | undefined,
|
|
nowMs = Date.now(),
|
|
): LoopLimitRuntime | undefined {
|
|
if (!config) return undefined;
|
|
if (config.kind === "iterations") {
|
|
return { kind: "iterations", initial: config.iterations, remaining: config.iterations };
|
|
}
|
|
return { kind: "duration", durationMs: config.durationMs, deadlineMs: nowMs + config.durationMs };
|
|
}
|
|
|
|
export function consumeLoopLimitIteration(limit: LoopLimitRuntime | undefined, nowMs = Date.now()): boolean {
|
|
if (!limit) return true;
|
|
if (limit.kind === "duration") {
|
|
return nowMs < limit.deadlineMs;
|
|
}
|
|
if (limit.remaining <= 0) return false;
|
|
limit.remaining -= 1;
|
|
return true;
|
|
}
|
|
|
|
export function isLoopDurationExpired(limit: LoopLimitRuntime | undefined, nowMs = Date.now()): boolean {
|
|
return limit?.kind === "duration" && nowMs >= limit.deadlineMs;
|
|
}
|
|
|
|
export function describeLoopLimit(config: LoopLimitConfig): string {
|
|
if (config.kind === "iterations") {
|
|
return `${config.iterations} ${config.iterations === 1 ? "iteration" : "iterations"}`;
|
|
}
|
|
return formatDuration(config.durationMs);
|
|
}
|
|
|
|
export function describeLoopLimitRuntime(limit: LoopLimitRuntime): string {
|
|
if (limit.kind === "iterations") {
|
|
return `${limit.remaining} of ${limit.initial} ${limit.initial === 1 ? "iteration" : "iterations"} remaining`;
|
|
}
|
|
return `${formatDuration(limit.durationMs)} limit`;
|
|
}
|
|
|
|
function formatDuration(durationMs: number): string {
|
|
if (durationMs % 3_600_000 === 0) {
|
|
const hours = durationMs / 3_600_000;
|
|
return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
}
|
|
if (durationMs % 60_000 === 0) {
|
|
const minutes = durationMs / 60_000;
|
|
return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
|
|
}
|
|
const seconds = durationMs / 1_000;
|
|
return `${seconds} ${seconds === 1 ? "second" : "seconds"}`;
|
|
}
|