projectkeeper 0.0.0-stage → 1.0.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 (276) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +283 -2
  4. package/README.zh-CN.md +285 -0
  5. package/THIRD_PARTY_NOTICES.md +53 -0
  6. package/bin/pk.mjs +6 -0
  7. package/bin/postinstall.mjs +5 -0
  8. package/bin/strip-types.mjs +28 -0
  9. package/package.json +68 -4
  10. package/patches/README.md +111 -0
  11. package/patches/pi-ai-0.85.0.ts +122 -0
  12. package/patches/pi-ai-0.87.1.ts +122 -0
  13. package/scripts/apply-pi-streaming-patch.ts +86 -0
  14. package/skills/clerk/coverage/SKILL.md +35 -0
  15. package/skills/clerk/cross-check/SKILL.md +118 -0
  16. package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
  17. package/skills/clerk/lane-follow-up/SKILL.md +94 -0
  18. package/skills/clerk/lane-plan/SKILL.md +107 -0
  19. package/skills/clerk/lane-slot/SKILL.md +113 -0
  20. package/skills/clerk/lane-topic/SKILL.md +116 -0
  21. package/skills/clerk/orientation/SKILL.md +160 -0
  22. package/skills/clerk/reconcile/SKILL.md +77 -0
  23. package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
  24. package/skills/clerk/spot-check/SKILL.md +52 -0
  25. package/skills/clerk/synthesis/SKILL.md +65 -0
  26. package/skills/table-follow-up/SKILL.md +42 -0
  27. package/src/cli.ts +319 -0
  28. package/src/codemap/README.md +13 -0
  29. package/src/codemap/engines.ts +102 -0
  30. package/src/codemap/facts.ts +504 -0
  31. package/src/context/agent-entry.ts +232 -0
  32. package/src/context/ask.ts +97 -0
  33. package/src/context/assemble.ts +1058 -0
  34. package/src/context/code-source.ts +103 -0
  35. package/src/context/k-briefs.ts +114 -0
  36. package/src/context/lineage.ts +100 -0
  37. package/src/context/object-brief.ts +300 -0
  38. package/src/context/owner-words.ts +191 -0
  39. package/src/context/pack-fixture.ts +342 -0
  40. package/src/context/project-rules.ts +160 -0
  41. package/src/intake/intake.ts +392 -0
  42. package/src/intake/material-rules.ts +166 -0
  43. package/src/intake/removal.ts +316 -0
  44. package/src/intake/skipped.ts +72 -0
  45. package/src/keeper/adjustment.ts +565 -0
  46. package/src/keeper/bounds/boundary.ts +509 -0
  47. package/src/keeper/bounds/command.ts +1665 -0
  48. package/src/keeper/bounds/paths.ts +164 -0
  49. package/src/keeper/bounds/reads.ts +718 -0
  50. package/src/keeper/bounds/shell-write-guard.ts +521 -0
  51. package/src/keeper/capabilities.ts +136 -0
  52. package/src/keeper/clerk-steps.ts +151 -0
  53. package/src/keeper/clerk-tools.ts +1424 -0
  54. package/src/keeper/conversation.ts +253 -0
  55. package/src/keeper/evidence.ts +662 -0
  56. package/src/keeper/fake-provider.ts +132 -0
  57. package/src/keeper/held.ts +15 -0
  58. package/src/keeper/history-tools.ts +163 -0
  59. package/src/keeper/jobs/answering.ts +53 -0
  60. package/src/keeper/key-check.ts +105 -0
  61. package/src/keeper/ledger-tools.ts +295 -0
  62. package/src/keeper/link-check.ts +185 -0
  63. package/src/keeper/merge.ts +260 -0
  64. package/src/keeper/note-origin.ts +56 -0
  65. package/src/keeper/numbers-check.ts +102 -0
  66. package/src/keeper/open-in-pi.ts +57 -0
  67. package/src/keeper/organize/absence.ts +266 -0
  68. package/src/keeper/organize/carried-on.ts +214 -0
  69. package/src/keeper/organize/clerk-coverage.ts +293 -0
  70. package/src/keeper/organize/clerk-prompts.ts +132 -0
  71. package/src/keeper/organize/clerk.ts +1395 -0
  72. package/src/keeper/organize/coverage-gate.ts +19 -0
  73. package/src/keeper/organize/coverage-tools.ts +765 -0
  74. package/src/keeper/organize/entries.ts +153 -0
  75. package/src/keeper/organize/entry-gate.ts +289 -0
  76. package/src/keeper/organize/fill-tools.ts +718 -0
  77. package/src/keeper/organize/follow-up.ts +280 -0
  78. package/src/keeper/organize/generation-check.ts +504 -0
  79. package/src/keeper/organize/graph.ts +230 -0
  80. package/src/keeper/organize/lane-tools.ts +289 -0
  81. package/src/keeper/organize/looked-tools.ts +54 -0
  82. package/src/keeper/organize/materials.ts +243 -0
  83. package/src/keeper/organize/owner-lines.ts +493 -0
  84. package/src/keeper/organize/place-tools.ts +216 -0
  85. package/src/keeper/organize/placement-inference.ts +1045 -0
  86. package/src/keeper/organize/placement-time.ts +320 -0
  87. package/src/keeper/organize/placing.ts +821 -0
  88. package/src/keeper/organize/positioning.ts +33 -0
  89. package/src/keeper/organize/readiness.ts +47 -0
  90. package/src/keeper/organize/reading.ts +401 -0
  91. package/src/keeper/organize/round-blocks.ts +65 -0
  92. package/src/keeper/organize/round-news.ts +164 -0
  93. package/src/keeper/organize/round-open.ts +586 -0
  94. package/src/keeper/organize/schedule-state.ts +39 -0
  95. package/src/keeper/organize/schedule.ts +137 -0
  96. package/src/keeper/organize/service.ts +409 -0
  97. package/src/keeper/organize/skills.ts +116 -0
  98. package/src/keeper/organize/slots.ts +85 -0
  99. package/src/keeper/organize/stage-gate.ts +175 -0
  100. package/src/keeper/organize/stage-tools.ts +471 -0
  101. package/src/keeper/organize/standing-notes.ts +141 -0
  102. package/src/keeper/organize/table-follow-up.ts +330 -0
  103. package/src/keeper/organize/takeover-clerk.ts +832 -0
  104. package/src/keeper/organize/takeover-state.ts +103 -0
  105. package/src/keeper/organize/takeover.ts +216 -0
  106. package/src/keeper/organize/update-pending.ts +535 -0
  107. package/src/keeper/organize/workbench-placement.ts +92 -0
  108. package/src/keeper/own-keys.ts +114 -0
  109. package/src/keeper/owner-text.ts +165 -0
  110. package/src/keeper/project-folder.ts +334 -0
  111. package/src/keeper/prompts.ts +101 -0
  112. package/src/keeper/references.ts +177 -0
  113. package/src/keeper/relook/compose.ts +120 -0
  114. package/src/keeper/relook/prompts.ts +36 -0
  115. package/src/keeper/roles.ts +259 -0
  116. package/src/keeper/round-tools.ts +227 -0
  117. package/src/keeper/route.ts +148 -0
  118. package/src/keeper/rules.ts +97 -0
  119. package/src/keeper/runtime.ts +1858 -0
  120. package/src/keeper/scope-tools.ts +194 -0
  121. package/src/keeper/sendback-text.ts +83 -0
  122. package/src/keeper/step-timing.ts +207 -0
  123. package/src/keeper/testing/clerk-script.ts +212 -0
  124. package/src/keeper/testing/streaming-arguments.ts +57 -0
  125. package/src/keeper/tools.ts +2340 -0
  126. package/src/keeper/utterance-tools.ts +64 -0
  127. package/src/keeper/verbatim.ts +397 -0
  128. package/src/ledger/adapters.ts +186 -0
  129. package/src/ledger/arrangements.ts +257 -0
  130. package/src/ledger/blame.ts +58 -0
  131. package/src/ledger/code-engine-child.ts +69 -0
  132. package/src/ledger/code-engine.ts +548 -0
  133. package/src/ledger/code.ts +607 -0
  134. package/src/ledger/docs.ts +294 -0
  135. package/src/ledger/git-read.ts +321 -0
  136. package/src/ledger/index.ts +1850 -0
  137. package/src/ledger/item-range.ts +16 -0
  138. package/src/ledger/lines.ts +328 -0
  139. package/src/ledger/numbering.ts +269 -0
  140. package/src/ledger/ranges.ts +74 -0
  141. package/src/ledger/rebuild-worker.ts +15 -0
  142. package/src/ledger/rebuild.ts +387 -0
  143. package/src/ledger/repo-scan.ts +181 -0
  144. package/src/ledger/schema.ts +450 -0
  145. package/src/ledger/sessions.ts +134 -0
  146. package/src/ledger/text-scan.ts +466 -0
  147. package/src/ledger/time.ts +185 -0
  148. package/src/ledger/ts-symbols.ts +180 -0
  149. package/src/ledger/views.ts +377 -0
  150. package/src/mcp/server.ts +127 -0
  151. package/src/mcp/tools.ts +187 -0
  152. package/src/model/ids.ts +23 -0
  153. package/src/model/k-types.ts +912 -0
  154. package/src/model/time.ts +85 -0
  155. package/src/model/types.ts +1382 -0
  156. package/src/model/views-k.ts +707 -0
  157. package/src/model/vocab.ts +269 -0
  158. package/src/process/analysis.ts +79 -0
  159. package/src/process/breakpoint-candidates.ts +383 -0
  160. package/src/process/breakpoints.ts +746 -0
  161. package/src/process/claim-check.ts +295 -0
  162. package/src/process/delivery-links.ts +52 -0
  163. package/src/process/facts.ts +487 -0
  164. package/src/process/generations.ts +138 -0
  165. package/src/process/index.ts +243 -0
  166. package/src/process/integration.ts +128 -0
  167. package/src/process/placement.ts +107 -0
  168. package/src/process/plan.ts +276 -0
  169. package/src/process/sendbacks.ts +244 -0
  170. package/src/process/text.ts +185 -0
  171. package/src/process/units.ts +543 -0
  172. package/src/process/unnumbered.ts +431 -0
  173. package/src/process/work.ts +884 -0
  174. package/src/scope/candidates.ts +86 -0
  175. package/src/scope/decisions.ts +229 -0
  176. package/src/scope/discover.ts +741 -0
  177. package/src/scope/ignore.ts +126 -0
  178. package/src/scope/ignored-place.ts +93 -0
  179. package/src/scope/skip.ts +161 -0
  180. package/src/scope/toolchain.ts +180 -0
  181. package/src/scope/worktree.ts +187 -0
  182. package/src/server/api.ts +549 -0
  183. package/src/server/app.ts +595 -0
  184. package/src/server/drafts-api.ts +170 -0
  185. package/src/server/folders-api.ts +243 -0
  186. package/src/server/graph-view.ts +639 -0
  187. package/src/server/http.ts +224 -0
  188. package/src/server/k-agent-api.ts +56 -0
  189. package/src/server/k-rounds-d99.ts +91 -0
  190. package/src/server/k-views.ts +405 -0
  191. package/src/server/keeper-page.ts +446 -0
  192. package/src/server/keys-api.ts +239 -0
  193. package/src/server/project-folder-api.ts +104 -0
  194. package/src/server/vendor.ts +14 -0
  195. package/src/server/workbench-content.ts +561 -0
  196. package/src/sources/anchor.ts +129 -0
  197. package/src/sources/files.ts +267 -0
  198. package/src/sources/gitobs.ts +41 -0
  199. package/src/sources/history.ts +231 -0
  200. package/src/sources/scan-worker.ts +10 -0
  201. package/src/sources/sessions/locate.ts +292 -0
  202. package/src/sources/sessions/read.ts +346 -0
  203. package/src/sources/sessions/scope.ts +42 -0
  204. package/src/sources/sessions/utterances.ts +223 -0
  205. package/src/sources/watch.ts +215 -0
  206. package/src/store/json-file.ts +50 -0
  207. package/src/store/paths.ts +39 -0
  208. package/src/store/project-store.ts +295 -0
  209. package/src/store/versions.ts +172 -0
  210. package/src/store/workspace.ts +216 -0
  211. package/src/table/check.ts +333 -0
  212. package/src/table/follow-up-tools.ts +30 -0
  213. package/src/table/follow-up.ts +344 -0
  214. package/src/table/import.ts +610 -0
  215. package/src/table/project-home.ts +61 -0
  216. package/src/table/sessions.ts +168 -0
  217. package/src/table/takeover.ts +483 -0
  218. package/src/util/git-check.ts +29 -0
  219. package/src/util/git.ts +411 -0
  220. package/src/util/own-ts.ts +15 -0
  221. package/src/util/paths.ts +225 -0
  222. package/template/FILLING-IN.md +148 -0
  223. package/template/examples/papertrail.json +747 -0
  224. package/template/projectkeeper.schema.json +1001 -0
  225. package/ui/app.js +1135 -0
  226. package/ui/failures.js +82 -0
  227. package/ui/flyout.js +120 -0
  228. package/ui/folder-chooser.js +264 -0
  229. package/ui/glossary.js +156 -0
  230. package/ui/graph-fit.js +157 -0
  231. package/ui/graph-material.js +61 -0
  232. package/ui/graph-palette.js +109 -0
  233. package/ui/graph-tools.js +62 -0
  234. package/ui/graph.js +1712 -0
  235. package/ui/index.html +31 -0
  236. package/ui/k/code-view.js +230 -0
  237. package/ui/k/draft-view.js +93 -0
  238. package/ui/k/k-views.css +224 -0
  239. package/ui/k/round-tree.js +450 -0
  240. package/ui/k/scope-k.js +159 -0
  241. package/ui/k-data.js +118 -0
  242. package/ui/k-fold.js +106 -0
  243. package/ui/k-process.css +302 -0
  244. package/ui/k-process.js +934 -0
  245. package/ui/keeper-activity.js +175 -0
  246. package/ui/keeper-view.js +801 -0
  247. package/ui/keeper.css +101 -0
  248. package/ui/key-order.js +62 -0
  249. package/ui/list-fold.js +67 -0
  250. package/ui/markdown.d.ts +21 -0
  251. package/ui/markdown.js +472 -0
  252. package/ui/placement.d.ts +92 -0
  253. package/ui/placement.js +445 -0
  254. package/ui/popover-place.d.ts +16 -0
  255. package/ui/popover-place.js +89 -0
  256. package/ui/popover.js +198 -0
  257. package/ui/project-folder.js +37 -0
  258. package/ui/shell-state.js +40 -0
  259. package/ui/strip-rows.js +171 -0
  260. package/ui/styles.css +492 -0
  261. package/ui/theme-pick.js +36 -0
  262. package/ui/theme.js +74 -0
  263. package/ui/themes/LICENSE.md +13 -0
  264. package/ui/themes/README.md +44 -0
  265. package/ui/themes/TOKENS.md +291 -0
  266. package/ui/themes/a1/board.css +1557 -0
  267. package/ui/themes/a1/pk.css +392 -0
  268. package/ui/themes/b1/board.css +1163 -0
  269. package/ui/themes/b1/pk.css +218 -0
  270. package/ui/themes/d2/board.css +518 -0
  271. package/ui/themes/d2/pk.css +243 -0
  272. package/ui/themes/guard.css +73 -0
  273. package/ui/themes/i1/board.css +776 -0
  274. package/ui/themes/i1/pk.css +223 -0
  275. package/ui/themes/manifest.json +13 -0
  276. package/ui/views.js +2796 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A local OpenAI-compatible test double for the Keeper (E8). It plays each job kind with canned
