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,239 @@
1
+ /**
2
+ * `Keeper` → `Model provider` and `Usage` per round (Spec §6.10 "key、模型与路由", §3.10; D38, D105; CKC-03 AC-29,
3
+ * AC-33~AC-37): the owner's own keys — add, replace, remove, check — beside the keys configured outside the product;
4
+ * the project's route — main model, each step's model or whom it follows, the backup order — and each key's jobs at once;
5
+ * and, in `Usage`, one row per round split by model.
6
+ *
7
+ * A key's value comes in on `POST /api/keys` and `POST /api/keys/:id`, goes to the runtime, and never comes back out:
8
+ * every answer here carries the full mask at most. A refused request answers 400 with the reason, so nothing a key was
9
+ * part of reaches the server log (http.ts logs only a 500's stack).
10
+ */
11
+ import type { App } from './app.ts';
12
+ import { HttpApp, HttpError } from './http.ts';
13
+ import type { KeeperJob, Usage } from '../model/types.ts';
14
+ import type { ProjectStore } from '../store/project-store.ts';
15
+ import { cleanKeyValue } from '../keeper/own-keys.ts';
16
+ import { ROUTE_STEPS, wantOf } from '../keeper/route.ts';
17
+ import { THINKING_LEVELS } from '../keeper/clerk-steps.ts';
18
+ import { roundJobs } from './keeper-page.ts';
19
+
20
+ const fail = (e: unknown): never => { throw e instanceof HttpError ? e : new HttpError(400, e instanceof Error ? e.message : String(e)); };
21
+ const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
22
+
23
+ /** The page's view of the keys and the project's route. */
24
+ export async function modelProviderView(app: App, projectId: string) {
25
+ const keeper = app.keeper;
26
+ const keys = await keeper.keysView();
27
+ const name = (id: string) => keys.find((k) => k.id === id)?.name ?? id;
28
+ const usable = (id: string) => { const k = keys.find((x) => x.id === id); return Boolean(k && (k.state.status === 'Usable' || k.state.status === 'Rate-limited')); };
29
+ const route = keeper.routeFor(projectId);
30
+ const state = await keeper.providerState(projectId);
31
+ const lanes = keeper.laneState(projectId);
32
+ // The key the main row shows is the one the project runs on now: its own route's, else the machine's (a provider named
33
+ // where a key is meant is that provider's first usable key), else — no main model set anywhere — the key in use.
34
+ const keyFor = (provider: string): string => (keys.some((k) => k.id === provider) ? provider : keys.find((k) => k.provider === provider && usable(k.id))?.id ?? keys.find((k) => k.provider === provider)?.id ?? provider);
35
+ const mainModel = route.main ? { ...route.main, provider: keyFor(route.main.provider) } : state.model ? { provider: state.model.provider, id: state.model.id, thinking: state.model.thinking } : null;
36
+ const main = mainModel ? { ...mainModel, keyName: name(mainModel.provider), usable: usable(mainModel.provider), chosen: Boolean(route.main) } : null;
37
+ // The models a step can be set to: those of the providers with a usable key (§6.10 可选的模型来自有可用 key 的 provider), one entry per provider and model.
38
+ const offered = new Map<string, { provider: string; providerName: string; model: string; keys: string[]; priced: boolean }>();
39
+ for (const k of keys) {
40
+ if (!usable(k.id)) continue;
41
+ for (const m of k.models) {
42
+ const at = `${k.provider}/${m.id}`;
43
+ const o = offered.get(at) ?? { provider: k.provider, providerName: k.providerName, model: m.id, keys: [], priced: m.priced };
44
+ o.keys.push(k.name);
45
+ offered.set(at, o);
46
+ }
47
+ }
48
+ // The provider a key runs for (an extra key's `like`, an own key's provider); a provider id stays itself.
49
+ const familyOf = (id: string): string => keys.find((k) => k.id === id)?.provider ?? id;
50
+ const mainFamily = main ? familyOf(main.provider) : null;
51
+ /** Where a step's own model sits among the offered ones: the provider it names, else the main key's provider when that
52
+ * carries it (a step set by model alone runs on the main key first), else the first provider that offers it. */
53
+ const placeOf = (own: { readonly provider?: string; readonly model?: string } | null): string | null => {
54
+ if (!own?.model) return null;
55
+ const family = own.provider ? familyOf(own.provider)
56
+ : mainFamily && offered.has(`${mainFamily}/${own.model}`) ? mainFamily
57
+ : [...offered.values()].find((o) => o.model === own.model)?.provider ?? mainFamily ?? '';
58
+ return `${family}|${own.model}`;
59
+ };
60
+ return {
61
+ keys,
62
+ providers: keeper.keyProviders(),
63
+ thinking: ['off', ...THINKING_LEVELS],
64
+ route: {
65
+ own: route.own,
66
+ main,
67
+ // The main key is first in the order already; a backup naming it again (a machine with no main model set, whose first backup runs) is not listed twice.
68
+ backups: route.backups.filter((b) => b.provider !== main?.provider).map((b) => ({ ...b, keyName: name(b.provider), usable: usable(b.provider) })),
69
+ steps: ROUTE_STEPS.map((kind) => {
70
+ const own = route.steps[kind] ?? null;
71
+ const routeNow = { ...route, main: route.main ?? mainModel };
72
+ const want = wantOf(routeNow, kind);
73
+ // Whom the step follows when it has no model of its own, and the model that gives it — said even while it has one,
74
+ // since the select offers going back to it.
75
+ const steps = { ...routeNow.steps };
76
+ if (own?.thinking) steps[kind] = { thinking: own.thinking }; else delete steps[kind];
77
+ const without = wantOf({ ...routeNow, steps }, kind);
78
+ return { kind, own, model: want.model, provider: want.provider, thinking: want.thinking, follows: want.follows, followsWhom: without.follows, followsModel: without.model, place: placeOf(own) };
79
+ }),
80
+ },
81
+ offered: [...offered.values()],
82
+ inUse: state.model ? { ...state.model, keyName: name(state.model.provider) } : null,
83
+ fallback: state.fallback ? { ...state.fallback, keyName: name(state.fallback.provider), fromName: name(state.fallback.from.provider) } : null,
84
+ connected: state.connected,
85
+ reason: state.reason,
86
+ lanes: { lanesPerKey: lanes.lanesPerKey, total: lanes.total, keys: lanes.keys.map((k) => ({ ...k, keyName: name(k.provider) })) },
87
+ switches: state.switches,
88
+ };
89
+ }
90
+
91
+ // ───────────────────────── Usage per round per model (§3.10, §6.10 `Usage`; CKC-03 AC-37) ─────────────────────────
92
+
93
+ export interface ModelUsage {
94
+ /** The model, as the owner reads it: provider and model id. */
95
+ readonly model: string;
96
+ readonly keys: readonly string[];
97
+ readonly steps: readonly { readonly kind: string; readonly count: number }[];
98
+ /** The time its jobs ran, added up (jobs that ran side by side each count). */
99
+ readonly timeMs: number;
100
+ readonly input: number;
101
+ readonly output: number;
102
+ readonly cacheRead: number;
103
+ /** Null when the provider reports no price for it: the tokens are what there is. */
104
+ readonly cost: number | null;
105
+ readonly priced: boolean;
106
+ }
107
+ export interface RoundUsage {
108
+ readonly id: string;
109
+ readonly number: number;
110
+ readonly kind: string;
111
+ readonly status: string;
112
+ readonly startedAt: string;
113
+ readonly endedAt: string | null;
114
+ readonly wallMs: number;
115
+ readonly cost: number | null;
116
+ readonly input: number;
117
+ readonly output: number;
118
+ readonly byModel: readonly ModelUsage[];
119
+ }
120
+
121
+ /** Steps in the order a round runs them; a subagent a step sent comes last. */
122
+ const STEP_ORDER = ['session-drafts', 'orientation', 'skeleton', 'main', 'lane', 'dig', 'cross-check', 'synthesis', 'spot-check'];
123
+ const stepOrder = (kind: string): number => { const i = STEP_ORDER.indexOf(kind); return i < 0 ? STEP_ORDER.length : i; };
124
+ const jobTime = (j: KeeperJob, now: number): number => j.timing?.wallMs ?? (j.startedAt ? (j.endedAt ? Date.parse(j.endedAt) : now) - Date.parse(j.startedAt) : 0);
125
+
126
+ /**
127
+ * One row per round, newest first: its wall time and cost, split by the model each job ran on — the steps each model
128
+ * carried, their time, input and output tokens and cost. A model the provider gives no price for says so (`priced`
129
+ * false, cost null) and gives its tokens. Read from the jobs as recorded: the model a job ran on is on the job.
130
+ */
131
+ export function usageByRound(
132
+ store: ProjectStore,
133
+ look: { readonly keyName: (id: string) => string; readonly familyOf: (id: string) => string; readonly priced: (provider: string, model: string) => boolean | null },
134
+ now = Date.now(),
135
+ ): RoundUsage[] {
136
+ const rounds = store.clerkRounds.all().sort((a, b) => b.startedAt.localeCompare(a.startedAt));
137
+ return rounds.map((round) => {
138
+ const jobs = roundJobs(store, round).filter((j) => j.agent !== 'program' && j.model);
139
+ const groups = new Map<string, { keys: Set<string>; steps: Map<string, number>; timeMs: number; usage: Usage; priced: boolean }>();
140
+ for (const j of jobs) {
141
+ const family = look.familyOf(j.model!.provider);
142
+ const at = `${family}/${j.model!.id}`;
143
+ const g = groups.get(at) ?? { keys: new Set<string>(), steps: new Map<string, number>(), timeMs: 0, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: null }, priced: false };
144
+ g.keys.add(look.keyName(j.model!.provider));
145
+ const kind = j.step?.kind ?? 'subagent';
146
+ g.steps.set(kind, (g.steps.get(kind) ?? 0) + 1);
147
+ g.timeMs += Math.max(0, jobTime(j, now));
148
+ const listed = look.priced(j.model!.provider, j.model!.id);
149
+ const priced = listed ?? (j.usage.cost !== null && j.usage.cost > 0);
150
+ g.priced = g.priced || priced;
151
+ g.usage = { input: g.usage.input + j.usage.input, output: g.usage.output + j.usage.output, cacheRead: g.usage.cacheRead + j.usage.cacheRead, cacheWrite: g.usage.cacheWrite + j.usage.cacheWrite, cost: priced && j.usage.cost !== null ? (g.usage.cost ?? 0) + j.usage.cost : g.usage.cost };
152
+ groups.set(at, g);
153
+ }
154
+ const byModel: ModelUsage[] = [...groups.entries()].map(([model, g]) => ({
155
+ model, keys: [...g.keys].sort(), steps: [...g.steps.entries()].map(([kind, count]) => ({ kind, count })).sort((a, b) => stepOrder(a.kind) - stepOrder(b.kind)), timeMs: g.timeMs,
156
+ input: g.usage.input, output: g.usage.output, cacheRead: g.usage.cacheRead, cost: g.priced ? g.usage.cost ?? 0 : null, priced: g.priced,
157
+ })).sort((a, b) => b.timeMs - a.timeMs);
158
+ const costs = byModel.filter((m) => m.cost !== null);
159
+ return {
160
+ id: round.id, number: round.number, kind: round.kind, status: round.status, startedAt: round.startedAt, endedAt: round.endedAt,
161
+ wallMs: (round.endedAt ? Date.parse(round.endedAt) : now) - Date.parse(round.startedAt),
162
+ cost: costs.length ? costs.reduce((n, m) => n + (m.cost ?? 0), 0) : null,
163
+ input: byModel.reduce((n, m) => n + m.input, 0), output: byModel.reduce((n, m) => n + m.output, 0),
164
+ byModel,
165
+ };
166
+ });
167
+ }
168
+
169
+ export function registerKeyRoutes(http: HttpApp, app: App): void {
170
+ http.route('GET', '/api/projects/:id/model-provider', ({ params }) => modelProviderView(app, params.id!));
171
+
172
+ // A key in: provider, an optional name, the key. Saved on this machine, checked at once; only the mask comes back.
173
+ http.route('POST', '/api/keys', async ({ body }) => {
174
+ const b = (body ?? {}) as Record<string, unknown>;
175
+ try {
176
+ const provider = str(b.provider);
177
+ if (!provider) throw new Error('Choose the provider the key is for');
178
+ const value = cleanKeyValue(b.key);
179
+ const r = await app.keeper.addKey(provider, str(b.name), value);
180
+ return { key: r.key, check: r.check };
181
+ } catch (e) { return fail(e); }
182
+ });
183
+ http.route('POST', '/api/keys/:key', async ({ params, body }) => {
184
+ const b = (body ?? {}) as Record<string, unknown>;
185
+ try {
186
+ if (!app.keeper.ownKeys.get(params.key!)) throw new HttpError(404, 'Only a key saved here can be replaced; a key configured outside ProjectKeeper is changed where it is set');
187
+ const value = b.key === undefined || b.key === '' ? null : cleanKeyValue(b.key);
188
+ return await app.keeper.replaceKey(params.key!, value, str(b.name));
189
+ } catch (e) { return fail(e); }
190
+ });
191
+ http.route('DELETE', '/api/keys/:key', async ({ params }) => {
192
+ if (!app.keeper.ownKeys.get(params.key!)) throw new HttpError(404, 'Only a key saved here can be removed; a key configured outside ProjectKeeper is removed where it is set');
193
+ return app.keeper.removeKey(params.key!);
194
+ });
195
+ http.route('POST', '/api/keys/:key/check', async ({ params }) => {
196
+ if (!app.keeper.models.getProvider(params.key!)) throw new HttpError(404, `No key ${params.key}`);
197
+ return { check: await app.keeper.checkKeyNow(params.key!) };
198
+ });
199
+ // A key's own number of jobs at once; `null` gives it back to the setting for every key.
200
+ http.route('POST', '/api/keys/:key/lanes', ({ params, body }) => {
201
+ const raw = (body as { lanes?: unknown } | null)?.lanes;
202
+ if (raw === null) return { lanes: app.keeper.setKeyLanes(params.key!, null) };
203
+ const n = Number(raw);
204
+ if (!Number.isFinite(n) || n < 1 || n > 32) throw new HttpError(400, 'lanes must be a number from 1 to 32');
205
+ return { lanes: app.keeper.setKeyLanes(params.key!, n) };
206
+ });
207
+
208
+ // The project's route: { model?: {provider, id, thinking}, backups?: [{provider, id, thinking}], steps?: { kind: {provider?, model?, thinking?} | null } }.
209
+ http.route('POST', '/api/projects/:id/route', ({ params, body }) => {
210
+ const b = (body ?? {}) as Record<string, unknown>;
211
+ const model = (m: unknown) => { const o = (m ?? {}) as Record<string, unknown>; const provider = str(o.provider); const id = str(o.id); if (!provider || !id) throw new HttpError(400, 'A model is a key (provider) and a model id'); return { provider, id, thinking: str(o.thinking) }; };
212
+ try {
213
+ app.project(params.id!);
214
+ const steps = b.steps === undefined ? undefined : (() => {
215
+ if (!b.steps || typeof b.steps !== 'object' || Array.isArray(b.steps)) throw new HttpError(400, 'steps must be an object keyed by step');
216
+ return Object.fromEntries(Object.entries(b.steps as Record<string, unknown>).map(([k, v]) => {
217
+ if (v === null) return [k, null];
218
+ const o = (v ?? {}) as Record<string, unknown>;
219
+ return [k, { ...(str(o.provider) ? { provider: str(o.provider)! } : {}), ...(str(o.model) ? { model: str(o.model)! } : {}), ...(str(o.thinking) ? { thinking: str(o.thinking)! } : {}) }];
220
+ }));
221
+ })();
222
+ app.keeper.setRoute(params.id!, {
223
+ ...(b.model !== undefined ? { model: model(b.model) } : {}),
224
+ ...(Array.isArray(b.backups) ? { backups: b.backups.map(model) } : {}),
225
+ ...(steps ? { steps } : {}),
226
+ });
227
+ return modelProviderView(app, params.id!);
228
+ } catch (e) { return fail(e); }
229
+ });
230
+
231
+ http.route('GET', '/api/projects/:id/usage-rounds', async ({ params }) => {
232
+ const keys = await app.keeper.keysView().catch(() => []);
233
+ const keyName = (id: string) => keys.find((k) => k.id === id)?.name ?? id;
234
+ const priced = (provider: string, model: string): boolean | null => {
235
+ try { const m = app.keeper.models.getModel(provider, model) ?? app.keeper.models.getModel(app.keeper.familyOf(provider), model); return m ? m.cost.input > 0 || m.cost.output > 0 : null; } catch { return null; }
236
+ };
237
+ return { rounds: usageByRound(app.store(params.id!), { keyName, familyOf: (id) => app.keeper.familyOf(id), priced }) };
238
+ });
239
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Owner-facing grant for the Project folder standing authorization (CKC-26 AC-7–AC-10), and its state as the Keeper page
3
+ * shows it. The owner gives it on that page (`Authorize…`) and withdraws it there (`Withdraw`, the revoke route of
4
+ * every standing authorization).
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { isAbsolute, relative, sep } from 'node:path';
8
+ import { canonicalPath } from '../util/paths.ts';
9
+ import type { App } from './app.ts';
10
+ import { HttpApp, HttpError, requireString } from './http.ts';
11
+ import { grantProjectFolderAuthorization, projectFolderPath, syncProjectFolder, type ProjectFolderAuthorizationInput } from '../keeper/project-folder.ts';
12
+
13
+ export function registerProjectFolderRoutes(http: HttpApp, app: App): void {
14
+ http.route('POST', '/api/projects/:id/authorizations/project-folder', ({ params, body }) => {
15
+ const project = app.project(params.id!);
16
+ const input = (body ?? {}) as Record<string, unknown>;
17
+ const quote = requireString(input.quote, 'quote');
18
+ if (input.path !== undefined && (typeof input.path !== 'string' || !input.path.trim())) throw new HttpError(400, 'path must be a nonempty string');
19
+ if (input.commits !== undefined && typeof input.commits !== 'boolean') throw new HttpError(400, 'commits must be a boolean');
20
+ if (input.sourceId !== undefined && (typeof input.sourceId !== 'string' || !input.sourceId.trim())) throw new HttpError(400, 'sourceId must be a nonempty string');
21
+ const path = input.path as string | undefined;
22
+ const sourceId = input.sourceId as string | undefined;
23
+ try {
24
+ return grantProjectFolder(app, project.id, { path, commits: input.commits as boolean | undefined, quote, sourceId });
25
+ } catch (error) {
26
+ if (error instanceof HttpError) throw error;
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ if (message.startsWith('An active Project folder')) throw new HttpError(409, message);
29
+ if (/Invalid project folder|must be inside|must not be hidden|Unsafe project folder|escapes through|chosen folder already contains|folder contains files outside|Invalid owner source/.test(message)) throw new HttpError(400, message);
30
+ throw error;
31
+ }
32
+ });
33
+ }
34
+
35
+ /**
36
+ * The owner's grant of the project folder (Spec §1.14; CKC-26 AC-7, AC-8): record it, write the folder, and keep the
37
+ * folder out of what is read as the project's material. It is ProjectKeeper's output, written from the assets, so the
38
+ * Keeper never reads its own folder back as a project document; the scope item says so, and the owner can see it.
39
+ */
40
+ export function grantProjectFolder(app: App, projectId: string, input: ProjectFolderAuthorizationInput) {
41
+ const project = app.project(projectId);
42
+ const store = app.store(project.id);
43
+ projectFolderPath(project, input.path);
44
+ const authorization = grantProjectFolderAuthorization(store, project, input);
45
+ // Excluded before anything is written (QC AW-2): a first write that fails part way must not leave the folder to be read
46
+ // back as project material once a later write succeeds.
47
+ excludeProjectFolder(app, project.id, authorization.projectFolder!.path, authorization.id);
48
+ const sync = syncProjectFolder(store, project);
49
+ return { authorization: store.authorizations.get(authorization.id), sync };
50
+ }
51
+
52
+ /** What the authorization allows, in one sentence: fixed interface text, shown beside the control on the Keeper page. */
53
+ export const PROJECT_FOLDER_ALLOWS = 'With this authorization the Keeper maintains one folder of its own inside the project — projectkeeper/, unless you name another — and makes commits that contain only that folder; it never pushes, and it changes no other file of the project.';
54
+
55
+ /** The folder a grant uses when the owner names no other, relative to the project root. */
56
+ export const PROJECT_FOLDER_USUAL = 'projectkeeper';
57
+
58
+ /** The project-folder authorization as the Keeper page shows it: what it allows, whether it stands, and what it did. */
59
+ export interface ProjectFolderState {
60
+ readonly allows: string;
61
+ readonly granted: boolean;
62
+ /** The authorization in force; `Withdraw` revokes this one. */
63
+ readonly authorizationId: string | null;
64
+ /** The folder in force; without a grant, the folder of the last grant that was withdrawn, if any. */
65
+ readonly path: string | null;
66
+ /** The folder name a new grant starts from, relative to the project root: the last grant's folder, else `projectkeeper`. */
67
+ readonly usual: string;
68
+ readonly commits: boolean;
69
+ readonly grantedAt: string | null;
70
+ readonly lastWrite: { readonly at: string; readonly commit: string | null } | null;
71
+ /** When the last grant was withdrawn; null while one stands or when none was ever given. */
72
+ readonly withdrawnAt: string | null;
73
+ /** Whether `path` is in the project now: a withdrawal leaves the folder where it is. */
74
+ readonly exists: boolean;
75
+ }
76
+
77
+ export function projectFolderState(app: App, projectId: string): ProjectFolderState {
78
+ const all = app.store(projectId).authorizations.filter((a) => a.projectFolder != null).sort((a, b) => a.at.localeCompare(b.at));
79
+ const active = all.filter((a) => !a.revokedAt).at(-1) ?? null;
80
+ const last = active ?? all.at(-1) ?? null;
81
+ const path = last?.projectFolder?.path ?? null;
82
+ return {
83
+ allows: PROJECT_FOLDER_ALLOWS, granted: active !== null, authorizationId: active?.id ?? null, path, usual: relativeToRoot(app.project(projectId).locations[0], path) ?? PROJECT_FOLDER_USUAL,
84
+ commits: last?.projectFolder?.commits ?? true, grantedAt: active?.at ?? null, lastWrite: last?.projectFolder?.lastWrite ?? null,
85
+ withdrawnAt: active ? null : last?.revokedAt ?? null, exists: path !== null && existsSync(path),
86
+ };
87
+ }
88
+
89
+ /** A folder inside the project as the owner names it: relative to the project's root, with forward slashes. */
90
+ function relativeToRoot(location: string | undefined, path: string | null): string | null {
91
+ if (!location || !path) return null;
92
+ try {
93
+ const rel = relative(canonicalPath(location), path);
94
+ return rel && !isAbsolute(rel) && rel.split(sep)[0] !== '..' ? rel.split(sep).join('/') : null;
95
+ } catch { return null; }
96
+ }
97
+
98
+ /** The project folder as a scope item: Excluded, with the authorization it belongs to as its reason. */
99
+ export function excludeProjectFolder(app: App, projectId: string, path: string, authorizationId: string): void {
100
+ app.addScopeItem(projectId, {
101
+ path, category: 'Directory', relation: 'Excluded',
102
+ reason: `ProjectKeeper's project folder (${authorizationId}): written from the assets under the owner's authorization, not read as the project's material`,
103
+ });
104
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The node_modules the page's own scripts (cytoscape, dagre, cytoscape-dagre) are served from at `/vendor/`: the
3
+ * package's own in a clone, the one above it when ProjectKeeper is installed as a package and npm put its dependencies
4
+ * beside it.
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ export function vendorDir(appRoot: string): string {
10
+ for (let dir = appRoot; ; dir = dirname(dir)) {
11
+ if (existsSync(join(dir, 'node_modules', 'cytoscape', 'package.json'))) return join(dir, 'node_modules');
12
+ if (dirname(dir) === dir) return join(appRoot, 'node_modules');
13
+ }
14
+ }