product-console-companion 0.7.1 → 0.7.2

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.
@@ -16,6 +16,7 @@ import { join } from 'node:path';
16
16
  import { buildLocalDesignContext } from './design-context.js';
17
17
  import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
18
18
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
19
+ import { replyText, streamWithFallback } from './text-model.js';
19
20
  const MAX_CANDIDATE_FILES = 8;
20
21
  /** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
21
22
  * (with a note in the log), rather than read and cut off partway through. The whole point of
@@ -25,7 +26,7 @@ const MAX_FILE_BYTES = 512 * 1024;
25
26
  /** Structured model-only call used by the investigation reader. The reader, not this call and
26
27
  * not the model, owns every repository read and evidence id. */
27
28
  export async function runStructuredModelCall(client, request) {
28
- const stream = client.messages.stream({
29
+ const stream = streamWithFallback(client, {
29
30
  model: request.model,
30
31
  max_tokens: request.maxTokens,
31
32
  thinking: { type: 'adaptive' },
@@ -46,14 +47,12 @@ export async function runStructuredModelCall(client, request) {
46
47
  }
47
48
  throw error;
48
49
  }
49
- if (message.stop_reason === 'refusal')
50
- throw new Error('The model declined the structured investigation call.');
51
50
  if (message.stop_reason === 'max_tokens')
52
51
  throw new Error('budget_exhausted: the structured investigation call reached its output-token limit.');
53
- const text = message.content.find((block) => block.type === 'text');
54
- if (!text || text.type !== 'text')
52
+ const text = replyText(message, 'The model declined the structured investigation call.');
53
+ if (!text)
55
54
  throw new Error('The structured investigation call returned no text.');
56
- return JSON.parse(text.text);
55
+ return JSON.parse(text);
57
56
  }
58
57
  export const fileSelectSchema = {
59
58
  type: 'object',
@@ -19,6 +19,7 @@ import { StringDecoder } from 'node:string_decoder';
19
19
  import { assertSingleLineArguments } from './cli-arguments.js';
20
20
  import { gitErrorMessage } from './sandbox.js';
21
21
  import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
22
+ import { ModelRefusalError } from './text-model.js';
22
23
  /** Independent of --max-budget-usd — a dollar cap doesn't prevent a network-stall hang. No
23
24
  * automatic retry on top of this (see cli.ts): auto-retry would risk double-spending the
24
25
  * customer's own subscription/API quota. */
@@ -85,6 +86,7 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
85
86
  assertSingleLineArguments('claude', args);
86
87
  const filesRead = new Set();
87
88
  let resultEnvelope = null;
89
+ let lastStopReason = null;
88
90
  let stderrTail = '';
89
91
  let timedOut = false;
90
92
  let stdinError = null;
@@ -152,6 +154,9 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
152
154
  resultEnvelope = event;
153
155
  }
154
156
  else if (eventType === 'assistant') {
157
+ const stopReason = event.message?.stop_reason;
158
+ if (typeof stopReason === 'string')
159
+ lastStopReason = stopReason;
155
160
  const blocks = event.message?.content ?? [];
156
161
  for (const b of blocks) {
157
162
  if (b.type === 'tool_use' && b.name === 'Read' && b.input?.file_path) {
@@ -211,6 +216,11 @@ async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
211
216
  const message = envelope.errors?.length ? envelope.errors.join('; ') : envelope.result;
212
217
  throw new Error(message || 'Unknown claude CLI error');
213
218
  }
219
+ // A refused turn can still end in a non-error result envelope; its result is empty or partial
220
+ // text, so it must fail here rather than be returned (or JSON-parsed) as an answer.
221
+ if (envelope.stop_reason === 'refusal' || lastStopReason === 'refusal') {
222
+ throw new ModelRefusalError('The model declined this request.');
223
+ }
214
224
  if (capturedStdinError) {
215
225
  throw promptWriteFailure(capturedStdinError);
216
226
  }
@@ -15,7 +15,7 @@ export const MAX_EVIDENCE_FILE_BYTES = 512 * 1024;
15
15
  * ceiling leaves at least 3x headroom while bounding serialized prompt overhead.
16
16
  */
17
17
  export const MAX_ASSESSMENT_PROMPT_CHARACTERS = 384 * 1024;
18
- export const INVESTIGATION_MODEL = 'claude-sonnet-5';
18
+ export const INVESTIGATION_MODEL = 'claude-sonnet-5-5';
19
19
  const execFileAsync = promisify(execFile);
20
20
  export class MissingPolicy4ExecutionError extends Error {
21
21
  name = 'MissingPolicy4ExecutionError';
@@ -7,7 +7,7 @@ import { DEFAULT_MAPPER_SETTINGS, enumerateTrackedFiles, expandImports, isSuppor
7
7
  import { repositoryMissingReason, spawnFailureCode } from './local-scan.js';
8
8
  import { createSandbox } from './sandbox.js';
9
9
  export const SURFACE_SEED_PROMPT_VERSION = 'map_module_seeds@1';
10
- export const SURFACE_SEED_MODEL = 'claude-sonnet-5';
10
+ export const SURFACE_SEED_MODEL = 'claude-sonnet-5-5';
11
11
  const execFileAsync = promisify(execFile);
12
12
  const MODEL_ROLES = [
13
13
  'implementation',
@@ -36,6 +36,34 @@ const PURPOSE_ERRORS = {
36
36
  empty: 'No result returned.',
37
37
  },
38
38
  };
39
+ /** Server-side refusal fallback: on a policy decline the API re-runs the request on Anthropic's
40
+ * recommended substitute inside the same call. The scalar "default" form needs exactly this header
41
+ * (the array form is gated by -2026-06-01; pairing either header with the other form is a 400). */
42
+ const FALLBACK_BETA = 'server-side-fallback-2026-07-01';
43
+ /** SDK 0.111 types `fallbacks` as the array form only; the API also takes the scalar "default". */
44
+ const DEFAULT_FALLBACKS = 'default';
45
+ /** Every API-key call goes through here, so each one carries the refusal fallback. */
46
+ export function streamWithFallback(client, params) {
47
+ return client.beta.messages.stream({ ...params, betas: [FALLBACK_BETA], fallbacks: DEFAULT_FALLBACKS });
48
+ }
49
+ /** stop_reason 'refusal' — after the fallback, the whole chain declined. Thrown before content is
50
+ * read, so a partial or empty reply is never parsed as a result. */
51
+ export class ModelRefusalError extends Error {
52
+ category;
53
+ constructor(message, category = null) {
54
+ super(message);
55
+ this.name = 'ModelRefusalError';
56
+ this.category = category;
57
+ }
58
+ }
59
+ /** The reply text once a refusal is ruled out. A mid-output fallback continues the declined
60
+ * model's partial text in a later block, so every text block is joined; null when there is none. */
61
+ export function replyText(message, refusal) {
62
+ if (message.stop_reason === 'refusal')
63
+ throw new ModelRefusalError(refusal, message.stop_details?.category ?? null);
64
+ const text = message.content.map((block) => (block.type === 'text' ? block.text : '')).join('');
65
+ return text || null;
66
+ }
39
67
  function usageRecord(value) {
40
68
  if (!value || typeof value !== 'object' || Array.isArray(value))
41
69
  return null;
@@ -68,7 +96,7 @@ export function anthropicTextModel(client) {
68
96
  const content = request.purpose.endsWith(':file_select') && blocks.length === 1 && blocks[0].type === 'text'
69
97
  ? blocks[0].text
70
98
  : blocks;
71
- const stream = client.messages.stream({
99
+ const stream = streamWithFallback(client, {
72
100
  model: request.model,
73
101
  max_tokens: request.maxTokens,
74
102
  thinking: { type: 'adaptive' },
@@ -83,11 +111,11 @@ export function anthropicTextModel(client) {
83
111
  refusal: `The model declined the ${request.purpose} call.`,
84
112
  empty: `The ${request.purpose} call returned no text.`,
85
113
  };
86
- if (message.stop_reason === 'refusal' && errors.refusal)
87
- throw new Error(errors.refusal);
88
- const text = message.content.find((block) => block.type === 'text');
89
- if (!text || text.type !== 'text')
114
+ // A purpose with no refusal wording of its own still fails as a refusal, under its empty-reply
115
+ // wording — never by reading whatever partial text the declined attempt left behind.
116
+ const text = replyText(message, errors.refusal ?? errors.empty);
117
+ if (!text)
90
118
  throw new Error(errors.empty);
91
- return { text: text.text, usage: usageRecord(message.usage) };
119
+ return { text, usage: usageRecord(message.usage) };
92
120
  };
93
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-console-companion",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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",