product-console-companion 0.7.0 → 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
  }
@@ -17,6 +17,7 @@ export const COMPANION_VERSION = packageVersion;
17
17
  export const COMPANION_CAPABILITIES = [
18
18
  'auto-surface-map@1',
19
19
  'auto-surface-mapper@1',
20
+ 'auto-surface-mapper@2',
20
21
  'snapshot-read@1',
21
22
  'local-git-range@1',
22
23
  'verify-automatic-surface@1',
@@ -14,7 +14,7 @@ const JS_TO_TS = {
14
14
  '.mjs': ['.mts'],
15
15
  '.cjs': ['.cts'],
16
16
  };
17
- const BLOCKING = new Set([
17
+ const BLOCKING_V1 = new Set([
18
18
  'unresolved_internal',
19
19
  'unresolved_dynamic',
20
20
  'declaration_only',
@@ -28,6 +28,16 @@ const BLOCKING = new Set([
28
28
  'limit_files',
29
29
  'limit_bytes',
30
30
  ]);
31
+ /** Under @2, `limit_bytes` is a deliberate trim (nearest files kept), not a block. */
32
+ const BLOCKING_V2 = new Set([...BLOCKING_V1].filter((code) => code !== 'limit_bytes'));
33
+ /** Every mapper version this companion can produce; advertised as capabilities. */
34
+ export const SUPPORTED_MAPPER_VERSIONS = [
35
+ 'auto-surface-mapper@1',
36
+ 'auto-surface-mapper@2',
37
+ ];
38
+ export function isSupportedMapperVersion(value) {
39
+ return SUPPORTED_MAPPER_VERSIONS.includes(value);
40
+ }
31
41
  export class MappingFailure extends Error {
32
42
  kind;
33
43
  code;
@@ -501,6 +511,8 @@ function seedRoots(seeds, workspace) {
501
511
  }
502
512
  export async function expandImports(input) {
503
513
  const settings = input.settings ?? DEFAULT_MAPPER_SETTINGS;
514
+ const mapperVersion = input.mapperVersion ?? 'auto-surface-mapper@1';
515
+ const blocking = mapperVersion === 'auto-surface-mapper@2' ? BLOCKING_V2 : BLOCKING_V1;
504
516
  const workspace = await discoverWorkspace(input.sandboxPath, input.tree);
505
517
  const diagnostics = [...workspace.diagnostics];
506
518
  const externals = [];
@@ -606,11 +618,11 @@ export async function expandImports(input) {
606
618
  const uniqueDiagnostics = [...new Map(diagnostics.map((entry) => [`${entry.code}\0${entry.path}\0${entry.detail}`, entry])).values()].sort(compareDiagnostic);
607
619
  const uniqueExternals = [...new Map(externals.map((entry) => [`${entry.from}\0${entry.specifier}`, entry])).values()].sort((a, b) => a.from.localeCompare(b.from) || a.specifier.localeCompare(b.specifier));
608
620
  return {
609
- mapper: { version: 'auto-surface-mapper@1', settings },
621
+ mapper: { version: mapperVersion, settings },
610
622
  resolutionInputs: workspace.resolutionInputs,
611
623
  files,
612
624
  externals: uniqueExternals,
613
- status: uniqueDiagnostics.some((diagnostic) => BLOCKING.has(diagnostic.code)) ? 'incomplete' : 'complete',
625
+ status: uniqueDiagnostics.some((diagnostic) => blocking.has(diagnostic.code)) ? 'incomplete' : 'complete',
614
626
  diagnostics: uniqueDiagnostics,
615
627
  };
616
628
  }
@@ -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';
@@ -3,11 +3,11 @@ import { execFile } from 'node:child_process';
3
3
  import { statSync } from 'node:fs';
4
4
  import { promisify } from 'node:util';
5
5
  import { hasLinkedPathComponent } from './investigation-reader.js';
6
- import { DEFAULT_MAPPER_SETTINGS, enumerateTrackedFiles, expandImports, MappingFailure, trackedNamedPathStatus, } from './import-resolver.js';
6
+ import { DEFAULT_MAPPER_SETTINGS, enumerateTrackedFiles, expandImports, isSupportedMapperVersion, MappingFailure, trackedNamedPathStatus, } from './import-resolver.js';
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',
@@ -191,6 +191,9 @@ function seedPrompt(claim, trackedPaths) {
191
191
  }
192
192
  export async function mapSurfaceSnapshot(input) {
193
193
  const { claim, sandboxPath, tree } = input;
194
+ if (!isSupportedMapperVersion(claim.mapperVersion)) {
195
+ throw new MappingFailure('stable', 'mapper_version_unsupported', `Unsupported mapper version ${String(claim.mapperVersion)}`);
196
+ }
194
197
  const diagnostics = [];
195
198
  const namedPaths = [];
196
199
  const namedSeeds = [];
@@ -275,6 +278,7 @@ export async function mapSurfaceSnapshot(input) {
275
278
  tree,
276
279
  seeds,
277
280
  settings: claim.mapperSettings,
281
+ mapperVersion: claim.mapperVersion,
278
282
  });
279
283
  const expansion = diagnostics.some((diagnostic) => diagnostic.code === 'named_path_unverifiable')
280
284
  ? { ...expanded, status: 'incomplete' }
@@ -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.0",
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",