@volter/twin-moonshot 0.1.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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +164 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +25 -0
  5. package/dist/src/index.d.ts +14 -0
  6. package/dist/src/index.js +86 -0
  7. package/dist/src/moonshot-budget.d.ts +57 -0
  8. package/dist/src/moonshot-budget.js +142 -0
  9. package/dist/src/moonshot-capabilities.d.ts +4 -0
  10. package/dist/src/moonshot-capabilities.js +1200 -0
  11. package/dist/src/moonshot-conformance.d.ts +14 -0
  12. package/dist/src/moonshot-conformance.js +405 -0
  13. package/dist/src/moonshot-connector.d.ts +168 -0
  14. package/dist/src/moonshot-connector.js +416 -0
  15. package/dist/src/moonshot-models.d.ts +36 -0
  16. package/dist/src/moonshot-models.js +37 -0
  17. package/dist/src/moonshot-scenario.d.ts +54 -0
  18. package/dist/src/moonshot-scenario.js +175 -0
  19. package/dist/src/moonshot-server.d.ts +13 -0
  20. package/dist/src/moonshot-server.js +202 -0
  21. package/dist/src/moonshot-stub.d.ts +70 -0
  22. package/dist/src/moonshot-stub.js +222 -0
  23. package/dist/src/moonshot-twin.d.ts +144 -0
  24. package/dist/src/moonshot-twin.js +1647 -0
  25. package/dist/src/moonshot-types.d.ts +251 -0
  26. package/dist/src/moonshot-types.js +19 -0
  27. package/package.json +53 -0
  28. package/src/cli.ts +25 -0
  29. package/src/index.ts +129 -0
  30. package/src/moonshot-budget.ts +163 -0
  31. package/src/moonshot-capabilities.ts +1220 -0
  32. package/src/moonshot-conformance.ts +416 -0
  33. package/src/moonshot-connector.ts +465 -0
  34. package/src/moonshot-models.ts +89 -0
  35. package/src/moonshot-scenario.ts +194 -0
  36. package/src/moonshot-server.ts +220 -0
  37. package/src/moonshot-stub.ts +230 -0
  38. package/src/moonshot-twin.ts +1670 -0
  39. package/src/moonshot-types.ts +225 -0
