@withtruing/companion 0.0.0-stage → 0.13.1
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/LICENSE +21 -0
- package/README.md +328 -2
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +48 -4
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const PURPOSE_ERRORS = {
|
|
2
|
+
'verify_module:file_select': {
|
|
3
|
+
refusal: null,
|
|
4
|
+
empty: 'Could not pick files to check (no text in the file-select response).',
|
|
5
|
+
},
|
|
6
|
+
'verify_module:judge': {
|
|
7
|
+
refusal: 'The model declined to check this module.',
|
|
8
|
+
empty: 'No verdict returned.',
|
|
9
|
+
},
|
|
10
|
+
'generate_spec_batch:file_select': {
|
|
11
|
+
refusal: null,
|
|
12
|
+
empty: 'Could not pick files for this batch (no text in the file-select response).',
|
|
13
|
+
},
|
|
14
|
+
'generate_spec_batch:write': {
|
|
15
|
+
refusal: 'The model declined to write specs for this batch.',
|
|
16
|
+
empty: 'No specs returned.',
|
|
17
|
+
},
|
|
18
|
+
propose_skeleton: {
|
|
19
|
+
refusal: 'The model declined this request.',
|
|
20
|
+
empty: 'No result returned.',
|
|
21
|
+
},
|
|
22
|
+
propose_new_capabilities: {
|
|
23
|
+
refusal: 'The model declined this request.',
|
|
24
|
+
empty: 'No result returned.',
|
|
25
|
+
},
|
|
26
|
+
design_prompt: {
|
|
27
|
+
refusal: 'The model declined to write a design prompt.',
|
|
28
|
+
empty: 'No design prompt returned.',
|
|
29
|
+
},
|
|
30
|
+
analyse_shipment: {
|
|
31
|
+
refusal: 'The model declined to analyse this shipment.',
|
|
32
|
+
empty: 'No drift analysis returned.',
|
|
33
|
+
},
|
|
34
|
+
generic: {
|
|
35
|
+
refusal: 'The model declined this request.',
|
|
36
|
+
empty: 'No result returned.',
|
|
37
|
+
},
|
|
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
|
+
}
|
|
67
|
+
function usageRecord(value) {
|
|
68
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
69
|
+
return null;
|
|
70
|
+
const usage = {};
|
|
71
|
+
for (const [key, candidate] of Object.entries(value)) {
|
|
72
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate))
|
|
73
|
+
usage[key] = candidate;
|
|
74
|
+
}
|
|
75
|
+
return Object.keys(usage).length > 0 ? usage : null;
|
|
76
|
+
}
|
|
77
|
+
/** Adapt the existing Anthropic transport without changing any request field or content-block
|
|
78
|
+
* ordering used by the API-key backend. */
|
|
79
|
+
export function anthropicTextModel(client) {
|
|
80
|
+
return async (request) => {
|
|
81
|
+
const blocks = [];
|
|
82
|
+
if (request.documentBase64) {
|
|
83
|
+
blocks.push({
|
|
84
|
+
type: 'document',
|
|
85
|
+
source: { type: 'base64', media_type: 'application/pdf', data: request.documentBase64 },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (request.stableText) {
|
|
89
|
+
blocks.push({
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: request.stableText,
|
|
92
|
+
cache_control: { type: 'ephemeral' },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
blocks.push({ type: 'text', text: request.text });
|
|
96
|
+
const content = request.purpose.endsWith(':file_select') && blocks.length === 1 && blocks[0].type === 'text'
|
|
97
|
+
? blocks[0].text
|
|
98
|
+
: blocks;
|
|
99
|
+
const stream = streamWithFallback(client, {
|
|
100
|
+
model: request.model,
|
|
101
|
+
max_tokens: request.maxTokens,
|
|
102
|
+
thinking: { type: 'adaptive' },
|
|
103
|
+
output_config: request.schema
|
|
104
|
+
? { effort: request.effort, format: { type: 'json_schema', schema: request.schema } }
|
|
105
|
+
: { effort: request.effort },
|
|
106
|
+
system: request.system,
|
|
107
|
+
messages: [{ role: 'user', content }],
|
|
108
|
+
});
|
|
109
|
+
const message = await stream.finalMessage();
|
|
110
|
+
const errors = PURPOSE_ERRORS[request.purpose] ?? {
|
|
111
|
+
refusal: `The model declined the ${request.purpose} call.`,
|
|
112
|
+
empty: `The ${request.purpose} call returned no text.`,
|
|
113
|
+
};
|
|
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)
|
|
118
|
+
throw new Error(errors.empty);
|
|
119
|
+
return { text, usage: usageRecord(message.usage) };
|
|
120
|
+
};
|
|
121
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire contract with the Console's /api/companion/* routes. Deliberately NOT imported from
|
|
3
|
+
* the parent repo's server/model-caller.ts or shared/types.ts — this package is meant to be
|
|
4
|
+
* independently npm-publishable (see the local-companion plan's Section 7), so it defines its
|
|
5
|
+
* own copy of the shapes it needs rather than reaching across a package boundary that won't
|
|
6
|
+
* exist once this ships standalone. The pure reader-result checks are vendored byte-for-byte in
|
|
7
|
+
* reader-result-contract.ts from the server's canonical copy; keep the remaining shapes in sync
|
|
8
|
+
* with server/model-caller.ts's ModelTaskSpec, server/store.ts's LocalTaskRow, and the named
|
|
9
|
+
* Stage 2 wire schemas by hand.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -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,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@withtruing/companion",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.13.1",
|
|
4
|
+
"description": "The Truing companion: reads your code and runs AI on your own computer, for Truing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"truing": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -b",
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"test": "tsx --test --test-concurrency=1 test/*.test.ts"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"cli",
|
|
23
|
+
"ai",
|
|
24
|
+
"anthropic",
|
|
25
|
+
"claude",
|
|
26
|
+
"companion",
|
|
27
|
+
"byok",
|
|
28
|
+
"byos",
|
|
29
|
+
"truing"
|
|
30
|
+
],
|
|
31
|
+
"homepage": "https://app.withtruing.com",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/fynchconsulting/product-console.git",
|
|
35
|
+
"directory": "companion"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@anthropic-ai/sdk": "^0.111.0",
|
|
39
|
+
"cross-spawn": "^7.0.6",
|
|
40
|
+
"typescript": "5.9.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/cross-spawn": "^6.0.6",
|
|
44
|
+
"@types/node": "^24.10.1",
|
|
45
|
+
"tsx": "^4.20.6"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
}
|
|
50
|
+
}
|