3
+ * tool calls so the workbench and the tests can run without a real model provider. Never
4
+ * registered unless asked for explicitly (`pk serve --fake-provider` or a test).
5
+ */
6
+ import { createServer, type ServerResponse } from 'node:http';
7
+
8
+ type Msg = { role: string; content: unknown };
9
+ export interface ToolCall { name: string; args: Record<string, unknown>; /** Test malformed provider JSON without pre-normalizing it. */ rawArguments?: string }
10
+ /** `afterTool` is true on a turn that follows a tool result: a planner that wants a second turn answers it. */
11
+ export type Planner = (prompt: string, messages: Msg[], afterTool?: boolean) => ToolCall[] | string;
12
+
13
+ const contentOf = (m: Msg): string => (typeof m.content === 'string' ? m.content : ((m.content as { text?: string }[] | null) ?? []).map((p) => p.text ?? '').join('\n'));
14
+
15
+ export function userText(messages: Msg[]): string {
16
+ const users = messages.filter((m) => m.role === 'user');
17
+ const last = users[users.length - 1];
18
+ return last ? contentOf(last) : '';
19
+ }
20
+
21
+ /** The job's own task, which on a turn after a tool call is no longer the last message. */
22
+ export function taskText(messages: Msg[]): string {
23
+ const task = messages.filter((m) => m.role === 'user').map(contentOf).find((s) => /^Task: /m.test(s));
24
+ return task ?? userText(messages);
25
+ }
26
+
27
+ /**
28
+ * Every call the job made to one tool so far, with the text it got back: a job that works in several turns (a round's
29
+ * main job reads, then writes what the reading lets it write) finds the ids it needs here. A result belongs to the
30
+ * call with its id in the assistant turn just before it; the double numbers each turn's calls from call_0 again.
31
+ */
32
+ export function callResults(messages: Msg[], tool: string): { args: Record<string, unknown>; result: string }[] {
33
+ const out: { args: Record<string, unknown>; result: string }[] = [];
34
+ let calls = new Map<string, { name: string; args: Record<string, unknown> }>();
35
+ for (const m of messages as (Msg & { tool_calls?: { id: string; function: { name: string; arguments: string } }[]; tool_call_id?: string })[]) {
36
+ if (m.role === 'assistant') {
37
+ calls = new Map((m.tool_calls ?? []).map((c) => { let args: Record<string, unknown> = {}; try { args = JSON.parse(c.function.arguments) as Record<string, unknown>; } catch { /* keep empty */ } return [c.id, { name: c.function.name, args }]; }));
38
+ } else if (m.role === 'tool' && m.tool_call_id) {
39
+ const call = calls.get(m.tool_call_id);
40
+ if (call?.name === tool) out.push({ args: call.args, result: contentOf(m) });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** Default behaviour: a product re-look writes a note, an investigation answers plainly, a conversation turn answers with
47
+ * an echo, and any other job has nothing to do. */
48
+ export const defaultPlanner: Planner = (prompt, messages, afterTool = false) => {
49
+ // Every job the double plays is finished once its first batch of calls has run, and says so rather than planning
50
+ // them again.
51
+ if (afterTool) return '';
52
+ const task = /^Task: ([a-z ]+)/m.exec(prompt)?.[1] ?? '';
53
+ // The subagent's own session answers plainly, or the double would delegate for ever.
54
+ if (/^Investigate this question for the parent job/m.test(prompt)) return 'The fake provider reads nothing, so there is nothing to report. Sources: none';
55
+ if (task.startsWith('product re')) {
56
+ const jdg = /Judgement record: (jdg_[a-z0-9]+)/.exec(prompt)?.[1];
57
+ return [{ name: 'pk_write_note', args: { mountKind: 'project', mountIds: [], title: 'Fake provider note', preview: 'This note was written by the local test double; it carries no judgement.', ask: 'For information', currentView: 'No real model has looked at this project yet.', judgementRecordId: jdg, reason: 'fake re-look' } }];
58
+ }
59
+ // Owner turns say "Owner message (source src_…; cite …)", agent queries say "(… source src_…)".
60
+ const owner = /source (src_[0-9a-f]{16})[;)]/.exec(prompt)?.[1];
61
+ const text = /"""\n([\s\S]*?)\n"""/.exec(prompt)?.[1] ?? prompt;
62
+ if (owner && /^DELEGATE:/i.test(text)) return [{ name: 'pk_begin_request', args: { scope: text.replace(/^DELEGATE:\s*/i, '').slice(0, 80), quote: text } }];
63
+ if (owner) {
64
+ const turns = messages.filter((m) => m.role === 'user').length;
65
+ return `Answer #${turns} (fake provider): I read “${text.slice(0, 80)}”. No real model is connected, so this is an echo with the owner message cited [${owner}].`;
66
+ }
67
+ return 'Nothing to do (fake provider).';
68
+ };
69
+
70
+ export function startFakeProvider(planner: Planner = defaultPlanner, options: { toolArgumentChunkSize?: number } = {}): Promise<{ url: string; requests: { messages: Msg[]; tools: string[] }[]; close(): void; mode: { value: 'normal' | 'quota' | 'hang' } }> {
71
+ if (options.toolArgumentChunkSize !== undefined && (!Number.isSafeInteger(options.toolArgumentChunkSize) || options.toolArgumentChunkSize < 1)) throw new Error('toolArgumentChunkSize must be a positive integer');
72
+ const state = { url: '', requests: [] as { messages: Msg[]; tools: string[] }[], mode: { value: 'normal' as 'normal' | 'quota' | 'hang' }, close: () => undefined as void };
73
+ const sse = (res: ServerResponse, chunks: unknown[]) => {
74
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
75
+ for (const c of chunks) res.write(`data: ${JSON.stringify(c)}\n\n`);
76
+ res.write('data: [DONE]\n\n');
77
+ res.end();
78
+ };
79
+ const chunk = (delta: Record<string, unknown>, finish: string | null) => ({ id: 'fake', object: 'chat.completion.chunk', created: 1, model: 'fake-1', choices: [{ index: 0, delta, finish_reason: finish }], ...(finish ? { usage: { prompt_tokens: 80, completion_tokens: 8, total_tokens: 88 } } : {}) });
80
+ const server = createServer((req, res) => {
81
+ let body = '';
82
+ req.on('data', (c) => { body += c; });
83
+ req.on('end', () => {
84
+ const json = JSON.parse(body) as { messages: Msg[]; tools?: { function: { name: string } }[] };
85
+ state.requests.push({ messages: json.messages, tools: (json.tools ?? []).map((t) => t.function.name) });
86
+ if (state.mode.value === 'quota') { res.writeHead(429, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'insufficient_quota: You exceeded your current quota', type: 'insufficient_quota' } })); return; }
87
+ if (state.mode.value === 'hang') { req.on('close', () => res.destroy()); return; }
88
+ // A turn is "after a tool call" only when the latest message is a tool result; earlier turns' results are history.
89
+ const last = json.messages[json.messages.length - 1];
90
+ const hasToolResult = last?.role === 'tool';
91
+ // A planner may answer a turn that follows a tool result — a job that delegates has a second turn, in which it
92
+ // writes what the subagent's answer lets it write. One that has nothing more to say returns nothing and the
93
+ // double closes the turn with text, as it always did.
94
+ const planned = planner(userText(json.messages), json.messages, hasToolResult);
95
+ if (hasToolResult && (typeof planned === 'string' || planned.length === 0)) {
96
+ const errors = json.messages.filter((m) => m.role === 'tool' && String(m.content).startsWith('ERROR')).map((m) => String(m.content));
97
+ const last = userText(json.messages);
98
+ const owner = /Owner message \(source (src_[0-9a-f]{16})/.exec(last)?.[1];
99
+ const text = errors.length ? `Tool errors: ${errors.join(' | ')}` : typeof planned === 'string' && planned.length ? planned : owner && /DELEGATE:/i.test(last) ? `Done as you asked (fake provider): nothing was actually changed. Basis [${owner}].` : 'Done.';
100
+ sse(res, [chunk({ role: 'assistant', content: text }, null), chunk({}, 'stop')]);
101
+ return;
102
+ }
103
+ if (typeof planned === 'string' || planned.length === 0) { const text = typeof planned === 'string' ? planned : 'Nothing to do.'; sse(res, [chunk({ role: 'assistant', content: text }, null), chunk({}, 'stop')]); return; }
104
+ // A safety valve for the double itself: a planner that ignores `afterTool` would keep asking for the same
105
+ // calls for ever (seen 2026-09-20: an area job reached 1,360 steps). Twelve turns of tool calls is more than any
106
+ // canned job needs — D99's main agent moves through its stages, sends its lanes and accounts for its coverage in
107
+ // about eight — so the turn is closed with text instead. Turns are counted, not results: one turn of parallel
108
+ // calls has several.
109
+ const lastUser = json.messages.map((m) => m.role).lastIndexOf('user');
110
+ if (json.messages.slice(lastUser + 1).filter((m) => m.role === 'assistant').length >= 12) { sse(res, [chunk({ role: 'assistant', content: 'Done.' }, null), chunk({}, 'stop')]); return; }
111
+ const chunkSize = options.toolArgumentChunkSize;
112
+ if (chunkSize) {
113
+ const parts = planned.map((c) => c.rawArguments ?? JSON.stringify(c.args));
114
+ const chunks = [chunk({ role: 'assistant', tool_calls: planned.map((c, i) => ({ index: i, id: `call_${i}`, type: 'function', function: { name: c.name, arguments: '' } })) }, null)];
115
+ // Interleave independent calls as real providers do; SSE boundaries need not be TCP boundaries.
116
+ for (let offset = 0; parts.some((p) => offset < p.length); offset += chunkSize) {
117
+ chunks.push(chunk({ tool_calls: parts.flatMap((p, i) => offset < p.length ? [{ index: i, function: { arguments: p.slice(offset, offset + chunkSize) } }] : []) }, null));
118
+ }
119
+ sse(res, [...chunks, chunk({}, 'tool_calls')]);
120
+ } else {
121
+ sse(res, [chunk({ role: 'assistant', tool_calls: planned.map((c, i) => ({ index: i, id: `call_${i}`, type: 'function', function: { name: c.name, arguments: c.rawArguments ?? JSON.stringify(c.args) } })) }, null), chunk({}, 'tool_calls')]);
122
+ }
123
+ });
124
+ });
125
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
126
+ state.url = `http://127.0.0.1:${(server.address() as { port: number }).port}/v1`;
127
+ state.close = () => { server.close(); };
128
+ resolve(state);
129
+ }));
130
+ }
131
+
132
+ export const FAKE_MODEL = { id: 'fake-1', name: 'Fake provider (test double)', reasoning: false, input: ['text'] as ('text' | 'image')[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 8000 };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Whether automatic organizing is held right now (Spec §3.8, §3.10). Pausing holds the rounds that open by themselves;
3
+ * a round the owner starts with Follow up runs anyway, and when it has run out of work the pause holds again. The
4
+ * owner's own questions, corrections and requests, and execution agents' queries, have their own lane and are never
5
+ * held by the pause or the rhythm.
6
+ */
7
+ import type { Project } from '../model/types.ts';
8
+
9
+ export function ownerRoundOpen(p: Pick<Project, 'followUpAt' | 'lastRoundAt'>): boolean {
10
+ return Boolean(p.followUpAt && (!p.lastRoundAt || p.lastRoundAt < p.followUpAt));
11
+ }
12
+
13
+ export function organizingHeld(p: Pick<Project, 'organizingPaused' | 'followUpAt' | 'lastRoundAt'>): boolean {
14
+ return Boolean(p.organizingPaused) && !ownerRoundOpen(p);
15
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The Keeper's tools for version history (Spec §1.2 `History only`, §3.1; CKC-03 AC-24), and the checks that keep what
3
+ * it reads there out of the current picture (Spec §2.6; CKC-02 AC-23; D61). The reading itself is in
4
+ * sources/history.ts; this module words it for the model and records what was read.
5
+ *
6
+ * Why the checks sit with the writes: in the takeover whose review led to D61, specifications deleted from the
7
+ * project and read back out of git history were built into current nodes. A rule the model is asked to remember is
8
+ * forgotten again; a write that refuses is not.
9
+ */
10
+ import { Type } from 'typebox';
11
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
12
+ import type { ProjectStore } from '../store/project-store.ts';
13
+ import type { Source } from '../model/types.ts';
14
+ import { anchorLabel } from '../sources/anchor.ts';
15
+ import { deletedDocuments, isHistoryOnly, pathHistory, readRevision } from '../sources/history.ts';
16
+ import type { ToolContext } from './tools.ts';
17
+
18
+ const USES = 'History only: use it to mark what current material has outdated, to cross-check current documents, or to produce the history when it is traced. It forms no current node, is never material to organize and enters no context; if you find something that was dropped but should not have been, or an intent that got buried, write a note for the owner — never revive it as current.';
19
+ const PAGE_CHARS = 60_000;
20
+ const LIST_CHARS = 90_000;
21
+
22
+ function ok(value: unknown) {
23
+ const body = typeof value === 'string' ? value : JSON.stringify(value, null, 1);
24
+ return { content: [{ type: 'text' as const, text: body.length > 120_000 ? `${body.slice(0, 120_000)}\n…(truncated)` : body }], details: {} };
25
+ }
26
+ function fail(message: string) {
27
+ return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
28
+ }
29
+ const str = (v: unknown): string => (typeof v === 'string' ? v : '');
30
+
31
+ /** Lines `from`..`to` (1-based, inclusive) of a text, cut to the page size; says where the next page starts. */
32
+ function page(text: string, fromLine: number, toLine: number | null): { text: string; fromLine: number; toLine: number; lines: number; nextFromLine: number | null } {
33
+ const lines = text.split(/\r?\n/);
34
+ const from = Math.min(Math.max(1, Math.floor(fromLine) || 1), Math.max(1, lines.length));
35
+ const last = Math.min(lines.length, toLine && toLine >= from ? Math.floor(toLine) : lines.length);
36
+ const out: string[] = [];
37
+ let size = 0;
38
+ let end = from - 1;
39
+ for (let i = from - 1; i < last; i++) {
40
+ const line = lines[i]!;
41
+ if (out.length > 0 && size + line.length + 1 > PAGE_CHARS) break;
42
+ out.push(line);
43
+ size += line.length + 1;
44
+ end = i + 1;
45
+ }
46
+ return { text: out.join('\n'), fromLine: from, toLine: end, lines: lines.length, nextFromLine: end < last ? end + 1 : null };
47
+ }
48
+
49
+ export function historyTools(ctx: ToolContext): ToolDefinition[] {
50
+ const { store, project } = ctx;
51
+ return [
52
+ defineTool({
53
+ name: 'pk_history_log', label: 'History of a path',
54
+ description: 'Every commit in the repository’s history that touched a path — a file, or a directory (then everything under it) — newest first, including the commit that deleted it and a move away from it with where it went. Each row gives the commit, time, author, subject, what happened (Added, Modified, Deleted, Renamed …) and readAt: the commit to read that version at with pk_history_read (for a deletion, the last commit that still had it). The whole history, a page at a time: limit commits (default 100) after offset; next says where the next page starts. repo: a repository or worktree of the scope, by id or path; default the main repository (an absolute path picks the repository that holds it). Read-only.',
55
+ parameters: Type.Object({
56
+ path: Type.String({ description: 'relative to the repository root (or absolute inside it)' }),
57
+ repo: Type.Optional(Type.String()), limit: Type.Optional(Type.Number({ description: 'commits per page (default 100)' })),
58
+ offset: Type.Optional(Type.Number({ description: 'commits to skip: the next of the previous page' })),
59
+ }),
60
+ execute: async (_id, p) => {
61
+ const r = pathHistory(project, { path: str(p.path), repo: str(p.repo) || undefined, limit: Number(p.limit) || undefined, offset: Number(p.offset) || undefined });
62
+ if (typeof r === 'string') return fail(r);
63
+ return ok({
64
+ repo: r.target.root, path: r.target.rel, commits: r.rows, ...(r.more ? { more: true, next: r.next } : {}), ...(r.offset ? { offset: r.offset } : {}),
65
+ ...(r.rows.length === 0 ? { note: `No commit in the history of ${r.target.root} touched ${r.target.rel}${r.offset ? ` after the first ${r.offset}` : ''}.` } : {}),
66
+ });
67
+ },
68
+ }),
69
+ defineTool({
70
+ name: 'pk_history_read', label: 'Read an old version',
71
+ description: `Read a file as it was at a commit — a deleted design, an earlier version of a plan, an old file of a worktree branch already merged. commit: a hash (abbreviated is fine), a branch or tag, or HEAD~n; a file deleted in some commit is read at the commit before it (pk_history_log gives it as readAt, pk_history_deleted as lastPresentIn). The version becomes a source anchored at repository, commit and path, Used as History only, which you can cite by its id. ${USES} When the version is exactly what the project has at that path now, it is current material: nothing is recorded as history and the current file’s sources are named. Long files come in pages: fromLine / toLine. Read-only.`,
72
+ parameters: Type.Object({
73
+ path: Type.String({ description: 'relative to the repository root (or absolute inside it)' }), commit: Type.String(),
74
+ repo: Type.Optional(Type.String()), fromLine: Type.Optional(Type.Number()), toLine: Type.Optional(Type.Number()),
75
+ }),
76
+ execute: async (_id, p) => {
77
+ const r = readRevision(store, project, { path: str(p.path), commit: str(p.commit), repo: str(p.repo) || undefined });
78
+ if (typeof r === 'string') return fail(r);
79
+ if (r.kind === 'current') {
80
+ return ok({ current: true, path: r.target.rel, file: r.file, commit: r.commit, sourceIds: r.sourceIds, note: 'This is exactly what the project has at this path now: current material, not history. Read or cite the current file (its sources are listed); nothing was recorded as History only.' });
81
+ }
82
+ const previous = store.sources.get(r.source.id);
83
+ if (!previous || previous.version.fingerprint !== r.source.version.fingerprint || previous.usedAs !== 'History only') {
84
+ store.sources.put(r.source, { jobId: ctx.jobId, basisSourceIds: [r.source.id], summary: `History only: ${r.target.rel} as it was at ${r.source.version.commit!.slice(0, 10)}, read from version history` });
85
+ }
86
+ ctx.onSaved?.('sources', r.source.id, `History only: ${r.source.title}`);
87
+ const pg = page(r.text, Number(p.fromLine) || 1, Number(p.toLine) || null);
88
+ return ok({
89
+ id: r.source.id, usedAs: 'History only', at: anchorLabel(r.source.anchor), repo: r.target.repo, commit: r.source.version.commit, path: r.target.rel,
90
+ ...(r.meta ? { committed: { at: r.meta.at, author: r.meta.author, subject: r.meta.subject } } : {}),
91
+ now: r.nowAtPath, chars: r.text.length, lines: pg.lines, fromLine: pg.fromLine, toLine: pg.toLine,
92
+ ...(pg.nextFromLine ? { nextFromLine: pg.nextFromLine } : {}), text: pg.text, uses: USES,
93
+ });
94
+ },
95
+ }),
96
+ defineTool({
97
+ name: 'pk_history_deleted', label: 'Deleted documents',
98
+ description: 'The documents the repository’s main history deleted and the current version does not have again, grouped by directory: each with the commit that deleted it (time, author, subject) and lastPresentIn, the last commit it appears in — read it there with pk_history_read. A move is not a deletion; a deletion in the working tree not committed yet is listed as uncommitted. Documents are text documents (Markdown, text, reStructuredText …); all: true lists every kind of file. dir narrows to one directory. Deletion means the project no longer needs it: what you read stays History only. Read-only.',
99
+ parameters: Type.Object({ repo: Type.Optional(Type.String()), dir: Type.Optional(Type.String()), all: Type.Optional(Type.Boolean()) }),
100
+ execute: async (_id, p) => {
101
+ const r = deletedDocuments(project, { repo: str(p.repo) || undefined, dir: str(p.dir) || undefined, all: p.all === true });
102
+ if (typeof r === 'string') return fail(r);
103
+ const full = { repo: r.target.root, head: r.head, documentsOnly: p.all !== true, total: r.total, directories: r.directories };
104
+ if (JSON.stringify(full).length <= LIST_CHARS) return ok(full);
105
+ // Too long for one answer: every directory keeps its count; the files are listed while there is room.
106
+ let room = LIST_CHARS - JSON.stringify({ ...full, directories: [] }).length - 400;
107
+ const directories = r.directories.map((d) => {
108
+ const files = [];
109
+ for (const f of d.files) { const size = JSON.stringify(f).length + 4; if (room - size < 0) break; room -= size; files.push(f); }
110
+ return { ...d, files, ...(files.length < d.files.length ? { more: d.files.length - files.length } : {}) };
111
+ });
112
+ return ok({ ...full, directories, truncated: true, note: 'Not every file fits in one answer: each directory keeps its count, and dir lists one directory in full.' });
113
+ },
114
+ }),
115
+ ];
116
+ }
117
+
118
+ // ───────────────────────── history stays history: the checks the writes run (CKC-02 AC-23; D61) ─────────────────────────
119
+
120
+ const known = (store: ProjectStore, ids: readonly string[]): Source[] => [...new Set(ids)].map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined);
121
+
122
+ /**
123
+ * Why a work item may not rest on these sources, or null. A work item is a current node, and what exists only in
124
+ * history forms none (Spec §2.6); reference-only material never becomes the project's plan or work (§1.1, §1.2). Either
125
+ * may be cited next to the project's own current material. A `Removed` work item keeps what it rested on.
126
+ */
127
+ export function workItemHistoryRefusal(store: ProjectStore, sourceIds: readonly string[], validity: string): string | null {
128
+ if (validity === 'Removed') return null;
129
+ const cited = known(store, sourceIds);
130
+ if (cited.length === 0 || !cited.every((s) => isHistoryOnly(s) || s.usedAs === 'Reference only')) return null;
131
+ if (cited.every(isHistoryOnly)) {
132
+ return 'Every source this work item rests on is History only — an old version or deleted file from version history, or material the project keeps for recovery only. What exists only in history forms no current node: no work item is built from it. Use it to mark what current material has outdated, to cross-check the current plan, or to trace history; if work was dropped that should not have been, write a note for the owner.';
133
+ }
134
+ return 'Every source this work item rests on is History only or Reference only: neither makes a work item of the project. A work item comes from the project’s own current plan, tasks or issues; cite those.';
135
+ }
136
+
137
+ /**
138
+ * Why a relation may not have this endpoint, or null. A source at either end of a relation is drawn as a node of its own
139
+ * (Spec §1.5), and a History only source forms no node; it can still be the relation's evidence.
140
+ */
141
+ export function relationEndpointHistoryRefusal(store: ProjectStore, fromId: string, toId: string): string | null {
142
+ const end = [fromId, toId].map((id) => store.sources.get(id)).find((s): s is Source => s !== undefined && isHistoryOnly(s));
143
+ if (!end) return null;
144
+ return `${end.id} is History only (${anchorLabel(end.anchor)}): a source at the end of a relation becomes a node on the graph, and what exists only in history forms no node. Relate the current objects instead and cite the old version as evidence (evidenceSourceIds), or mark the current object it shows as outdated (pk_write_mark with the old version in clueSourceIds).`;
145
+ }
146
+
147
+ /**
148
+ * Why a rule in force may not rest on these sources, or null. A rule found only in history is no longer the project's
149
+ * rule — deletion says it is not needed — and rules in force enter the start context (Spec §1.15, §7.3), which history
150
+ * never does (§7.1). A rule written as replaced, to record that the project changed it, may cite history.
151
+ */
152
+ export function ruleHistoryRefusal(store: ProjectStore, sourceIds: readonly string[], validity: string): string | null {
153
+ if (!['Current', 'Proposed', 'Deferred'].includes(validity)) return null;
154
+ const cited = known(store, sourceIds);
155
+ if (cited.length === 0 || !cited.every(isHistoryOnly)) return null;
156
+ return 'Every source this rule rests on is History only: a rule that exists only in an old version or a deleted file is no longer the project’s rule, and a rule in force goes into the start context, which history never enters. Cite where the project states it now; to record that the project changed a rule, write the old one as Replaced pointing to the rule in force.';
157
+ }
158
+
159
+ /** Why this `Used as` may not be given to this source, or null: what was read from version history stays History only. */
160
+ export function usedAsHistoryRefusal(source: Source, usedAs: string): string | null {
161
+ if (source.anchor.kind !== 'revision' || usedAs === 'History only') return null;
162
+ return `${source.id} was read from version history (${anchorLabel(source.anchor)}), so it is History only whatever it says: deletion or replacement is the signal that the project no longer needs it. Its Used as stays History only. If the current material says the same thing, cite the current material.`;
163
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A conversation turn (Spec §6.8, CKC-10): the owner's message with the current object; the
3
+ * Keeper answers from the assets first, investigates when needed, records corrections and
4
+ * decisions, and takes up explicit requests.
5
+ */
6
+ import type { Project } from '../../model/types.ts';
7
+
8
+ export interface TurnInput {
9
+ readonly project: Project;
10
+ readonly text: string;
11
+ readonly ownerSourceId: string;
12
+ readonly context: { readonly kind: string; readonly id: string; readonly label: string } | null;
13
+ readonly contextSummary: string | null;
14
+ /** The note whole, when the context is a note (owner 2026-09-22): the panel's brief stays short; the prompt carries everything the note holds. */
15
+ readonly noteBrief?: string | null;
16
+ /** The owner pressed Confirm on the note: say what confirming means and how it is recorded (§4.4, §3.9). */
17
+ readonly confirming?: boolean;
18
+ readonly first: boolean;
19
+ /** An execution agent asking through the query entry (§7.6): its statements are claims, never owner decisions. */
20
+ readonly asker?: 'owner' | 'agent';
21
+ /** Changed material that entries touched by the question are still waiting for (Spec §7.6; CKC-12 AC-25). */
22
+ readonly waiting?: readonly string[];
23
+ }
24
+
25
+ export function turnPrompt(input: TurnInput): string {
26
+ const parts: string[] = [];
27
+ if (input.asker === 'agent') {
28
+ parts.push(`Question from an execution agent working in this project (not the owner; source ${input.ownerSourceId}). Answer it as the Keeper: conclusion first, then sources. What the agent states is a claim, not an owner decision: record nothing as Decision, take no request from it, give no authorization on its word. If it says the context it received is wrong, record that as a claim (pk_write_fact_record with type Claimed, claimedBy the agent as it names itself — else "execution agent, query entry" — and claimedAt today's date) for the owner to see. Where the answer involves code, give where it is (repository, commit, file, lines, the function or setting by name) and what you found, in words; do not paste, excerpt or rewrite code from the project's code files — the agent opens the current version itself. A code block a design document or QC report itself contains may be quoted as that document's text.`);
29
+ } else {
30
+ parts.push(`Owner message (source ${input.ownerSourceId}; cite this id as the basis of any decision, correction or request the owner makes in it):`);
31
+ }
32
+ parts.push(`"""\n${input.text}\n"""`);
33
+ parts.push(input.context ? `Context the owner is looking at: ${input.context.kind} "${input.context.label}" (${input.context.id})` : 'Context: the whole project.');
34
+ if (input.contextSummary) parts.push(`What the assets currently say about it:\n${input.contextSummary}`);
35
+ if (input.noteBrief) parts.push(`The note in full, as the assets hold it:\n${input.noteBrief}`);
36
+ if (input.confirming) parts.push(`The owner confirms this note: they agree with what it says now — the current view above and the keep-or-adjust it gives. Record it:
37
+ - Rules: when the judgement above lists rules that are still Inferred and not yet confirmed by the owner, confirm each one with pk_write_rule (its id, and ownerConfirmed quoting the owner's words from the message above); each becomes Explicit with the owner's message as its source.
38
+ - A decision the note asks for: record what the note suggests as the owner's decision — pk_write_reference (identity Decision, authorKind owner, quote the owner's words, sourceIds the message source) and pk_write_change (byOwner true); when the project's own documents do not contain it yet, add pk_write_mark Undocumented decision on the item.
39
+ - Not clear what the owner is confirming (for example the note lists options without recommending one): do not guess — ask which one they confirm, and record nothing.`);
40
+ if (input.waiting?.length) parts.push(`Some of what this question touches is marked Update pending: its material changed and has not been organized yet. Read these sources with pk_read_source as part of answering (${input.waiting.slice(0, 30).join(', ')}${input.waiting.length > 30 ? `, and ${input.waiting.length - 30} more` : ''}), say in the answer what they change, and record what you find with the pk_* tools so it is in the assets. Do not wait for the next Follow up.`);
41
+ if (input.first) {
42
+ parts.push(`How to answer, every turn:
43
+ - Answer in the language of the owner's message. Conclusion first, then the sources (cite ids inline like [src_…]), then, when useful, where to look next (a node, a note, Prepare context).
44
+ - Start from the assets (pk_project_overview, pk_read_assets, pk_list_sources, pk_read_source). When the answer is already there, answer at once. When it needs detail, investigate yourself with your tools (files, code, git history, commands that change nothing) and record the facts you establish with pk_write_fact_record so later answers can reuse them; or delegate a deep dive with pk_investigate. Do not read whole session histories. When you do not know, say what would have to be checked to know; never answer "out of scope".
45
+ - Corrections: when the owner says an attribution, classification, relation or judgement is wrong, check the material, then update the asset with the pk_* tools (reference, thread, relation, note, mark) citing the owner's message as an owner statement, and say exactly what you changed. If a project material contradicts the owner's correction, say which material, and put pk_write_mark Suspected stale on that source or entry. Whether an object has followed a change is judged by the next Follow up round's main job, not here: record what the owner said on the object itself, citing the message, and tell the owner the next round judges it from that — or now, when they press Follow up.
46
+ - Decisions: when the owner decides something about the product or answers an open question, record it as a product reference item (pk_write_reference: identity Decision, authorKind owner, quote = the owner's words, sourceIds = the message source) and a change record with pk_write_change (effect Approved, Replaced, Deferred, Abandoned or Added as fits, byOwner true); if the project's own documents do not contain it yet, add pk_write_mark Undocumented decision on the item.
47
+ - Requests: when the owner explicitly asks you to change project content or to do a specific piece of work, call pk_begin_request, do it within that scope, and report what you changed and what remains. Otherwise never change project content; give advice or options instead.
48
+ - Keep your reply to conclusions, sources and entry points; the tool steps you took are shown separately as "How the Keeper investigated".`);
49
+ } else {
50
+ parts.push('Same rules as before: assets first, sources cited, corrections and decisions recorded with the pk_* tools, requests only after pk_begin_request; answer in the language of the message.');
51
+ }
52
+ return parts.join('\n\n');
53
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Whether a key can be used (Spec §6.10 "这把 key 能不能用"; D105; CKC-03 AC-34): checked when it is saved and whenever the
3
+ * owner presses `Check`.
4
+ *
5
+ * The check is ONE request of ONE output token (`maxTokens: 1`, no thinking asked for) to the cheapest priced model the
6
+ * key carries — the request every provider answers the same way a job's request is answered, so a key that passes here
7
+ * passes for the work, and a key that is refused, out of quota or rate-limited says so in the provider's own words. A
8
+ * model list was the other cheap request; the Coding Plan endpoints (Zhipu, Z.ai) do not serve one, so it would not tell
9
+ * a usable key from a refused one there.
10
+ *
11
+ * Quota: where the provider reports what is left, it is read (`QUOTA_REPORTERS`: DeepSeek's balance); a provider that does
12
+ * not report it is said not to, never shown as unlimited.
13
+ */
14
+ import type { ModelRuntime } from '@earendil-works/pi-coding-agent';
15
+
16
+ export type KeyStatus = 'Usable' | 'Refused' | 'Out of quota' | 'Rate-limited' | 'Could not check' | 'Not checked';
17
+
18
+ export interface KeyCheck {
19
+ readonly status: KeyStatus;
20
+ /** The provider's own words when it refused, or why the check could not be made. */
21
+ readonly reason: string | null;
22
+ /** When an exhausted quota comes back, when the provider says. */
23
+ readonly until: string | null;
24
+ readonly at: string;
25
+ /** Which request the check made. */
26
+ readonly how: string;
27
+ /** What is left, in the provider's terms, when it reports it; otherwise null and `quotaReported` false. */
28
+ readonly quota: string | null;
29
+ readonly quotaReported: boolean;
30
+ }
31
+
32
+ export type ErrorClass = 'quota' | 'ratelimit' | 'unavailable' | 'error';
33
+
34
+ /** A provider that reports what is left on a key: a line to show, and whether anything is left. */
35
+ export type QuotaReporter = (baseUrl: string, apiKey: string, signal: AbortSignal) => Promise<{ readonly text: string; readonly available: boolean } | null>;
36
+
37
+ /** DeepSeek: `GET /user/balance` (https://api-docs.deepseek.com/api/get-user-balance). */
38
+ const deepseekBalance: QuotaReporter = async (baseUrl, apiKey, signal) => {
39
+ const res = await fetch(`${new URL(baseUrl).origin}/user/balance`, { headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' }, signal });
40
+ if (!res.ok) return null;
41
+ const body = await res.json() as { is_available?: boolean; balance_infos?: { currency?: string; total_balance?: string }[] };
42
+ const infos = body.balance_infos ?? [];
43
+ const text = infos.length ? `Balance ${infos.map((b) => `${b.total_balance ?? '?'} ${b.currency ?? ''}`.trim()).join(' · ')}` : 'Balance reported, no amount given';
44
+ return { text, available: body.is_available !== false };
45
+ };
46
+
47
+ /** Providers that report what is left on a key, by the provider a key's endpoint belongs to. */
48
+ export const QUOTA_REPORTERS: Partial<Record<string, QuotaReporter>> = { deepseek: deepseekBalance };
49
+
50
+ /** A refusal of the key itself (wrong, revoked, not allowed), as against a limit. */
51
+ export function isRefusal(message: string): boolean {
52
+ if (/\b(401|403)\b/.test(message)) return true;
53
+ if (/"code"\s*:\s*"?(1000|1001|1002|1003|1004)"?/.test(message)) return true; // Zhipu / Z.ai authentication codes
54
+ return /invalid[_ ]?api[_ ]?key|incorrect api key|authentication|unauthori[sz]ed|forbidden|permission denied|api key (is )?(not valid|invalid|expired)|身份验证|令牌已过期|无效的?\s*(api ?key|令牌)/i.test(message);
55
+ }
56
+
57
+ /** The cheapest model of a key that has a price, else its first model: what the one-token check asks. */
58
+ export function checkModelOf(models: readonly { readonly id: string; readonly cost: { readonly input: number; readonly output: number } }[]): string | null {
59
+ const priced = models.filter((m) => m.cost.input > 0 || m.cost.output > 0).sort((a, b) => (a.cost.input + a.cost.output) - (b.cost.input + b.cost.output));
60
+ return (priced[0] ?? models[0])?.id ?? null;
61
+ }
62
+
63
+ export async function checkKey(
64
+ models: ModelRuntime,
65
+ keyId: string,
66
+ options: {
67
+ readonly family: string;
68
+ readonly classify: (message: string) => ErrorClass;
69
+ readonly resetFrom: (message: string, provider: string) => string | null;
70
+ readonly redact: (text: string) => string;
71
+ readonly timeoutMs?: number;
72
+ },
73
+ ): Promise<KeyCheck> {
74
+ const at = new Date().toISOString();
75
+ const list = models.getModels(keyId);
76
+ const modelId = checkModelOf(list);
77
+ const how = modelId ? `one request of one output token to ${modelId}` : 'no request: the key carries no model';
78
+ const base = { at, how, quota: null, quotaReported: false, until: null };
79
+ if (!modelId) return { ...base, status: 'Could not check', reason: 'This key carries no model to ask.' };
80
+ const model = models.getModel(keyId, modelId)!;
81
+ const auth = await models.getAuth(keyId).catch(() => undefined);
82
+ if (!auth) return { ...base, status: 'Refused', reason: 'No key value is configured for this provider.' };
83
+ const signal = AbortSignal.timeout(options.timeoutMs ?? 30_000);
84
+ // What is left, where the provider reports it; asked beside the request, never instead of it.
85
+ const reporter = QUOTA_REPORTERS[options.family];
86
+ const quotaP = reporter && auth.auth.apiKey ? reporter(auth.auth.baseUrl ?? model.baseUrl, auth.auth.apiKey, signal).catch(() => null) : Promise.resolve(null);
87
+ let message: string | null = null;
88
+ try {
89
+ const reply = await models.completeSimple(model, { messages: [{ role: 'user', content: 'Reply with OK.', timestamp: Date.now() }] }, { maxTokens: 1, signal });
90
+ if (reply.stopReason === 'error' || reply.stopReason === 'aborted') message = reply.errorMessage ?? 'The provider returned an error';
91
+ } catch (e) { message = e instanceof Error ? e.message : String(e); }
92
+ const quota = await quotaP;
93
+ const q = { quota: quota?.text ?? null, quotaReported: Boolean(reporter) };
94
+ if (!message) {
95
+ if (quota && !quota.available) return { ...base, ...q, status: 'Out of quota', reason: 'The provider reports nothing left on this key.' };
96
+ return { ...base, ...q, status: 'Usable', reason: null };
97
+ }
98
+ const reason = options.redact(message).replace(/\s+/g, ' ').trim().slice(0, 300);
99
+ if (signal.aborted) return { ...base, ...q, status: 'Could not check', reason: `No answer within ${Math.round((options.timeoutMs ?? 30_000) / 1000)} seconds` };
100
+ if (isRefusal(message)) return { ...base, ...q, status: 'Refused', reason };
101
+ const kind = options.classify(message);
102
+ if (kind === 'quota') return { ...base, ...q, status: 'Out of quota', reason, until: options.resetFrom(message, options.family) };
103
+ if (kind === 'ratelimit') return { ...base, ...q, status: 'Rate-limited', reason };
104
+ return { ...base, ...q, status: 'Could not check', reason };
105
+ }