@@ -0,0 +1,465 @@
1
+ // Moonshot CONNECTOR — the live-vendor pull/push path that gives the Moonshot twin the full
2
+ // "git for SaaS" lifecycle (pull real state → mirror; push local writes → real).
3
+ //
4
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of
5
+ // the real state system.
6
+ // REFRESH (real → twin): fetch the real Models / Files / Batches / Balance, map them to
7
+ // observed resources, and fold them onto the root's log through the
8
+ // kernel's `observeResources` (content-addressed entries, so re-pulling
9
+ // identical state appends nothing).
10
+ // PERFORM (twin → real): the HEAD calls `performMoonshotAction` per deployable entry; the
11
+ // vendor's minted id comes back as the `externalId` the kernel adopts.
12
+ //
13
+ // The vendor I/O is an INJECTED executor (B3 auth boundary): the kernel + this pack hold NO
14
+ // Moonshot key and import NO SDK at runtime. Tests pass a fake executor; live runs pass
15
+ // `liveMoonshotExecute(apiKey)`. Same code path either way — fully exercisable offline.
16
+ import { assertBudgetGuardIntact, confirmAction, deployableEntries, observeResources, resolveSubjectId } from '@volter/world-core';
17
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
18
+ import { MoonshotBudget, MoonshotBudgetError, moonshotCallWeight, type MoonshotBudgetOptions } from './moonshot-budget.ts';
19
+
20
+ const SERVICE = 'moonshot';
21
+
22
+ /**
23
+ * The injected real-Moonshot boundary. `request` issues ONE Moonshot REST call:
24
+ * method — 'GET' | 'POST' | 'DELETE'
25
+ * path — e.g. '/v1/files' or '/v1/batches/batch_123/cancel'
26
+ * body — JSON body for POST (omitted otherwise)
27
+ * Returns the parsed JSON (an object, a `{ data }` list, or an `{ error }` envelope).
28
+ */
29
+ export type MoonshotExecute = (
30
+ method: 'GET' | 'POST' | 'DELETE',
31
+ path: string,
32
+ body?: Record<string, unknown>,
33
+ ) => Promise<{ data?: any; error?: { message?: string; type?: string }; [k: string]: unknown }>;
34
+
35
+ /** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
36
+ export type LiveMoonshotOptions = {
37
+ /** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
38
+ fetchImpl?: typeof fetch;
39
+ /** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
40
+ budget?: MoonshotBudget;
41
+ /** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
42
+ budgetOptions?: MoonshotBudgetOptions;
43
+ };
44
+
45
+ /**
46
+ * A live executor against the real Moonshot REST API (the user's own API key). Sends the required
47
+ * `Authorization: Bearer` header. Never imported by the pack's own serve path — only constructed
48
+ * by a caller that opts into real I/O.
49
+ *
50
+ * THIS IS THE ONE PLACE this pack issues a live `api.moonshot.ai` request, and therefore the one
51
+ * place the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE
52
+ * the request goes out (`checkBudget`, which THROWS `MoonshotBudgetError` instead of returning
53
+ * when the ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a
54
+ * `retry-after` / 429 / `x-ratelimit-remaining: 0` signal becomes a persisted cooldown that makes
55
+ * every later call fail fast WITHOUT touching Moonshot. There is deliberately no OPTION to
56
+ * disable the guard, and no value a caller can pass for `budget` that yields an unguarded client.
57
+ * What that does NOT claim is immunity from a caller who WANTS one: a fresh `budgetOptions.path`
58
+ * per construction, or an injected clock, restores the allowance, because the same seam tests
59
+ * need cannot be denied to a determined caller in the same process. See `moonshot-budget.ts` and
60
+ * the kernel header for the limits of the guarantee.
61
+ */
62
+ export function liveMoonshotExecute(
63
+ apiKey: string,
64
+ base = 'https://api.moonshot.ai',
65
+ opts: LiveMoonshotOptions = {},
66
+ ): MoonshotExecute {
67
+ const doFetch = opts.fetchImpl ?? fetch;
68
+ // ONE expression decides which budget is used, so there is no second, weaker test that could
69
+ // disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
70
+ // must be an UNMODIFIED MoonshotBudget — a duck-typed stand-in, a SUBCLASS overriding
71
+ // `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
72
+ // would otherwise hand back a client with no ceiling. The default ledger is keyed by a hash of
73
+ // THIS key: Moonshot limits per account, so a cwd-scoped ledger would hand the same key a
74
+ // fresh allowance per checkout/worktree/CI leg.
75
+ const budget = opts.budget !== undefined && opts.budget !== null
76
+ ? assertBudgetGuardIntact(opts.budget, MoonshotBudget, 'liveMoonshotExecute')
77
+ : new MoonshotBudget({ token: apiKey, ...(opts.budgetOptions ?? {}) });
78
+ return async (method, path, body) => {
79
+ // A path this pack never modelled is refused BEFORE the budget is even charged: an executor
80
+ // that will happily issue any URL is how a careless script reaches an unpriced endpoint.
81
+ if (!path.startsWith('/v1/') && !path.startsWith('/anthropic/v1/')) {
82
+ throw new Error(`moonshot: refusing to call an unmodeled path "${path}" — Moonshot serves /v1 and /anthropic/v1`);
83
+ }
84
+ const headers: Record<string, string> = { authorization: `Bearer ${apiKey}` };
85
+ const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
86
+ if (method === 'POST') {
87
+ headers['content-type'] = 'application/json';
88
+ init.body = JSON.stringify(body ?? {});
89
+ }
90
+ const weight = moonshotCallWeight(method, path);
91
+ // THROWS instead of calling. Nothing below this line runs when the budget refuses.
92
+ const reservation = budget.checkBudget(weight);
93
+ const res = await doFetch(`${base}${path}`, init);
94
+ const resHeaders: Record<string, string> = {};
95
+ res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
96
+ const parsed = (await res.json()) as { data?: any; error?: { message?: string; type?: string } };
97
+ // Settles the reservation and, on a back-off signal, arms the cooldown.
98
+ // recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
99
+ // call that louder refusal wins; an answer Moonshot ACCEPTED is kept, so a write that landed is
100
+ // never recorded as failed and performed again on retry.
101
+ try {
102
+ budget.recordCall(weight, resHeaders, { status: res.status, reservation });
103
+ } catch (error) {
104
+ if (!(error instanceof MoonshotBudgetError) || !res.ok) throw error;
105
+ }
106
+ return parsed;
107
+ };
108
+ }
109
+
110
+ function listOf(res: { data?: unknown }): any[] {
111
+ return Array.isArray(res.data) ? res.data : [];
112
+ }
113
+ /**
114
+ * A REFUSED pull is NOT an empty account. Moonshot answers failures with its documented
115
+ * `{ error: { message, type } }` envelope, so a status check alone is not enough — throw on the
116
+ * envelope rather than folding an empty list over real observed state.
117
+ */
118
+ function throwIfError(res: { error?: { message?: string; type?: string } }, ctx: string): void {
119
+ if (res.error) throw new Error(`moonshot ${ctx} failed: ${res.error.message ?? res.error.type ?? 'unknown error'}`);
120
+ }
121
+
122
+ // ── PULL ────────────────────────────────────────────────────────────────────
123
+
124
+ /** Map a real-Moonshot model object → a twin sync resource. */
125
+ export function mapModel(m: Record<string, unknown>): SyncResource {
126
+ return {
127
+ type: 'model',
128
+ id: String(m.id),
129
+ fields: {
130
+ object: 'model',
131
+ created: (m.created as number) ?? null,
132
+ owned_by: (m.owned_by as string) ?? null,
133
+ },
134
+ };
135
+ }
136
+
137
+ /** Map a real-Moonshot File object → a twin sync resource. */
138
+ export function mapFile(f: Record<string, unknown>): SyncResource {
139
+ return {
140
+ type: 'file',
141
+ id: String(f.id),
142
+ fields: {
143
+ object: 'file',
144
+ bytes: (f.bytes as number) ?? 0,
145
+ created_at: (f.created_at as number) ?? null,
146
+ filename: (f.filename as string) ?? null,
147
+ purpose: (f.purpose as string) ?? null,
148
+ status: (f.status as string) ?? null,
149
+ },
150
+ };
151
+ }
152
+
153
+ /** Map a real-Moonshot Batch object → a twin sync resource. */
154
+ export function mapBatch(b: Record<string, unknown>): SyncResource {
155
+ const counts = (b.request_counts && typeof b.request_counts === 'object') ? (b.request_counts as Record<string, unknown>) : {};
156
+ return {
157
+ type: 'batch',
158
+ id: String(b.id),
159
+ fields: {
160
+ object: 'batch',
161
+ endpoint: (b.endpoint as string) ?? null,
162
+ input_file_id: (b.input_file_id as string) ?? null,
163
+ completion_window: (b.completion_window as string) ?? null,
164
+ status: (b.status as string) ?? null,
165
+ output_file_id: (b.output_file_id as string) ?? null,
166
+ created_at: (b.created_at as number) ?? null,
167
+ completed_at: (b.completed_at as number) ?? null,
168
+ request_counts: counts,
169
+ },
170
+ };
171
+ }
172
+
173
+ /** Map the real-Moonshot balance → the twin's 'balance' resource (id 'me'). */
174
+ export function mapBalance(b: Record<string, unknown>): SyncResource {
175
+ const d = (b.data && typeof b.data === 'object') ? (b.data as Record<string, unknown>) : {};
176
+ return {
177
+ type: 'balance',
178
+ id: 'me',
179
+ fields: {
180
+ available_balance: (d.available_balance as number) ?? null,
181
+ voucher_balance: (d.voucher_balance as number) ?? null,
182
+ cash_balance: (d.cash_balance as number) ?? null,
183
+ },
184
+ };
185
+ }
186
+
187
+ const COLLECTIONS: Array<{ path: string; map: (r: Record<string, unknown>) => SyncResource }> = [
188
+ { path: '/v1/models', map: mapModel },
189
+ { path: '/v1/files', map: mapFile },
190
+ { path: '/v1/batches', map: mapBatch },
191
+ ];
192
+
193
+ /** Pull all modeled real collections via the executor and map them to twin sync resources. */
194
+ export async function pullMoonshotState(execute: MoonshotExecute): Promise<SyncResource[]> {
195
+ const out: SyncResource[] = [];
196
+ for (const c of COLLECTIONS) {
197
+ const res = await execute('GET', c.path);
198
+ throwIfError(res, `pull ${c.path}`);
199
+ for (const item of listOf(res)) out.push(c.map(item));
200
+ }
201
+ // The balance is its own envelope ({code, data:{…}, scode, status}) — no `data` list.
202
+ const bal = await execute('GET', '/v1/users/me/balance');
203
+ throwIfError(bal, 'pull /v1/users/me/balance');
204
+ out.push(mapBalance(bal as Record<string, unknown>));
205
+ return out;
206
+ }
207
+
208
+ /**
209
+ * Pull from real Moonshot and fold into the twin (mirror seeding). Observation entries are
210
+ * content-addressed, so a re-pull of identical state appends nothing.
211
+ *
212
+ * `occurredAt` has NO pinned default on purpose: the kernel hashes an observed event over
213
+ * (occurredAt + post-state), so under a fixed poll time a vendor value that REVERTS across polls
214
+ * collides with its own earlier observation and the fold reports a phantom delta while the
215
+ * projection keeps the stale value (ADDING_A_TWIN.md §6). Callers pass a moving timestamp.
216
+ */
217
+ export async function syncMoonshotFromReal(
218
+ execute: MoonshotExecute,
219
+ opts: { root?: string; occurredAt: string },
220
+ ): Promise<{ observed: number; deltasAppended: number }> {
221
+ const resources = await pullMoonshotState(execute);
222
+ const result = observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
223
+ ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
224
+ });
225
+ return { observed: result.observed, deltasAppended: result.appended };
226
+ }
227
+
228
+ // ── PUSH ────────────────────────────────────────────────────────────────────
229
+
230
+ // The twin operations this connector knows how to push to real Moonshot. Anything not here must
231
+ // FAIL LOUDLY rather than be silently dropped — pushing an unrecognized op risks hitting the
232
+ // wrong endpoint or no-op'ing a real change.
233
+ const PUSHABLE_VERBS = new Set(['create', 'cancel', 'delete']);
234
+
235
+ /** Throw if `op` is not a write operation this connector can faithfully push. */
236
+ function assertPushable(op: string): void {
237
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
238
+ if (!PUSHABLE_VERBS.has(verb)) {
239
+ throw new Error(`moonshot push: unsupported operation '${op}' — refusing to silently drop a local write`);
240
+ }
241
+ const why = UNPUSHABLE[op];
242
+ if (why) throw new Error(`moonshot push: cannot push '${op}' — ${why}`);
243
+ }
244
+
245
+ /** Why this action cannot be pushed, or null if it can. Pure — no vendor call on its path. */
246
+ export function unpushableReason(op: string): string | null {
247
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
248
+ if (!PUSHABLE_VERBS.has(verb)) return `unsupported operation '${op}'`;
249
+ return UNPUSHABLE[op] ?? null;
250
+ }
251
+
252
+ // Map a subject type → its REST collection path.
253
+ const COLLECTION_PATH: Record<string, string> = {
254
+ file: '/v1/files',
255
+ batch: '/v1/batches',
256
+ };
257
+
258
+ /**
259
+ * Pushes this connector must REFUSE rather than fake, keyed `"<type>.<verb>"`.
260
+ *
261
+ * `file.create` is the case: real Moonshot's `POST /v1/files` is multipart/form-data carrying
262
+ * the actual file body, while this executor sends JSON. Pushing `{purpose, filename}` as JSON
263
+ * would 400 at the vendor, and the twin stores no binary content to upload anyway. Refusing
264
+ * loudly is the honest answer; the gap is filed as `moonshot.connector.push_file_create`.
265
+ */
266
+ const UNPUSHABLE: Record<string, string> = {
267
+ 'file.create': "real Moonshot's POST /v1/files is multipart/form-data with the file body; this JSON executor cannot express it, and the twin stores no real bytes to send",
268
+ };
269
+
270
+ /**
271
+ * The twin's LOCALLY-MINTED id namespace (`file_twin_1`, `batch_twin_3` — see `nextId` in
272
+ * moonshot-twin.ts). A subject still bearing one has no counterpart in the real account.
273
+ */
274
+ const LOCAL_ID = /_twin_\d+$/;
275
+
276
+ /**
277
+ * Resolve the id the VENDOR knows this subject by.
278
+ *
279
+ * The kernel records the vendor's minted id at landing time (`vendorSubjectId` → the landed copy
280
+ * carries the vendor's id, `aliasOf` the local one), and `resolveSubjectId` answers it — so a
281
+ * later delete/cancel addresses the vendor by THAT. A locally-minted subject with no recorded
282
+ * alias is refused rather than guessed at — addressing the real account by the twin's own mint
283
+ * would DELETE or CANCEL a resource the vendor never had.
284
+ */
285
+ export function externalIdFor(subjectType: string, subjectId: string, root?: string): string | null {
286
+ if (!LOCAL_ID.test(subjectId)) return subjectId; // already a vendor id (e.g. observed by a pull)
287
+ const resolved = resolveSubjectId(SERVICE, subjectType, subjectId, root);
288
+ return resolved !== subjectId ? resolved : null;
289
+ }
290
+
291
+ /**
292
+ * Resolve the REST (method, path) for ONE pending action — faithful to the real Moonshot REST
293
+ * surface:
294
+ * - <type>.create → POST <collection>
295
+ * - <type>.cancel → POST <collection>/:id/cancel
296
+ * - <type>.delete → DELETE <collection>/:id
297
+ */
298
+ export function moonshotRequestForAction(
299
+ action: Pick<TwinAction, 'operation' | 'subject'>,
300
+ /** The id the VENDOR knows this subject by. Required for anything but a create — see
301
+ * `externalIdFor`; passing the twin's own mint would address a resource the vendor never had. */
302
+ externalId?: string,
303
+ ): { method: 'GET' | 'POST' | 'DELETE'; path: string } {
304
+ const op = action.operation ?? `${action.subject.type}.update`;
305
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
306
+ const collection = COLLECTION_PATH[action.subject.type];
307
+ if (!collection) throw new Error(`moonshot push: no REST collection for subject type '${action.subject.type}'`);
308
+ if (verb === 'create') return { method: 'POST', path: collection };
309
+ const vendorId = externalId ?? action.subject.id;
310
+ if (LOCAL_ID.test(vendorId)) {
311
+ throw new Error(`moonshot push: refusing to address the real account by the twin's own id '${vendorId}' — no vendor id was ever recorded for this subject`);
312
+ }
313
+ if (verb === 'cancel') return { method: 'POST', path: `${collection}/${vendorId}/cancel` };
314
+ if (verb === 'delete') return { method: 'DELETE', path: `${collection}/${vendorId}` };
315
+ // assertPushable rejects anything else, so this is only reached for the pushable verbs above.
316
+ return { method: 'POST', path: collection };
317
+ }
318
+
319
+ /**
320
+ * Push ONE pending action to REAL Moonshot via the injected executor. Returns the real external
321
+ * id (the object id from the response; for a create that's a freshly minted id, otherwise it
322
+ * echoes the subject). WRITES TO THE REAL ACCOUNT.
323
+ */
324
+ export async function pushMoonshotAction(
325
+ execute: MoonshotExecute,
326
+ action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
327
+ opts: { externalId?: string } = {},
328
+ ): Promise<{ externalId: string }> {
329
+ assertPushable(action.operation ?? `${action.subject.type}.update`);
330
+ const { method, path } = moonshotRequestForAction(action, opts.externalId);
331
+ const verb = (action.operation ?? '').includes('.') ? action.operation!.slice(action.operation!.indexOf('.') + 1) : '';
332
+ const payload = verb === 'create' ? createPayload(action) : undefined;
333
+ const res = await execute(method, path, payload);
334
+ throwIfError(res, `push ${action.subject.type}`);
335
+ const id = (res as { id?: unknown }).id;
336
+ return { externalId: typeof id === 'string' && id ? id : action.subject.id };
337
+ }
338
+
339
+ function createPayload(action: Pick<TwinAction, 'subject' | 'fields'>): Record<string, unknown> {
340
+ const f = (action.fields ?? {}) as Record<string, unknown>;
341
+ switch (action.subject.type) {
342
+ case 'batch':
343
+ return { input_file_id: f.input_file_id, endpoint: f.endpoint, completion_window: f.completion_window, ...(f.metadata ? { metadata: f.metadata } : {}) };
344
+ default:
345
+ return {};
346
+ }
347
+ }
348
+
349
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
350
+ /** The pack's executor over the kernel's: the same Moonshot call, carried by the head. The
351
+ * credential never enters this file — the kernel's executor applies it to the authorization
352
+ * header before this sees the request. */
353
+ export function moonshotExecuteOver(execute: RemoteExecute): MoonshotExecute {
354
+ return async (method, path, body) => {
355
+ const res = await execute({
356
+ method, path,
357
+ headers: { accept: 'application/json', authorization: 'Bearer twin', ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
358
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
359
+ });
360
+ if (res.body === '') return {};
361
+ try { return JSON.parse(res.body) as Record<string, unknown>; } catch { return { error: { type: 'server_error', message: res.body.slice(0, 200) } }; }
362
+ };
363
+ }
364
+
365
+ /** The refresh adapter: pull the account's models / files / batches / balance through the executor. */
366
+ export async function syncMoonshotFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string } = {}): Promise<ReturnType<typeof syncMoonshotFromReal>> {
367
+ return syncMoonshotFromReal(moonshotExecuteOver(execute), {
368
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
369
+ occurredAt: opts.occurredAt ?? new Date().toISOString(),
370
+ });
371
+ }
372
+
373
+ /**
374
+ * The PERFORM adapter: one deployable entry crosses to Moonshot, or settles with the reason it
375
+ * never could. `file.create` is unpushable-BY-DESIGN (the real endpoint is multipart with the
376
+ * file body; this executor sends JSON) — it settles without crossing rather than throwing, so it
377
+ * cannot wedge every later batch action behind it. A locally-minted subject with no vendor alias
378
+ * THROWS (the head records the failure): addressing the real account by the twin's own mint would
379
+ * delete or cancel a resource the vendor never had.
380
+ */
381
+ export async function performMoonshotAction(execute: RemoteExecute, action: TwinAction, ctx: PerformContext): Promise<PushOutcome> {
382
+ const op = action.operation ?? `${action.subject.type}.update`;
383
+ const why = unpushableReason(op);
384
+ if (why) return { externalId: action.subject.id, data: { performed: false, reason: why } };
385
+ const vendorId = ctx.resolve(action.subject.type, action.subject.id);
386
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
387
+ if (verb !== 'create' && LOCAL_ID.test(vendorId)) {
388
+ throw new Error(`moonshot push: refusing to address the real account by the twin's own id '${vendorId}' — no vendor id was ever recorded for this subject`);
389
+ }
390
+ const { externalId } = await pushMoonshotAction(moonshotExecuteOver(execute), { operation: op, subject: action.subject, fields: action.fields ?? {} }, ...(verb !== 'create' ? [{ externalId: vendorId }] as const : [] as const));
391
+ return { externalId };
392
+ }
393
+
394
+ /**
395
+ * Push the twin's PENDING local actions to real Moonshot and CONFIRM each. Idempotency: a
396
+ * confirmed action is no longer deployable, so a re-push enacts NOTHING.
397
+ *
398
+ * Every action this pack records is SINGLE-RESOURCE (one file, one batch), so confirming with
399
+ * `fields` alone is correct — see ADDING_A_TWIN.md §5 on compound actions, which this pack has
400
+ * none of. If a compound write is ever added here, it must ride its extra resources in
401
+ * `additionalObservations` or the push will silently delete them.
402
+ */
403
+ export async function pushPendingMoonshotActions(
404
+ execute: MoonshotExecute,
405
+ opts: { root?: string; occurredAt: string },
406
+ ): Promise<{ pushed: number; confirmed: string[]; externalIds: Record<string, string>; refused: Array<{ actionId: string; operation: string; reason: string }> }> {
407
+ const confirmed: string[] = [];
408
+ const externalIds: Record<string, string> = {};
409
+ const refused: Array<{ actionId: string; operation: string; reason: string }> = [];
410
+ for (const action of deployableEntries(SERVICE, opts.root)) {
411
+ const op = action.operation ?? `${action.subject.type}.update`;
412
+ // An action this connector cannot faithfully push is SKIPPED AND REPORTED, never confirmed
413
+ // and never silently dropped: it stays deployable, and it is named in `refused` so a caller
414
+ // can see it. Throwing instead meant ONE unpushable action — e.g. the local file create,
415
+ // whose real endpoint is multipart — aborted the whole sweep and took every unrelated
416
+ // pending write down with it.
417
+ const why = unpushableReason(op);
418
+ if (why) { refused.push({ actionId: action.id, operation: op, reason: why }); continue; }
419
+ const verb = op.includes('.') ? op.slice(op.indexOf('.') + 1) : op;
420
+ // Anything but a create must address the vendor by the id the vendor issued. A locally-minted
421
+ // subject with none recorded is REFUSED, never guessed.
422
+ let external: string | undefined;
423
+ if (verb !== 'create') {
424
+ const resolved = externalIdFor(action.subject.type, action.subject.id, opts.root);
425
+ if (resolved === null) {
426
+ refused.push({ actionId: action.id, operation: op, reason: `no vendor id recorded for ${action.subject.type} '${action.subject.id}' — it was never pushed to the real account, so there is nothing there to ${verb}` });
427
+ continue;
428
+ }
429
+ external = resolved;
430
+ }
431
+ const { externalId } = await pushMoonshotAction(execute, action, ...(external !== undefined ? [{ externalId: external }] as const : [] as const));
432
+ // The kernel records the vendor's id as the subject ALIAS (the landed copy carries the
433
+ // vendor's id, aliasOf the local one), so a later delete/cancel resolves through
434
+ // `resolveSubjectId` — no `_external_id` field to read back.
435
+ confirmAction({
436
+ service: SERVICE, actionId: action.id, subject: action.subject,
437
+ fields: action.fields ?? {},
438
+ occurredAt: opts.occurredAt, vendorSubjectId: externalId, receipt: { status: 'deployed' },
439
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
440
+ });
441
+ confirmed.push(action.id);
442
+ externalIds[action.id] = externalId;
443
+ }
444
+ return { pushed: confirmed.length, confirmed, externalIds, refused };
445
+ }
446
+
447
+ // ── FULL bi-directional sync ─────────────────────────────────────────────────
448
+ /**
449
+ * FULL bi-directional sync over the injected client: (1) PUSH every deployable local entry to
450
+ * real Moonshot and confirm it, then (2) PULL all modeled collections back and fold them onto
451
+ * the root's log. Pushing first means the pull observes the twin's own writes as confirmed
452
+ * external state (no double-count). Re-running with nothing deployable and identical real state
453
+ * is a no-op.
454
+ */
455
+ export async function fullSyncMoonshot(
456
+ execute: MoonshotExecute,
457
+ opts: { root?: string; occurredAt: string },
458
+ ): Promise<{ pushed: number; observed: number; deltasAppended: number; collections: number; refused: Array<{ actionId: string; operation: string; reason: string }> }> {
459
+ const push = await pushPendingMoonshotActions(execute, { occurredAt: opts.occurredAt, ...(opts.root !== undefined ? { root: opts.root } : {}) });
460
+ const resources = await pullMoonshotState(execute);
461
+ const pull = observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), {
462
+ ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}`,
463
+ });
464
+ return { pushed: push.pushed, observed: pull.observed, deltasAppended: pull.appended, collections: COLLECTIONS.length + 1, refused: push.refused };
465
+ }
@@ -0,0 +1,89 @@
1
+ // Moonshot model catalog — the static `GET /v1/models` surface. The real platform serves a list
2
+ // of OpenAI-shaped model objects; the twin returns a faithful, deterministic slice so
3
+ // `client.models.list()` / `client.models.retrieve(id)` round-trip like the vendor.
4
+ //
5
+ // PROVENANCE (what is sourced, and what is not — §9 discipline):
6
+ // • the model ids are the exact strings Moonshot's own OpenAPI enumerates per request schema:
7
+ // KimiK3ChatRequest['model'] = 'kimi-k3'; KimiK27CodeChatRequest['model'] =
8
+ // 'kimi-k2.7-code' | 'kimi-k2.7-code-highspeed'; KimiK26ChatRequest['model'] = 'kimi-k2.6';
9
+ // EstimateTokenRequest['model'] repeats the same four. (platform.kimi.ai/docs/openapi.json,
10
+ // read 2026-09-16.)
11
+ // • `context_length` comes from Moonshot's published model table
12
+ // (platform.kimi.ai/docs/models, read 2026-09-16): kimi-k3 1M (1,048,576); kimi-k2.7-code
13
+ // and kimi-k2.7-code-highspeed 256k (262,144); kimi-k2.6 256k.
14
+ // • `created` is NOT sourced — Moonshot publishes no per-model creation timestamp. The values
15
+ // are deterministic placeholders and no capability asserts them beyond `typeof === number`.
16
+ // Calling that out is the point: the rest of this table is sourced, this column is not.
17
+ // • RETIRED ids are deliberately ABSENT: moonshot-v1-*, kimi-latest (2026-01-28) and the
18
+ // kimi-k2 series (2026-05-25) are shut down per the same models page. A twin that still
19
+ // served them would let a stale client "work" locally and then 404 against the vendor.
20
+ //
21
+ // Moonshot has NO embeddings, audio, or image-generation models — chat, Responses, Messages,
22
+ // files, batches, token counting, signatures and web-search tools are the whole surface.
23
+ export type MoonshotModel = {
24
+ id: string;
25
+ object: 'model';
26
+ created: number;
27
+ owned_by: string;
28
+ /** The OpenAPI request schema's enum for this id — which endpoints accept it. */
29
+ supports: {
30
+ /** POST /v1/chat/completions (all four). */
31
+ chat: boolean;
32
+ /** POST /v1/responses — kimi-k3 only ("This endpoint currently supports kimi-k3"). */
33
+ responses: boolean;
34
+ /** POST /anthropic/v1/messages — kimi-k3 only (MessagesRequest['model'] enum). */
35
+ messages: boolean;
36
+ /** POST /v1/batches — only kimi-k2.7-code(-highspeed) and kimi-k2.6, NOT kimi-k3. */
37
+ batch: boolean;
38
+ /** Vision (image_url parts in chat messages). kimi-k3 and kimi-k2.6 are vision models. */
39
+ vision: boolean;
40
+ };
41
+ context_length: number;
42
+ /** Max output tokens. kimi-k3: default 131072, up to 1048576 (ChatRequestCommon
43
+ * max_completion_tokens description). The k2.6/k2.7-code models share the 256k window. */
44
+ max_completion_tokens: number;
45
+ };
46
+
47
+ const K3_CTX = 1_048_576;
48
+ const K2_CTX = 262_144;
49
+
50
+ export const MOONSHOT_MODELS: MoonshotModel[] = [
51
+ {
52
+ id: 'kimi-k3', object: 'model', created: 1_760_000_000, owned_by: 'moonshot',
53
+ supports: { chat: true, responses: true, messages: true, batch: false, vision: true },
54
+ context_length: K3_CTX, max_completion_tokens: 1_048_576,
55
+ },
56
+ {
57
+ id: 'kimi-k2.7-code', object: 'model', created: 1_760_000_001, owned_by: 'moonshot',
58
+ supports: { chat: true, responses: false, messages: false, batch: true, vision: false },
59
+ context_length: K2_CTX, max_completion_tokens: K2_CTX,
60
+ },
61
+ {
62
+ id: 'kimi-k2.7-code-highspeed', object: 'model', created: 1_760_000_002, owned_by: 'moonshot',
63
+ supports: { chat: true, responses: false, messages: false, batch: true, vision: false },
64
+ context_length: K2_CTX, max_completion_tokens: K2_CTX,
65
+ },
66
+ {
67
+ id: 'kimi-k2.6', object: 'model', created: 1_760_000_003, owned_by: 'moonshot',
68
+ supports: { chat: true, responses: false, messages: false, batch: true, vision: true },
69
+ context_length: K2_CTX, max_completion_tokens: K2_CTX,
70
+ },
71
+ ];
72
+
73
+ /** Resolve a model by id, or undefined if the twin doesn't model it. */
74
+ export function findModel(id: string): MoonshotModel | undefined {
75
+ return MOONSHOT_MODELS.find((m) => m.id === id);
76
+ }
77
+
78
+ /** The ids that accept a batch job's chat/completions requests. */
79
+ export const BATCH_MODELS = new Set(MOONSHOT_MODELS.filter((m) => m.supports.batch).map((m) => m.id));
80
+
81
+ /** kimi-k3's `reasoning_effort` — a CLOSED documented set (KimiK3ChatRequest). */
82
+ export const REASONING_EFFORTS = ['low', 'high', 'max'] as const;
83
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
84
+
85
+ /** kimi-k2.6 `thinking.type` — enabled and disabled are BOTH supported. */
86
+ export const K26_THINKING_TYPES = ['enabled', 'disabled'] as const;
87
+ /** kimi-k2.7-code `thinking.type` — ONLY 'enabled'; 'disabled' returns an error (OpenAPI:
88
+ * "Unlike kimi-k2.6, `\"disabled\"` is NOT supported — passing it returns an error."). */
89
+ export const K27_THINKING_TYPES = ['enabled'] as const;