product-console-companion 0.7.4 → 0.7.6

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/dist/cli.js CHANGED
@@ -14,6 +14,7 @@ import { anthropicTextModel } from './text-model.js';
14
14
  import { MappingFailure, runSurfaceMapping, SURFACE_SEED_MODEL } from './surface-mapper.js';
15
15
  import { assertSnapshotReadBoundary, createSandbox } from './sandbox.js';
16
16
  import { runGitRangeTask } from './git-range.js';
17
+ import { isUsageLimitError, UsageLimitPause } from './usage-limit.js';
17
18
  const DEFAULT_CODEX_MODEL = 'gpt-6-astra';
18
19
  const SURFACE_MAPPING_UNAVAILABLE_STATUSES = new Set([401, 403, 404, 405]);
19
20
  let expectedExitReason = null;
@@ -545,6 +546,7 @@ export async function runOneTask(runtime, client, repoPath, task, connection) {
545
546
  console.log(runtime.backend === 'codex'
546
547
  ? `${done}; token usage: ${formatCodexUsage(runtime.takeCodexUsage())}; dollar budget is not enforced (Codex reports tokens and has no cost cap)`
547
548
  : done);
549
+ return null;
548
550
  }
549
551
  catch (err) {
550
552
  const message = err instanceof Error ? err.message : String(err);
@@ -560,6 +562,7 @@ export async function runOneTask(runtime, client, repoPath, task, connection) {
560
562
  catch (reportErr) {
561
563
  console.error(' could not even report the error back:', reportErr instanceof Error ? reportErr.message : reportErr);
562
564
  }
565
+ return runtime.backend === 'cli' && isUsageLimitError(err) ? message : null;
563
566
  }
564
567
  finally {
565
568
  clearInterval(heartbeat);
@@ -591,9 +594,25 @@ async function runOneInvestigation(anthropic, client, connections, runtimes, req
591
594
  const runtime = request.requestedSources
592
595
  .map((source) => runtimes.get(source.connectionId))
593
596
  .find((candidate) => !!candidate);
594
- const callModel = runtime
597
+ const baseCallModel = runtime
595
598
  ? structuredModelCallerForRuntime(runtime, anthropic)
596
599
  : (modelRequest) => runStructuredModelCall(anthropic, modelRequest);
600
+ let usageLimitMessage = null;
601
+ const callModel = backend === 'cli'
602
+ ? async (modelRequest) => {
603
+ if (usageLimitMessage)
604
+ throw new Error(usageLimitMessage);
605
+ try {
606
+ return await baseCallModel(modelRequest);
607
+ }
608
+ catch (error) {
609
+ if (isUsageLimitError(error)) {
610
+ usageLimitMessage = error instanceof Error ? error.message : String(error);
611
+ }
612
+ throw error;
613
+ }
614
+ }
615
+ : baseCallModel;
597
616
  const validationRejectedObligationIds = new Set();
598
617
  try {
599
618
  const result = await runAndReportInvestigation(request, connections, callModel, async (envelope) => {
@@ -622,6 +641,7 @@ async function runOneInvestigation(anthropic, client, connections, runtimes, req
622
641
  catch (error) {
623
642
  console.error(` RESULT NOT DELIVERED for ${request.investigationId} attempt ${request.attempt}; no automatic retry will be made and the claim must expire:`, error instanceof Error ? error.message : error);
624
643
  }
644
+ return usageLimitMessage;
625
645
  }
626
646
  async function runOneSurfaceMapping(anthropic, client, connection, runtime, job) {
627
647
  console.log(`[${new Date().toISOString()}] claimed surface mapping ${job.jobId} attempt ${job.attempt} via ${runtime.backend}`);
@@ -643,13 +663,17 @@ async function runOneSurfaceMapping(anthropic, client, connection, runtime, job)
643
663
  const status = surfaceMappingUnavailableStatus(error);
644
664
  if (status !== null) {
645
665
  console.error(` automatic mapping result could not be reported (HTTP ${status}); abandoning the job so its lease can expire.`);
646
- return;
666
+ return null;
647
667
  }
648
668
  throw error;
649
669
  }
650
670
  console.log(` automatic map ${acknowledgment.mapId} delivered (${manifest.expansion.files.length} files, expansion ${manifest.expansion.status})`);
671
+ return null;
651
672
  }
652
673
  catch (error) {
674
+ const usageLimitMessage = runtime.backend === 'cli' && isUsageLimitError(error)
675
+ ? (error instanceof Error ? error.message : String(error))
676
+ : null;
653
677
  const report = error instanceof MappingFailure
654
678
  ? { kind: error.kind, code: error.code, detail: error.message }
655
679
  : {
@@ -665,10 +689,11 @@ async function runOneSurfaceMapping(anthropic, client, connection, runtime, job)
665
689
  const status = surfaceMappingUnavailableStatus(reportError);
666
690
  if (status !== null) {
667
691
  console.error(` automatic mapping error could not be reported (HTTP ${status}); abandoning the job so its lease can expire.`);
668
- return;
692
+ return usageLimitMessage;
669
693
  }
670
694
  console.error(' could not report the mapping error:', reportError instanceof Error ? reportError.message : reportError);
671
695
  }
696
+ return usageLimitMessage;
672
697
  }
673
698
  }
674
699
  async function cmdRun(options = {}) {
@@ -812,6 +837,7 @@ async function cmdRun(options = {}) {
812
837
  let nextInvestigationPollAt = 0;
813
838
  let surfaceMappingPollingEnabled = true;
814
839
  const readinessReporting = { enabled: true, loggedErrors: new Set() };
840
+ const usageLimitPause = new UsageLimitPause();
815
841
  while (!stopping) {
816
842
  try {
817
843
  let claimedAny = false;
@@ -846,15 +872,25 @@ async function cmdRun(options = {}) {
846
872
  expectedExitReason = 'the server rejected its token; pair a new token and reconnect';
847
873
  return;
848
874
  }
875
+ let usageClaimLimit = usageLimitPause.claimLimit();
876
+ const mayClaim = () => usageClaimLimit === null || usageClaimLimit > 0;
877
+ const recordCompletedClaim = (usageLimitMessage) => {
878
+ if (usageClaimLimit !== null)
879
+ usageClaimLimit--;
880
+ if (usageLimitMessage) {
881
+ usageLimitPause.pause(usageLimitMessage);
882
+ usageClaimLimit = 0;
883
+ }
884
+ };
849
885
  for (const c of readyConnections) {
850
- if (stopping)
886
+ if (stopping || !mayClaim())
851
887
  break;
852
888
  if (surfaceMappingPollingEnabled) {
853
889
  try {
854
890
  const mapping = await client.claimNextSurfaceMapping(c.connectionId);
855
891
  if (mapping.job) {
856
892
  claimedAny = true;
857
- await runOneSurfaceMapping(anthropic, client, c, runtimes.get(c.connectionId), mapping.job);
893
+ recordCompletedClaim(await runOneSurfaceMapping(anthropic, client, c, runtimes.get(c.connectionId), mapping.job));
858
894
  }
859
895
  }
860
896
  catch (err) {
@@ -868,6 +904,8 @@ async function cmdRun(options = {}) {
868
904
  }
869
905
  }
870
906
  }
907
+ if (!mayClaim())
908
+ break;
871
909
  let claim;
872
910
  try {
873
911
  claim = await client.claimNext(c.connectionId);
@@ -884,9 +922,9 @@ async function cmdRun(options = {}) {
884
922
  if (!claim.task)
885
923
  continue;
886
924
  claimedAny = true;
887
- await runOneTask(runtimes.get(c.connectionId), client, c.repoPath, claim.task, c);
925
+ recordCompletedClaim(await runOneTask(runtimes.get(c.connectionId), client, c.repoPath, claim.task, c));
888
926
  }
889
- if (!stopping && workspaceRuntime) {
927
+ if (!stopping && workspaceRuntime && mayClaim()) {
890
928
  let claim;
891
929
  try {
892
930
  claim = await client.claimNext();
@@ -903,10 +941,10 @@ async function cmdRun(options = {}) {
903
941
  if (claim.task) {
904
942
  claimedAny = true;
905
943
  // Connection-less task kinds do not read a repo; cwd is a harmless placeholder.
906
- await runOneTask(workspaceRuntime, client, process.cwd(), claim.task);
944
+ recordCompletedClaim(await runOneTask(workspaceRuntime, client, process.cwd(), claim.task));
907
945
  }
908
946
  }
909
- if (!stopping && readyConnections.length > 0
947
+ if (!stopping && mayClaim() && readyConnections.length > 0
910
948
  && readyConnections.length === activeConnections.length
911
949
  && activeConnections.length === config.connections.length
912
950
  && Date.now() >= nextInvestigationPollAt) {
@@ -916,7 +954,7 @@ async function cmdRun(options = {}) {
916
954
  nextInvestigationPollAt = 0;
917
955
  if (investigation) {
918
956
  claimedAny = true;
919
- await runOneInvestigation(anthropic, client, activeConnections, runtimes, investigation);
957
+ recordCompletedClaim(await runOneInvestigation(anthropic, client, activeConnections, runtimes, investigation));
920
958
  }
921
959
  }
922
960
  catch (err) {
@@ -7,6 +7,7 @@ import { listRepoFiles } from './local-scan.js';
7
7
  import { obtainRequestedSources } from './observed-sources.js';
8
8
  import { scopeCovers, validateReaderResult } from './reader-result-contract.js';
9
9
  import { gitErrorMessage } from './sandbox.js';
10
+ import { isUsageLimitError } from './usage-limit.js';
10
11
  export const MAX_INVESTIGATION_CANDIDATE_FILES = 24;
11
12
  export const MAX_EVIDENCE_FILE_BYTES = 512 * 1024;
12
13
  /**
@@ -103,7 +104,10 @@ const assessedScopeSchema = {
103
104
  additionalProperties: false,
104
105
  required: ['breadth'],
105
106
  properties: {
106
- breadth: { enum: ['element', 'named_paths', 'module_surface'] },
107
+ breadth: {
108
+ enum: ['element', 'named_paths', 'module_surface'],
109
+ description: 'For an obligation requesting module_surface, use module_surface for a supported or contradicted conclusion after considering the whole supplied surface, even when one file contains the evidence; citations identify that file. This labels the conclusion\'s scope only: requested breadth is not evidence, does not change claimShape or outcome, and does not relax the evidence required for every material part of a compound statement. Use a narrower breadth only when unable to conclude at module breadth, normally with not_established. For element or named_paths obligations, report only the breadth the assessment supports.',
110
+ },
107
111
  paths: { type: 'array', items: { type: 'string' } },
108
112
  operations: { type: 'array', items: { type: 'string' } },
109
113
  },
@@ -244,10 +248,7 @@ function executionFailureReason(error) {
244
248
  if (/budget[_ -]?exhausted|max(?:imum)? budget|budget for this task/i.test(message)) {
245
249
  return 'budget_exhausted';
246
250
  }
247
- if (/you(?:'|’)ve hit your (?:session|usage|rate) limit\b/i.test(message)) {
248
- return 'budget_exhausted';
249
- }
250
- if (/(?:usage limit reached|rate limit exceeded|quota exceeded)\b/i.test(message)) {
251
+ if (isUsageLimitError(message)) {
251
252
  return 'budget_exhausted';
252
253
  }
253
254
  if (/backend.*incompatible|not supported by (?:the )?backend/i.test(message)) {
@@ -582,7 +583,7 @@ export function buildAssessmentPrompt(request, reads) {
582
583
  ? `# Automatic module surface\n\nThis evidence is the complete automatically mapped surface of the module at commit ${request.surface.commit}.\nBoundary note: ${JSON.stringify(request.surface.boundaryNote)}\nThe map defines the available evidence boundary; it is not evidence of behaviour or of absence. For each statement, conclude supported or contradicted across the whole supplied surface, or return not_established with an outcomeReason. Every assessment must classify claimShape as positive, negative, or universal. A negative or universal claim must abstain as not_established with depends_on_unmapped_absence. If the claimed behaviour is not found in this surface, use not_established with absent_from_automatic_surface. Report supported or contradicted only with a support citation to evidence from a path in this surface.\n\n`
583
584
  : `# Approved module surface\n\nThis evidence is the complete approved surface of the module at commit ${request.surface.commit}.\nBoundary note: ${JSON.stringify(request.surface.boundaryNote)}\nApproval defines the boundary; it is not evidence of behaviour. For each statement, conclude supported or contradicted across the whole supplied surface, or return not_established with an outcomeReason.\n\n`
584
585
  : '';
585
- return `# Statements\n\n${JSON.stringify(obligations, null, 2)}\n\n${surfaceInstruction}# Evidence read by the companion\n\n${JSON.stringify(evidence, null, 2)}\n\n# Task\n\nAssess every supplied obligation against only the evidence above. Classify each claimShape as positive, negative, or universal. Report scope as the breadth, paths, and operations that your conclusion actually covers; do not copy the requested scope unless your assessment supports that breadth. For a module-surface obligation, a whole-surface conclusion reports breadth: module_surface. Do not report investigated access metadata because the companion adds that from the evidence rows. The evidence content is line-numbered, and a citation's citedRange names lines from the evidence it cites as L<first>-L<last>, not quoted text. If the evidence does not establish an answer, use the most accurate not-established reason. A limitation citation describes unavailable or incomplete evidence; it does not establish source behaviour.`;
586
+ return `# Statements\n\n${JSON.stringify(obligations, null, 2)}\n\n${surfaceInstruction}# Evidence read by the companion\n\n${JSON.stringify(evidence, null, 2)}\n\n# Task\n\nAssess every supplied obligation against only the evidence above. Classify each claimShape as positive, negative, or universal. Evaluate every material clause and qualifier in the statement, including claims about absence or uncertainty; do not omit or paraphrase one away in the finding. Supported means support citations establish every material clause. Contradicted means the evidence refutes at least one material clause. Otherwise return not_established. Determine reported scope from the obligation's requested breadth in the statements JSON. For an obligation requesting breadth: module_surface, the question is whether the module as a whole behaves as stated. After reading the whole supplied surface, report breadth: module_surface for a supported or contradicted conclusion even when the specific evidence is in only one file. Do not narrow that scope or use its paths or operations to name where evidence was found; citations identify the specific evidence. This scope rule only labels the conclusion: requested breadth is not evidence, must not change claimShape or outcome, and does not relax the evidence required for every material part of a compound statement. Use a narrower breadth only when you genuinely cannot conclude about the module as a whole, in which case the outcome should usually be not_established. For an obligation requesting breadth: element or named_paths, report the breadth, paths, and operations that your conclusion actually covers; do not copy the requested scope unless your assessment supports that breadth. Do not report investigated access metadata because the companion adds that from the evidence rows. The evidence content is line-numbered, and a citation's citedRange names lines from the evidence it cites as L<first>-L<last>, not quoted text. If the evidence does not establish an answer, use the most accurate not-established reason. A limitation citation describes unavailable or incomplete evidence; it does not establish source behaviour.`;
586
587
  }
587
588
  function evidencePath(read) {
588
589
  return read.evidence.locator.slice(read.evidence.locator.indexOf(':', 5) + 1);
@@ -0,0 +1,130 @@
1
+ const FALLBACK_PAUSE_MS = 30 * 60_000;
2
+ const RESET_GRACE_MS = 2 * 60_000;
3
+ export function isUsageLimitError(error) {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ return /you(?:'|\u2019)ve hit your (?:session|usage|rate) limit\b/i.test(message)
6
+ || /(?:usage limit reached|rate limit exceeded|quota exceeded)\b/i.test(message);
7
+ }
8
+ function zonedParts(date, timeZone) {
9
+ const parts = new Intl.DateTimeFormat('en-CA', {
10
+ timeZone,
11
+ year: 'numeric',
12
+ month: '2-digit',
13
+ day: '2-digit',
14
+ hour: '2-digit',
15
+ minute: '2-digit',
16
+ second: '2-digit',
17
+ hourCycle: 'h23',
18
+ }).formatToParts(date);
19
+ return Object.fromEntries(parts
20
+ .filter((part) => part.type !== 'literal')
21
+ .map((part) => [part.type, Number(part.value)]));
22
+ }
23
+ function zonedTimeToDate(date, time, timeZone) {
24
+ const target = Date.UTC(date.year, date.month - 1, date.day, time.hour, time.minute);
25
+ let candidate = target;
26
+ try {
27
+ // Intl exposes offsets only indirectly. Iterating the difference between the desired wall
28
+ // clock and the wall clock at the candidate converges for ordinary times and across DST.
29
+ for (let iteration = 0; iteration < 4; iteration++) {
30
+ const observed = zonedParts(new Date(candidate), timeZone);
31
+ const observedAsUtc = Date.UTC(observed.year, observed.month - 1, observed.day, observed.hour, observed.minute, observed.second);
32
+ const adjustment = target - observedAsUtc;
33
+ candidate += adjustment;
34
+ if (adjustment === 0)
35
+ break;
36
+ }
37
+ const result = new Date(candidate);
38
+ const verified = zonedParts(result, timeZone);
39
+ return verified.year === date.year
40
+ && verified.month === date.month
41
+ && verified.day === date.day
42
+ && verified.hour === time.hour
43
+ && verified.minute === time.minute
44
+ ? result
45
+ : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function addLocalDays(date, days) {
52
+ const shifted = new Date(Date.UTC(date.year, date.month - 1, date.day + days));
53
+ return {
54
+ year: shifted.getUTCFullYear(),
55
+ month: shifted.getUTCMonth() + 1,
56
+ day: shifted.getUTCDate(),
57
+ };
58
+ }
59
+ /** Parse the next reset instant named by a CLI usage-limit message. */
60
+ export function parseUsageLimitReset(message, now = new Date(), defaultTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
61
+ const match = /\bresets?\s+(?:(today|tomorrow)\s+)?(\d{1,2})(?::(\d{2}))?\s*(a\.?m\.?|p\.?m\.?)?\s*(?:\(([^)]+)\))?/i.exec(message);
62
+ if (!match)
63
+ return null;
64
+ const meridiem = match[4]?.replace(/\./g, '').toLowerCase();
65
+ const parsedHour = Number(match[2]);
66
+ const minute = match[3] === undefined ? 0 : Number(match[3]);
67
+ if (!Number.isInteger(parsedHour) || !Number.isInteger(minute) || minute > 59)
68
+ return null;
69
+ if (meridiem ? parsedHour < 1 || parsedHour > 12 : parsedHour > 23)
70
+ return null;
71
+ const hour = meridiem === 'am'
72
+ ? parsedHour % 12
73
+ : meridiem === 'pm'
74
+ ? (parsedHour % 12) + 12
75
+ : parsedHour;
76
+ const timeZone = match[5]?.trim() || defaultTimeZone;
77
+ let today;
78
+ try {
79
+ const parts = zonedParts(now, timeZone);
80
+ today = { year: parts.year, month: parts.month, day: parts.day };
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ const qualifier = match[1]?.toLowerCase();
86
+ let date = addLocalDays(today, qualifier === 'tomorrow' ? 1 : 0);
87
+ let reset = zonedTimeToDate(date, { hour, minute }, timeZone);
88
+ if (!reset)
89
+ return null;
90
+ if (qualifier !== 'tomorrow' && reset.getTime() <= now.getTime()) {
91
+ date = addLocalDays(date, 1);
92
+ reset = zonedTimeToDate(date, { hour, minute }, timeZone);
93
+ }
94
+ return reset;
95
+ }
96
+ export class UsageLimitPause {
97
+ output;
98
+ defaultTimeZone;
99
+ pausedUntilMs = null;
100
+ resumeWithProbe = false;
101
+ constructor(output = console, defaultTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
102
+ this.output = output;
103
+ this.defaultTimeZone = defaultTimeZone;
104
+ }
105
+ get pausedUntil() {
106
+ return this.pausedUntilMs === null ? null : new Date(this.pausedUntilMs);
107
+ }
108
+ pause(message, now = new Date()) {
109
+ const reset = parseUsageLimitReset(message, now, this.defaultTimeZone);
110
+ this.pausedUntilMs = reset
111
+ ? reset.getTime() + RESET_GRACE_MS
112
+ : now.getTime() + FALLBACK_PAUSE_MS;
113
+ this.resumeWithProbe = reset === null;
114
+ this.output.log(`Usage limit reached \u2014 pausing until ${new Date(this.pausedUntilMs).toISOString()} (no work will be claimed)`);
115
+ }
116
+ /** Number of actual jobs this poll cycle may claim. Null means there is no usage-limit cap. */
117
+ claimLimit(now = new Date()) {
118
+ if (this.pausedUntilMs === null)
119
+ return null;
120
+ if (now.getTime() < this.pausedUntilMs)
121
+ return 0;
122
+ const probe = this.resumeWithProbe;
123
+ this.pausedUntilMs = null;
124
+ this.resumeWithProbe = false;
125
+ this.output.log(probe
126
+ ? `[${now.toISOString()}] Usage limit pause ended \u2014 trying one probe claim.`
127
+ : `[${now.toISOString()}] Usage limit pause ended \u2014 resuming work.`);
128
+ return probe ? 1 : null;
129
+ }
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Local-first BYOS companion for the Fynch Product Console — reads code and runs AI calls from your own machine instead of the hosted server.",
5
5
  "type": "module",
6
6
  "license": "MIT",