@steve31415/baselib 3.14.0 → 3.16.0

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/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # baselib
2
2
 
3
- The Plasticine new-world shared platform library (`@steve31415/baselib`).
3
+ The Plasticine fleet's shared platform library (`@steve31415/baselib`).
4
4
  What it provides and why: `docs/SPEC.md`. How it's put together:
5
5
  `docs/IMPL.md`. Full design rationale with alternatives:
6
- `~/migration/research/base-services-design.md` (step 5).
6
+ `~/migration/research/base-services-design.md`.
7
7
 
8
8
  Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
9
9
  `app-update`, `llm`, `email`, and `twitter`. Browser exports: `log-browser`, `rum`,
@@ -34,17 +34,15 @@ deploy (`~/plasticine-way/docs/OPERATIONS.md`, "Deployment safety").
34
34
  first step of any production investigation (same doc, "Investigating
35
35
  production state").
36
36
 
37
- `twitter` provides Mirror2-first tweet lookup, TwitterAPI.io batch/search/
37
+ `twitter` provides Mirror-first tweet lookup, TwitterAPI.io batch/search/
38
38
  article calls, and thread assembly through direct reply edges. It includes
39
39
  one level of quotes, article bodies and URL expansions. Use one shared
40
40
  `MirrorTweetClient(mirrorUrl, serviceFetch)` and `TwitterApiIoClient(key)`
41
41
  per run; `assembleThread(id, { api, mirror, ancestors: 'all' })` returns
42
42
  role-tagged tweets, source counts and warnings. Batch prefetching Mirror
43
- ids seeds the shared cache. Reader2 and Snip retain their copies for now.
43
+ ids seeds the shared cache. Reader and Snip retain their copies for now.
44
44
 
45
45
  Releasing: bump `version`, commit, and run `npm run deploy` (Coder runs it
46
46
  automatically when integrating a baselib branch) — it pushes the `v<version>`
47
47
  tag, whose workflow gates on verify and publishes via OIDC, and waits for the
48
48
  registry (`~/plasticine-way/docs/OPERATIONS.md`, "Publishing baselib").
49
-
50
- Reflects Plasticine Way commit: see `docs/IMPL.md` footer.
@@ -1,7 +1,9 @@
1
1
  export declare function requestStarted(): void;
2
2
  export declare function requestEnded(): void;
3
- /** The fields a diagnostic event carries: requests in flight right now and
4
- * how long ago the last one ended (undefined before the first request). */
3
+ /** The fields an event carries: requests in flight right now and how long
4
+ * ago the last one ended. Before the first request has ended there is no
5
+ * such moment, so `sinceLastRequestMs` is omitted rather than invented;
6
+ * `inFlightRequests` alone marks a server that has not finished one yet. */
5
7
  export declare function activitySnapshot(): {
6
8
  inFlightRequests: number;
7
9
  sinceLastRequestMs?: number;
@@ -10,5 +12,5 @@ export declare function activitySnapshot(): {
10
12
  * request ends when the handler returns its Response; a streaming body
11
13
  * that outlives that is not tracked. */
12
14
  export declare function trackActivity<A extends unknown[]>(fetch: (...args: A) => Response | Promise<Response>): (...args: A) => Promise<Response>;
13
- /** Test hook: forget everything. */
15
+ /** Test hook: forget everything, including the logger registration. */
14
16
  export declare function resetActivity(): void;
package/dist/activity.js CHANGED
@@ -1,5 +1,5 @@
1
- // Request activity of this process, for log events that need to know
2
- // whether Cloud Run had CPU allocated at the time: with request-based
1
+ // Request activity of this process, carried by every server log event so
2
+ // any of them can say whether Cloud Run had CPU allocated at the time: with request-based
3
3
  // billing (the fleet default) an instance's CPU is throttled to almost
4
4
  // nothing between requests, and timers, connection handshakes and
5
5
  // credential refreshes crawl. A connection failure logged with
@@ -7,10 +7,13 @@
7
7
  // throttled tail; one with `inFlightRequests: 1` did not.
8
8
  //
9
9
  // `startServer` tracks every HTTP request automatically. A custom server
10
- // wraps its fetch handler with `trackActivity`. WebSocket sessions are not
10
+ // wraps its fetch handler with `trackActivity`. Wrapping is also what
11
+ // registers this module as the logger's process-meta source (process-meta.ts), so a
12
+ // process that serves nothing logs no activity fields. WebSocket sessions are not
11
13
  // counted (they hold CPU on Cloud Run, but the upgrade bypasses fetch), so
12
14
  // on a WebSocket-heavy app zero in-flight requests does not prove the
13
15
  // instance was idle.
16
+ import { setProcessMetaSource } from './process-meta.js';
14
17
  let inFlight = 0;
15
18
  let lastRequestEndedAt = 0;
16
19
  export function requestStarted() {
@@ -20,8 +23,10 @@ export function requestEnded() {
20
23
  inFlight = Math.max(0, inFlight - 1);
21
24
  lastRequestEndedAt = Date.now();
22
25
  }
23
- /** The fields a diagnostic event carries: requests in flight right now and
24
- * how long ago the last one ended (undefined before the first request). */
26
+ /** The fields an event carries: requests in flight right now and how long
27
+ * ago the last one ended. Before the first request has ended there is no
28
+ * such moment, so `sinceLastRequestMs` is omitted rather than invented;
29
+ * `inFlightRequests` alone marks a server that has not finished one yet. */
25
30
  export function activitySnapshot() {
26
31
  return {
27
32
  inFlightRequests: inFlight,
@@ -32,6 +37,7 @@ export function activitySnapshot() {
32
37
  * request ends when the handler returns its Response; a streaming body
33
38
  * that outlives that is not tracked. */
34
39
  export function trackActivity(fetch) {
40
+ setProcessMetaSource(activitySnapshot);
35
41
  return async (...args) => {
36
42
  requestStarted();
37
43
  try {
@@ -42,8 +48,9 @@ export function trackActivity(fetch) {
42
48
  }
43
49
  };
44
50
  }
45
- /** Test hook: forget everything. */
51
+ /** Test hook: forget everything, including the logger registration. */
46
52
  export function resetActivity() {
53
+ setProcessMetaSource(undefined);
47
54
  inFlight = 0;
48
55
  lastRequestEndedAt = 0;
49
56
  }
package/dist/auth.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare const SESSION_COOKIE = "pw_session";
6
6
  /** The `code` in a 403 JSON body when a signed-in user is refused by the
7
7
  * app's userAccess policy (as opposed to any other 403). */
8
8
  export declare const ACCESS_DENIED_CODE = "app_access_denied";
9
- /** A user's fleet role, assigned in auth2's users table. */
9
+ /** A user's fleet role, assigned in auth's users table. */
10
10
  export type UserRole = 'owner' | 'invited';
11
11
  /** Which signed-in users an app admits. 'owner' (the default): only the
12
12
  * fleet owner(s). 'invited': any active fleet user. */
@@ -39,7 +39,7 @@ export interface RequireIdentityOptions extends S2sOptions {
39
39
  missMs?: number;
40
40
  };
41
41
  /** Which signed-in users this app admits. Default 'owner': only users
42
- * whose auth2 role is owner. Pass 'invited' only for an app that is
42
+ * whose auth role is owner. Pass 'invited' only for an app that is
43
43
  * multi-user by design, and say so in its SPEC. Applies to browser
44
44
  * sessions and the test bypass alike; service callers are governed by
45
45
  * grants, not by this. */
package/dist/auth.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // in-instance (10 min hits / 30 s misses — the old-world numbers).
13
13
  //
14
14
  // Then the user-access policy: by default an app admits only users whose
15
- // auth2 role is 'owner'; `userAccess: 'invited'` admits any active fleet
15
+ // auth role is 'owner'; `userAccess: 'invited'` admits any active fleet
16
16
  // user. Being on the fleet sign-in allowlist never grants access by itself,
17
17
  // so a port that forgets the question is closed, not open. Service callers
18
18
  // are governed by grants, not by this policy.
package/dist/db.d.ts CHANGED
@@ -100,9 +100,9 @@ export declare const FLEET_POOL_DEFAULTS: {
100
100
  * any transaction or advisory lock it held is gone.
101
101
  * Each event carries `elapsedMs` (time in that phase: the connect
102
102
  * attempt, the wait, the idle spell, the checkout), the pg `code`, the
103
- * pool's counts, `inFlightRequests` + `sinceLastRequestMs` (`activity.ts`:
104
- * was Cloud Run CPU allocated?) and, given a Cloud SQL credential reader,
105
- * `credentialRemainingMs`. Query:
103
+ * pool's counts and, given a Cloud SQL credential reader,
104
+ * `credentialRemainingMs`; the logger adds request activity, as on every
105
+ * server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
106
106
  * `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
107
107
  *
108
108
  * The checked-out path is the one that used to crash the process: pg-pool
@@ -111,7 +111,7 @@ export declare const FLEET_POOL_DEFAULTS: {
111
111
  * rule: an advisory lock is not mutual exclusion. Our locks limit
112
112
  * duplicated effort; code under one must be safe to overlap, and long
113
113
  * work should probe the lock connection periodically (`select 1`) and
114
- * stop on failure (Plasticine Way `docs/DATABASE.md`; mirror2's ingestion
114
+ * stop on failure (Plasticine Way `docs/DATABASE.md`; mirror's ingestion
115
115
  * loop is the reference).
116
116
  *
117
117
  * `createPool` attaches all of this; exported for a pool an app built
package/dist/db.js CHANGED
@@ -5,8 +5,8 @@
5
5
  import { readdir, readFile } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import pg from 'pg';
8
- import { activitySnapshot } from './activity.js';
9
8
  import { requireEnv } from './config.js';
9
+ import { processMeta } from './process-meta.js';
10
10
  import { serializeError } from './log-core.js';
11
11
  // DATE columns come back as 'YYYY-MM-DD' strings, not JS Dates: calendar
12
12
  // dates are timezone-free by meaning, and a Date object smears them across
@@ -106,9 +106,9 @@ const instrumented = new WeakSet();
106
106
  * any transaction or advisory lock it held is gone.
107
107
  * Each event carries `elapsedMs` (time in that phase: the connect
108
108
  * attempt, the wait, the idle spell, the checkout), the pg `code`, the
109
- * pool's counts, `inFlightRequests` + `sinceLastRequestMs` (`activity.ts`:
110
- * was Cloud Run CPU allocated?) and, given a Cloud SQL credential reader,
111
- * `credentialRemainingMs`. Query:
109
+ * pool's counts and, given a Cloud SQL credential reader,
110
+ * `credentialRemainingMs`; the logger adds request activity, as on every
111
+ * server event (`activity.ts`: was Cloud Run CPU allocated?). Query:
112
112
  * `['apps'] | where kind == 'db-connection-loss' | summarize count() by app, tostring(meta.phase)`.
113
113
  *
114
114
  * The checked-out path is the one that used to crash the process: pg-pool
@@ -117,7 +117,7 @@ const instrumented = new WeakSet();
117
117
  * rule: an advisory lock is not mutual exclusion. Our locks limit
118
118
  * duplicated effort; code under one must be safe to overlap, and long
119
119
  * work should probe the lock connection periodically (`select 1`) and
120
- * stop on failure (Plasticine Way `docs/DATABASE.md`; mirror2's ingestion
120
+ * stop on failure (Plasticine Way `docs/DATABASE.md`; mirror's ingestion
121
121
  * loop is the reference).
122
122
  *
123
123
  * `createPool` attaches all of this; exported for a pool an app built
@@ -134,7 +134,6 @@ export function logPoolErrors(pool, logger, options = {}) {
134
134
  ...(since === undefined ? {} : { elapsedMs: Date.now() - since }),
135
135
  code: err.code,
136
136
  pool: { total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount },
137
- ...activitySnapshot(),
138
137
  ...(options.credentialExpiry
139
138
  ? expiry === undefined
140
139
  ? { credentialUnknown: true }
@@ -144,8 +143,9 @@ export function logPoolErrors(pool, logger, options = {}) {
144
143
  };
145
144
  if (log)
146
145
  log.warn(message, meta);
146
+ // No logger to add the activity fields, so add them here.
147
147
  else
148
- console.warn(message, { kind: CONNECTION_LOSS_KIND, ...meta, error: serializeError(err) });
148
+ console.warn(message, { kind: CONNECTION_LOSS_KIND, ...processMeta(), ...meta, error: serializeError(err) });
149
149
  };
150
150
  // Per-client bookkeeping across one checkout: the borrower-side error
151
151
  // listener (pg-pool drops its own at checkout) and when the client was
@@ -3,7 +3,7 @@ export declare const SHA40: RegExp;
3
3
  /** Lock staleness is judged from the GCS object's server-side timeCreated,
4
4
  * never from anything the lock writer wrote (its clock may be wrong). */
5
5
  export declare function lockIsStale(timeCreatedIso: string, nowMs: number, staleMs: number): boolean;
6
- /** Consistent serving base across an app's services (notes2's rule,
6
+ /** Consistent serving base across an app's services (notes's rule,
7
7
  * generalized): all services on one SHA -> that SHA; some already on the
8
8
  * target -> the rest must agree on one other SHA (the base); anything else
9
9
  * is a skew that needs a human. A null buildSha (pre-unification revision)
@@ -11,7 +11,7 @@ export function lockIsStale(timeCreatedIso, nowMs, staleMs) {
11
11
  return false;
12
12
  return nowMs - created > staleMs;
13
13
  }
14
- /** Consistent serving base across an app's services (notes2's rule,
14
+ /** Consistent serving base across an app's services (notes's rule,
15
15
  * generalized): all services on one SHA -> that SHA; some already on the
16
16
  * target -> the rest must agree on one other SHA (the base); anything else
17
17
  * is a skew that needs a human. A null buildSha (pre-unification revision)
@@ -51,7 +51,7 @@ export interface DeployConfig {
51
51
  * runtime-config change is a commit the deploy carries rather than a hand-run
52
52
  * `gcloud run services update`. Undeclared vars are left as they are. */
53
53
  updateEnv?: Record<string, string>;
54
- /** Refuse to deploy across a migrations/ diff (notes2 semantics). */
54
+ /** Refuse to deploy across a migrations/ diff (notes semantics). */
55
55
  refuseMigrationDiffOnDeploy?: boolean;
56
56
  /** Repo-relative path of an optional ES module exporting hooks. */
57
57
  hooks?: string;
@@ -114,10 +114,10 @@ export interface DeployHooks {
114
114
  publishAssets?: (ctx: HookContext) => Promise<void>;
115
115
  /** Runs after all gates pass, immediately before the first traffic switch. */
116
116
  preRelease?: (ctx: HookContext) => Promise<void>;
117
- /** Runs right after traffic moves (e.g. todo2's announce-build). */
117
+ /** Runs right after traffic moves (e.g. todo's announce-build). */
118
118
  postTraffic?: (ctx: HookContext) => Promise<void>;
119
119
  /** Extra smoke checks after the shared health checks. */
120
120
  smoke?: (ctx: HookContext) => Promise<void>;
121
- /** Runs after everything else succeeded (e.g. notes2's release state). */
121
+ /** Runs after everything else succeeded (e.g. notes's release state). */
122
122
  postSuccess?: (ctx: HookContext) => Promise<void>;
123
123
  }
package/dist/http.js CHANGED
@@ -55,8 +55,9 @@ export function startServer(opts) {
55
55
  assertBootSafety();
56
56
  logUncaughtErrors(opts.logger);
57
57
  const port = opts.port ?? Number(process.env.PORT ?? 8080);
58
- // Every request counts as activity (activity.ts) so connection-loss
59
- // events can say whether Cloud Run had CPU allocated at the time.
58
+ // Every request counts as activity (activity.ts), and wrapping registers
59
+ // the logger's activity source: every event from here on says whether
60
+ // Cloud Run had CPU allocated at the time.
60
61
  const server = serve({ fetch: trackActivity(opts.app.fetch), port }, (info) => {
61
62
  opts.logger.info('server started', { port: info.port });
62
63
  });
@@ -66,7 +66,8 @@ export async function llmComplete(request) {
66
66
  temperature,
67
67
  supportsTemperature: modelEntry.supportsTemperature ?? true,
68
68
  supportsThinkingBudgetZero: modelEntry.supportsThinkingBudgetZero ?? true,
69
- effort: modelEntry.supportsEffort ? request.effort : undefined,
69
+ supportsForcedToolChoice: modelEntry.supportsForcedToolChoice ?? true,
70
+ effort: modelEntry.supportsEffort ? (request.effort ?? modelEntry.defaultEffort) : undefined,
70
71
  jsonSchema: request.jsonSchema,
71
72
  apiKey,
72
73
  logger: request.logger,
@@ -40,6 +40,23 @@ export interface ModelEntry {
40
40
  * when the caller's output budget leaves no meaningful headroom.
41
41
  */
42
42
  thinksByDefault?: boolean;
43
+ /**
44
+ * Whether this model accepts a forced `tool_choice` (`{type: 'tool'}`),
45
+ * which the Anthropic adapter uses to get `jsonSchema` output. Defaults to
46
+ * `true`. Opus 5.5 rejects it with a 400 ("tool_choice: type \"tool\" and
47
+ * \"any\" are not supported for this model.", verified 2026-09-22); for
48
+ * such models the adapter requests structured output via
49
+ * `output_config.format` instead.
50
+ */
51
+ supportsForcedToolChoice?: boolean;
52
+ /**
53
+ * Effort level sent when the caller passes none (effort-capable models
54
+ * only). Absent means "send nothing and take the provider's default".
55
+ * Opus 5.5's API default is `medium`, one level below Opus 5's `high`; the
56
+ * registry pins `high` so moving a call site from Opus 5 to Opus 5.5 does
57
+ * not silently lower its effort.
58
+ */
59
+ defaultEffort?: 'low' | 'medium' | 'high';
43
60
  }
44
61
  /**
45
62
  * Output budget below which a `thinksByDefault` model is considered at risk of
@@ -10,7 +10,7 @@ export const THINKING_HEADROOM_WARN_TOKENS = 4096;
10
10
  */
11
11
  export const MODEL_REGISTRY = {
12
12
  // 2026-08 suite refresh: current models are claude-sonnet-5 / claude-opus-5
13
- // (Anthropic), the gpt-5.6 sol/terra/luna family (OpenAI), and the Gemini
13
+ // (Anthropic; claude-opus-5-5 succeeded claude-opus-5 on 2026-09-22), the gpt-5.6 sol/terra/luna family (OpenAI), and the Gemini
14
14
  // 3.5/3.6 GA line plus gemini-3.1-pro-preview (Google). Older names stay
15
15
  // registered so existing callers keep working, but no fallback chain routes
16
16
  // through a retiring model: gemini-2.5-pro and gemini-2.5-flash shut down
@@ -106,6 +106,19 @@ export const MODEL_REGISTRY = {
106
106
  fallbacks: ['claude-sonnet-5', 'gemini-3.1-pro-preview'],
107
107
  },
108
108
  // --- Top tier ---
109
+ 'claude-opus-5-5': {
110
+ provider: 'anthropic',
111
+ supportsEffort: true,
112
+ defaultEffort: 'high',
113
+ apiModelId: 'claude-opus-5-5',
114
+ fallbacks: ['gpt-5.6-sol', 'gemini-3.1-pro-preview'],
115
+ // All verified live 2026-09-22: rejects `temperature` and forced
116
+ // tool_choice (400 each); thinking is always on and its tokens count
117
+ // against `maxTokens`.
118
+ supportsTemperature: false,
119
+ supportsForcedToolChoice: false,
120
+ thinksByDefault: true,
121
+ },
109
122
  'claude-opus-5': {
110
123
  provider: 'anthropic',
111
124
  supportsEffort: true,
@@ -136,14 +149,14 @@ export const MODEL_REGISTRY = {
136
149
  'gpt-5.6-sol': {
137
150
  provider: 'openai',
138
151
  apiModelId: 'gpt-5.6-sol',
139
- fallbacks: ['claude-opus-5', 'gemini-3.1-pro-preview'],
152
+ fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
140
153
  supportsTemperature: false,
141
154
  thinksByDefault: true,
142
155
  },
143
156
  'gpt-5.2': {
144
157
  provider: 'openai',
145
158
  apiModelId: 'gpt-5.2',
146
- fallbacks: ['claude-opus-5', 'gemini-3.1-pro-preview'],
159
+ fallbacks: ['claude-opus-5-5', 'gemini-3.1-pro-preview'],
147
160
  },
148
161
  // Google's best Pro model as of 2026-08 — still Preview-labeled (no GA
149
162
  // Gemini 3.x Pro exists), but the only Pro-class option once 2.5-pro
@@ -151,7 +164,7 @@ export const MODEL_REGISTRY = {
151
164
  'gemini-3.1-pro-preview': {
152
165
  provider: 'gemini',
153
166
  apiModelId: 'gemini-3.1-pro-preview',
154
- fallbacks: ['claude-opus-5', 'gpt-5.6-sol'],
167
+ fallbacks: ['claude-opus-5-5', 'gpt-5.6-sol'],
155
168
  // Thinks by default (thinking_level defaults to `high`).
156
169
  thinksByDefault: true,
157
170
  // Rejects thinkingBudget 0 (400, verified 2026-08-09).
@@ -161,6 +174,6 @@ export const MODEL_REGISTRY = {
161
174
  'gemini-2.5-pro': {
162
175
  provider: 'gemini',
163
176
  apiModelId: 'gemini-2.5-pro',
164
- fallbacks: ['claude-opus-5', 'gpt-5.6-sol'],
177
+ fallbacks: ['claude-opus-5-5', 'gpt-5.6-sol'],
165
178
  },
166
179
  };
@@ -12,6 +12,51 @@ function classifyFinish(stopReason) {
12
12
  default: return 'other';
13
13
  }
14
14
  }
15
+ /**
16
+ * Keywords `output_config.format` rejects outright with a 400 (verified on
17
+ * Opus 5.5, 2026-09-22). `minItems` is also rejected for values other than 0
18
+ * or 1, handled separately; `minLength`/`maxLength`, `pattern` and `format`
19
+ * are accepted.
20
+ */
21
+ const UNSUPPORTED_FORMAT_KEYWORDS = [
22
+ 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
23
+ 'maxItems', 'uniqueItems',
24
+ ];
25
+ /**
26
+ * Transform a portable JSON schema into the dialect Anthropic's structured
27
+ * outputs (`output_config.format`) accepts: every object schema must set
28
+ * `additionalProperties: false`, and the numeric/array bounds listed above are
29
+ * dropped. The model then no longer enforces those bounds — callers validate
30
+ * the parsed result as they already must for truncation. Unlike forced
31
+ * tool_use, optional properties stay optional. Returns a deep copy — the
32
+ * caller's schema object is reused across failover attempts.
33
+ */
34
+ function toOutputFormatSchema(node) {
35
+ if (Array.isArray(node))
36
+ return node.map(toOutputFormatSchema);
37
+ if (node === null || typeof node !== 'object')
38
+ return node;
39
+ const out = {};
40
+ for (const [key, value] of Object.entries(node)) {
41
+ if (UNSUPPORTED_FORMAT_KEYWORDS.includes(key))
42
+ continue;
43
+ if (key === 'minItems' && typeof value === 'number' && value > 1)
44
+ continue;
45
+ // Maps whose values are schemas (not keywords): recurse per entry so a
46
+ // property that happens to be named `minimum` is kept.
47
+ if ((key === 'properties' || key === '$defs' || key === 'definitions')
48
+ && value !== null && typeof value === 'object' && !Array.isArray(value)) {
49
+ out[key] = Object.fromEntries(Object.entries(value).map(([k, v]) => [k, toOutputFormatSchema(v)]));
50
+ continue;
51
+ }
52
+ out[key] = toOutputFormatSchema(value);
53
+ }
54
+ const type = out.type;
55
+ if (type === 'object' || (Array.isArray(type) && type.includes('object'))) {
56
+ out.additionalProperties = false;
57
+ }
58
+ return out;
59
+ }
15
60
  export const anthropicProvider = {
16
61
  name: 'anthropic',
17
62
  async complete(request) {
@@ -26,7 +71,7 @@ export const anthropicProvider = {
26
71
  max_tokens: request.maxTokens,
27
72
  messages: [{ role: 'user', content: request.prompt }],
28
73
  };
29
- // Some models (Opus 4.7+, Sonnet 5, Fable 5) reject `temperature`
74
+ // Some models (Opus 4.7+, Sonnet 5, Fable 5, Opus 5.5) reject `temperature`
30
75
  // outright with a 400 — omit the field entirely rather than sending it.
31
76
  if (request.supportsTemperature) {
32
77
  body.temperature = request.temperature;
@@ -42,8 +87,16 @@ export const anthropicProvider = {
42
87
  ? [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }]
43
88
  : request.systemPrompt;
44
89
  }
45
- // JSON schema: use tool_use to get structured output
46
- if (request.jsonSchema) {
90
+ // JSON schema: a forced tool_use call where the model allows it, else
91
+ // structured outputs (Opus 5.5 rejects forced tool_choice).
92
+ const useOutputFormat = request.jsonSchema !== undefined && !request.supportsForcedToolChoice;
93
+ if (request.jsonSchema && useOutputFormat) {
94
+ body.output_config = {
95
+ ...body.output_config,
96
+ format: { type: 'json_schema', schema: toOutputFormatSchema(request.jsonSchema.schema) },
97
+ };
98
+ }
99
+ else if (request.jsonSchema) {
47
100
  body.tools = [{
48
101
  name: request.jsonSchema.name,
49
102
  description: `Generate structured output matching the ${request.jsonSchema.name} schema`,
@@ -103,7 +156,18 @@ export const anthropicProvider = {
103
156
  const finishReason = data.stop_reason ?? undefined;
104
157
  let text;
105
158
  let json;
106
- if (request.jsonSchema) {
159
+ if (useOutputFormat) {
160
+ text = data.content?.find((c) => c.type === 'text')?.text || '';
161
+ try {
162
+ json = text ? JSON.parse(text) : undefined;
163
+ }
164
+ catch {
165
+ // Truncated or malformed body: surface as json undefined, like the
166
+ // tool_use path, so the caller's truncation handling applies.
167
+ json = undefined;
168
+ }
169
+ }
170
+ else if (request.jsonSchema) {
107
171
  const toolUse = data.content?.find((c) => c.type === 'tool_use');
108
172
  json = toolUse?.input;
109
173
  text = json !== undefined ? JSON.stringify(json) : '';
@@ -176,6 +176,13 @@ export interface ProviderCallRequest {
176
176
  * model would 400); non-zero budgets pass through unchanged.
177
177
  */
178
178
  supportsThinkingBudgetZero: boolean;
179
+ /**
180
+ * Whether the target model accepts a forced `tool_choice`, per the registry
181
+ * entry's `supportsForcedToolChoice` flag. When `false`, the Anthropic
182
+ * adapter gets `jsonSchema` output through `output_config.format` instead
183
+ * of a forced tool call. Other providers ignore it.
184
+ */
185
+ supportsForcedToolChoice: boolean;
179
186
  jsonSchema?: JsonSchema;
180
187
  apiKey: string;
181
188
  logger?: Logger;
@@ -121,6 +121,10 @@ export interface EventFactoryOptions {
121
121
  app: string;
122
122
  source: 'server' | 'browser';
123
123
  context?: Record<string, unknown>;
124
+ /** Process-level fields merged under `meta` on every event, read at event
125
+ * time (the server logger passes process-meta.ts's request-activity source).
126
+ * Ignored for browser events. The caller's own meta wins on a name clash. */
127
+ processMeta?: () => Record<string, unknown> | undefined;
124
128
  }
125
129
  export declare function makeEvent(opts: EventFactoryOptions, level: LogLevel, message: string, meta?: Record<string, unknown>): LogEvent;
126
130
  /** The event for one logger call plus, the first time a site's meta breaks
package/dist/log-core.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // bounded, backed-off retries and per-batch identity
7
7
  // (~/migration/research/logging-reliability-design.md): every undelivered
8
8
  // batch either lands in Axiom or ends in an onGiveUp callback — the server
9
- // logger turns those into stdout drop markers that watchdog2's daily
9
+ // logger turns those into stdout drop markers that watchdog's daily
10
10
  // ship-failure check counts. makeEvent also guards the shape of `meta`: every
11
11
  // distinct key is an Axiom column (see "Key-shape guard" below).
12
12
  export const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
@@ -361,6 +361,11 @@ function buildEvent(opts, level, message, meta) {
361
361
  if (rewrites.length > 0)
362
362
  m = { ...m, logShape: rewrites.join('+') };
363
363
  }
364
+ // Added after shaping: a fixed vocabulary that must not count against the
365
+ // site's leaf budget.
366
+ const processMeta = opts.source === 'server' ? opts.processMeta?.() : undefined;
367
+ if (processMeta)
368
+ m = { ...processMeta, ...m };
364
369
  const event = {
365
370
  _time: new Date().toISOString(),
366
371
  app: opts.app,
package/dist/log.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface LoggerOptions {
15
15
  context?: Record<string, unknown>;
16
16
  fetchFn?: typeof fetch;
17
17
  }
18
- /** Register a directly-constructed shipper (e.g. watchdog2's platform-dataset
18
+ /** Register a directly-constructed shipper (e.g. watchdog's platform-dataset
19
19
  * shipper) so flushAllLoggers and shutdown cover it too. */
20
20
  export declare function registerShipper(shipper: LogShipper): void;
21
21
  /** Flush every registered shipper. `final` is the shutdown path: time-boxed
package/dist/log.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // log-core's key-shape guard rewrites offenders and WARNs once per site.
10
10
  import { writeSync } from 'node:fs';
11
11
  import { LogShipper, makeEvents, truncate, } from './log-core.js';
12
+ import { processMeta } from './process-meta.js';
12
13
  // makeEvent/makeEvents are exported for app test fakes: routing a recording
13
14
  // logger's calls through makeEvents applies the key-shape guard (strict under
14
15
  // vitest) to fake-logged sites too.
@@ -18,7 +19,7 @@ export { LogShipper, makeEvent, makeEvents, serializeError, setLogShapeStrict, t
18
19
  // with() children share their root's shipper, so registering loggers leaked
19
20
  // one entry per child.
20
21
  const allShippers = new Set();
21
- /** Register a directly-constructed shipper (e.g. watchdog2's platform-dataset
22
+ /** Register a directly-constructed shipper (e.g. watchdog's platform-dataset
22
23
  * shipper) so flushAllLoggers and shutdown cover it too. */
23
24
  export function registerShipper(shipper) {
24
25
  allShippers.add(shipper);
@@ -147,7 +148,7 @@ export function createLogger(opts) {
147
148
  process.once('beforeExit', () => void shipper.finalFlush());
148
149
  }
149
150
  function build(context) {
150
- const factory = { app: opts.app, source: 'server', context };
151
+ const factory = { app: opts.app, source: 'server', context, processMeta };
151
152
  const emit = (level, message, meta) => {
152
153
  for (const event of makeEvents(factory, level, message, meta)) {
153
154
  if (stdout)
@@ -0,0 +1,3 @@
1
+ export declare function setProcessMetaSource(next: (() => Record<string, unknown>) | undefined): void;
2
+ /** The registered source's fields now; undefined when none is registered. */
3
+ export declare function processMeta(): Record<string, unknown> | undefined;
@@ -0,0 +1,16 @@
1
+ // The one process-wide source of fields the server logger merges under
2
+ // `meta` on every event. Today that is request activity (activity.ts),
3
+ // registered by whatever serves HTTP (trackActivity, so startServer and
4
+ // custom servers alike): a process that never registers — a Cloud Run Job,
5
+ // a CLI, a test — emits no such fields at all, so absence means "not a
6
+ // server" and 0 means "idle server". Internal: not part of the package's
7
+ // public surface, and kept apart from log.ts so neither the logger nor
8
+ // activity.ts depends on the other.
9
+ let source;
10
+ export function setProcessMetaSource(next) {
11
+ source = next;
12
+ }
13
+ /** The registered source's fields now; undefined when none is registered. */
14
+ export function processMeta() {
15
+ return source?.();
16
+ }
package/dist/rum.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Real-user monitoring: Core Web Vitals shipped to the Axiom `rum` dataset
2
- // (targets: ~/migration/research/performance-targets.md). Configured by the
2
+ // (targets: ~/plasticine-way/docs/PERFORMANCE.md). Configured by the
3
3
  // same pw-log-* meta tags as log-browser; token-less pages (local dev)
4
4
  // no-op. Metrics finalize on tab-hide, so the shipper flushes there with
5
5
  // keepalive fetch.
package/dist/s2s.js CHANGED
@@ -99,7 +99,7 @@ export async function resolveS2sCaller(opts, headers, method, path) {
99
99
  // ("Wrong number of segments in token: <jwt>", "Invalid token signature:
100
100
  // <jwt>"), so the error is never logged as-is: the token's shape and a
101
101
  // redacted message only (PW LESSONS 2026-09-02 — log the shape, never
102
- // the credential). Found by Mirror2's device-key design review.
102
+ // the credential). Found by Mirror's device-key design review.
103
103
  opts.logger?.warn('s2s token verification failed', {
104
104
  path,
105
105
  reason: redactToken(err instanceof Error ? err.message : String(err), m[1]),
@@ -55,7 +55,7 @@ export declare class TwitterApiError extends Error {
55
55
  constructor(message: string, apiCalls: number);
56
56
  }
57
57
  export declare function assertTweetId(id: string): void;
58
- /** Harvested from Reader2/Mirror2: bounded retries and timeout, four active
58
+ /** Harvested from Reader/Mirror: bounded retries and timeout, four active
59
59
  * requests per shared client. Credentials and upstream bodies are never logged. */
60
60
  export declare class TwitterApiIoClient implements TwitterClient {
61
61
  private readonly apiKey;
@@ -17,7 +17,7 @@ export function assertTweetId(id) {
17
17
  if (!/^\d{1,25}$/.test(id))
18
18
  throw new Error('Invalid tweet id');
19
19
  }
20
- /** Harvested from Reader2/Mirror2: bounded retries and timeout, four active
20
+ /** Harvested from Reader/Mirror: bounded retries and timeout, four active
21
21
  * requests per shared client. Credentials and upstream bodies are never logged. */
22
22
  export class TwitterApiIoClient {
23
23
  apiKey;
@@ -50,10 +50,10 @@ export class MirrorTweetClient {
50
50
  signal: AbortSignal.timeout(30_000),
51
51
  });
52
52
  if (!response.ok)
53
- throw new Error(`Mirror2 tweets query HTTP ${response.status}`);
53
+ throw new Error(`Mirror tweets query HTTP ${response.status}`);
54
54
  const data = await response.json();
55
55
  if (!Array.isArray(data.records))
56
- throw new Error('Mirror2 response has no records array');
56
+ throw new Error('Mirror response has no records array');
57
57
  const tweets = new Map();
58
58
  for (const r of data.records) {
59
59
  if (r.is_deleted || r.deleted_at)
package/dist/twitter.js CHANGED
@@ -67,7 +67,7 @@ export async function assembleThread(id, options) {
67
67
  add(await mirror.getTweetsByIds(missing), 'mirror2');
68
68
  }
69
69
  catch (error) {
70
- warning('Mirror2 lookup failed', error);
70
+ warning('Mirror lookup failed', error);
71
71
  }
72
72
  const absent = missing.filter((key) => !records.has(key));
73
73
  if (absent.length)
@@ -85,10 +85,10 @@ export async function assembleThread(id, options) {
85
85
  add(conversation.tweets, 'mirror2');
86
86
  archivedAuthor = conversation.complete && conversation.tweets.some((tweet) => sameAuthor(tweet, linked));
87
87
  if (!conversation.complete)
88
- warning('Mirror2 conversation reached its row limit; searching the author via API');
88
+ warning('Mirror conversation reached its row limit; searching the author via API');
89
89
  }
90
90
  catch (error) {
91
- warning('Mirror2 conversation failed', error);
91
+ warning('Mirror conversation failed', error);
92
92
  }
93
93
  if (!archivedAuthor && /^\w+$/.test(linked.author.userName)) {
94
94
  let cursor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steve31415/baselib",
3
- "version": "3.14.0",
3
+ "version": "3.16.0",
4
4
  "description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
5
5
  "type": "module",
6
6
  "license": "MIT",