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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeper runtime on pi (CKC-03, Spec §1.13, §3.10, §6.9, §8).
|
|
3
|
+
*
|
|
4
|
+
* One `KeeperRuntime` per process. Each cognitive job runs in its own pi session created with
|
|
5
|
+
* the project directory as cwd, so global skills, extensions, prompt templates and the
|
|
6
|
+
* project's instruction files load exactly as when the owner runs pi there (§8.2). The job's
|
|
7
|
+
* input is what its prompt gives it; nothing is forked from another job's session (§3.4). A job whose run was
|
|
8
|
+
* interrupted — a rate limit or quota from the provider, a restart of ProjectKeeper — goes on in its own session on
|
|
9
|
+
* whichever key it is given next, so what it had read and reasoned is not lost (§3.10, D99; `openSavedSession`).
|
|
10
|
+
*
|
|
11
|
+
* Owner-initiated work and automatic organizing run in separate lanes so an owner request
|
|
12
|
+
* never waits behind backlog (§3.2). Pausing stops only automatic work.
|
|
13
|
+
*/
|
|
14
|
+
import { guardTurn, keeperBuiltinTools, RefusalStreak, REPEATED_REFUSAL_LIMIT, StepTimer, type RepeatedRefusal } from './step-timing.ts';
|
|
15
|
+
import { EventEmitter } from 'node:events';
|
|
16
|
+
import { organizingHeld } from './held.ts';
|
|
17
|
+
import { openInPiOffWindows } from './open-in-pi.ts';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import {
|
|
21
|
+
buildSessionProjection, calculateContextTokens, createAgentSession, DefaultResourceLoader, estimateTokens, getLatestCompactionEntry,
|
|
22
|
+
ModelRuntime, parseSessionEntries, ProjectTrustStore, SessionManager, SettingsManager, getAgentDir,
|
|
23
|
+
type AgentSession, type ToolDefinition,
|
|
24
|
+
} from '@earendil-works/pi-coding-agent';
|
|
25
|
+
import { estimateContextTokens } from '@earendil-works/pi-agent-core';
|
|
26
|
+
import { getCurrentSystemMessage } from '@earendil-works/pi-ai';
|
|
27
|
+
import type { JobKind, JobStatus, KeeperStatus } from '../model/vocab.ts';
|
|
28
|
+
import type { KeeperJob, Project, Usage } from '../model/types.ts';
|
|
29
|
+
import type { ClerkStage, StepTiming } from '../model/k-types.ts';
|
|
30
|
+
import { newId } from '../model/ids.ts';
|
|
31
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
32
|
+
import type { Workspace } from '../store/workspace.ts';
|
|
33
|
+
import { keeperTools, type ToolContext } from './tools.ts';
|
|
34
|
+
import { roundTools } from './round-tools.ts';
|
|
35
|
+
import { isReplacedRound, jobRole, stepToolsFor, toolsFor } from './roles.ts';
|
|
36
|
+
import { clerkTools } from './clerk-tools.ts';
|
|
37
|
+
import { OwnKeyStore, publicKey, type OwnKey, type PublicOwnKey } from './own-keys.ts';
|
|
38
|
+
import { checkKey, QUOTA_REPORTERS, type KeyCheck } from './key-check.ts';
|
|
39
|
+
import { keysForJob, modelOnKey, onPin, ROUTE_STEPS as ROUTE_STEP_KINDS, routeKeys, routeOf, wantOf, type ProjectRoute, type Route, type RouteKey, type StepRoute, type Want } from './route.ts';
|
|
40
|
+
import { THINKING_LEVELS } from './clerk-steps.ts';
|
|
41
|
+
import type { RouteModel } from '../model/types.ts';
|
|
42
|
+
type RouteModelInput = { readonly provider: string; readonly id: string; readonly thinking?: string | null };
|
|
43
|
+
type StepRouteInput = { readonly provider?: string; readonly model?: string; readonly thinking?: string };
|
|
44
|
+
import { stageTools } from './organize/stage-tools.ts';
|
|
45
|
+
import { laneTools, type LaneRunner } from './organize/lane-tools.ts';
|
|
46
|
+
import { writeRefusal } from './organize/stage-gate.ts';
|
|
47
|
+
import { clerkSkillPathsFor } from './organize/skills.ts';
|
|
48
|
+
import { ledgerTools } from './ledger-tools.ts';
|
|
49
|
+
import { coverageTools } from './organize/coverage-tools.ts';
|
|
50
|
+
import { ownerLineTools } from './organize/owner-lines.ts';
|
|
51
|
+
import { entryGateTools } from './organize/entry-gate.ts';
|
|
52
|
+
import { placeTools } from './organize/place-tools.ts';
|
|
53
|
+
import type { LedgerHook } from './evidence.ts';
|
|
54
|
+
import { keeperPreamble } from './prompts.ts';
|
|
55
|
+
import { PI_CAPABILITIES } from './capabilities.ts';
|
|
56
|
+
import { createReadBoundary, type ReadBoundary } from './bounds/boundary.ts';
|
|
57
|
+
import { READING_TOOLS, stepReads } from './bounds/reads.ts';
|
|
58
|
+
import { isAbsolute } from 'node:path';
|
|
59
|
+
import { gitStatusAsync } from '../util/git.ts';
|
|
60
|
+
import { keeperPathLimit } from '../util/paths.ts';
|
|
61
|
+
import { linkPutOutDeliveries } from '../process/delivery-links.ts';
|
|
62
|
+
import { unlightUnchecked } from '../process/breakpoint-candidates.ts';
|
|
63
|
+
import { TABLE_FOLLOW_UP, tableFollowUpPreamble, tableSkillDir } from '../table/follow-up.ts';
|
|
64
|
+
import { tableFollowUpTools, type TableWriter } from '../table/follow-up-tools.ts';
|
|
65
|
+
|
|
66
|
+
export interface JobRequest {
|
|
67
|
+
readonly kind: JobKind;
|
|
68
|
+
readonly initiator: 'auto' | 'owner';
|
|
69
|
+
readonly scope: { readonly kind: string; readonly ids: readonly string[]; readonly label: string };
|
|
70
|
+
/** The full task prompt (after the preamble). */
|
|
71
|
+
readonly prompt: string;
|
|
72
|
+
readonly priority?: number;
|
|
73
|
+
readonly requestBasis?: KeeperJob['requestBasis'];
|
|
74
|
+
readonly parentJobId?: string | null;
|
|
75
|
+
/** A subagent of a job that is already running: it starts even while automatic organizing is held, because its
|
|
76
|
+
* parent is mid-flight and waiting for it. Pausing holds what has not started; `Stop` is what ends a running tree. */
|
|
77
|
+
readonly bypassHold?: boolean;
|
|
78
|
+
/** Stream assistant text to listeners (chat). */
|
|
79
|
+
readonly stream?: boolean;
|
|
80
|
+
readonly timeoutMs?: number | null;
|
|
81
|
+
/** Extra tools (e.g. chat-specific) besides the pk_* set. */
|
|
82
|
+
readonly extraTools?: readonly ToolDefinition[];
|
|
83
|
+
readonly task?: unknown;
|
|
84
|
+
/** Materials a material-organizing job covers (fact record ids and coverage follow them). */
|
|
85
|
+
readonly materials?: readonly { readonly key: string; readonly sourceIds: readonly string[] }[];
|
|
86
|
+
/** Judgement record of a product re-look. */
|
|
87
|
+
readonly judgementId?: string | null;
|
|
88
|
+
/** A conversation: turns with the same key share one live pi session (§6.8). */
|
|
89
|
+
readonly sessionKey?: string | null;
|
|
90
|
+
/** Source id of the owner's message that starts this turn. */
|
|
91
|
+
readonly ownerSourceId?: string | null;
|
|
92
|
+
readonly conversation?: boolean;
|
|
93
|
+
/** The step of a clerk-method round this job is (Spec §3.3): it decides the job's tools and its model settings. */
|
|
94
|
+
readonly step?: KeeperJob['step'];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A job the program does itself (a round's ledger step, a skipped step, a round's root): never queued for a model. */
|
|
98
|
+
export interface ProgramJobRequest {
|
|
99
|
+
readonly kind: JobKind;
|
|
100
|
+
readonly scope: { readonly kind: string; readonly ids: readonly string[]; readonly label: string };
|
|
101
|
+
readonly step: KeeperJob['step'];
|
|
102
|
+
readonly parentJobId: string | null;
|
|
103
|
+
readonly task: unknown;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface KeeperEvent {
|
|
107
|
+
readonly projectId: string;
|
|
108
|
+
readonly jobId: string;
|
|
109
|
+
readonly kind: 'job' | 'delta' | 'step' | 'done' | 'status';
|
|
110
|
+
readonly data?: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ChatTelemetry {
|
|
114
|
+
readonly model: { readonly provider: string; readonly id: string; readonly name: string } | null;
|
|
115
|
+
readonly context: {
|
|
116
|
+
readonly usedTokens: number | null;
|
|
117
|
+
readonly limitTokens: number | null;
|
|
118
|
+
readonly percent: number | null;
|
|
119
|
+
/** True only when messages after the provider's last exact usage had to be estimated. */
|
|
120
|
+
readonly estimated: boolean;
|
|
121
|
+
readonly source: 'live' | 'saved' | 'unavailable';
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface SavedChatSession {
|
|
126
|
+
readonly sessionFile: string | null;
|
|
127
|
+
/** The leaf recorded for the selected conversation turn, so an abandoned branch is never counted. */
|
|
128
|
+
readonly leafId?: string | null;
|
|
129
|
+
/** The model recorded on the job is a fallback only when the session itself cannot name one. */
|
|
130
|
+
readonly model: { readonly provider: string; readonly id: string } | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A backup provider in use because the chosen one reported its quota exhausted (§6.9: visible, never silent). */
|
|
134
|
+
export interface ProviderFallback {
|
|
135
|
+
readonly provider: string;
|
|
136
|
+
readonly id: string;
|
|
137
|
+
readonly from: { provider: string; id: string };
|
|
138
|
+
readonly reason: string;
|
|
139
|
+
readonly since: string;
|
|
140
|
+
readonly until: string; // when the chosen provider is tried again
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ProviderState {
|
|
144
|
+
readonly connected: boolean;
|
|
145
|
+
readonly reason: string | null;
|
|
146
|
+
readonly model: { provider: string; id: string; thinking: string | null } | null;
|
|
147
|
+
readonly available: { provider: string; id: string; name: string }[];
|
|
148
|
+
readonly backups: readonly { provider: string; id: string }[];
|
|
149
|
+
readonly fallback: ProviderFallback | null;
|
|
150
|
+
readonly switches: readonly { at: string; from: string; to: string; reason: string; until: string }[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The reset time a quota error names, when it names one. Z.ai and Zhipu report Beijing time, in English ("… will reset
|
|
154
|
+
* at 2026-09-18 17:08:09") or in Chinese ("… 将在 2026-09-18 17:08:09 重置"). */
|
|
155
|
+
export function quotaResetFrom(message: string, provider: string): string | null {
|
|
156
|
+
const m = /(?:reset(?:s)?\s+at|将在)\s*(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)/i.exec(message);
|
|
157
|
+
if (!m) return null;
|
|
158
|
+
const t = Date.parse(`${m[1]}T${m[2]}${/^zai/.test(provider) ? '+08:00' : ''}`);
|
|
159
|
+
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `parked`: how many subagents the job in this lane is waiting for. While any is out the lane does not count against
|
|
163
|
+
* the key's capacity — otherwise a main job with several subagents would hold a lane doing nothing, and a round whose
|
|
164
|
+
* subagents outnumber the lanes could wait on itself (D59 design 1). It is a count because a main job sends several
|
|
165
|
+
* at once (pi runs the tool calls of one turn in parallel): with a yes/no flag the first subagent to answer put the
|
|
166
|
+
* lane back in the count while the job was still waiting for the others. */
|
|
167
|
+
interface Lane { running: KeeperJob | null; session: AgentSession | null; abort: (() => void) | null; provider: string | null; parked?: number;
|
|
168
|
+
/** Stop the running job to go on when organizing resumes (D99, E148 D-i): it ends `Paused`, marked to continue its session. */
|
|
169
|
+
pause?: (() => void) | null;
|
|
170
|
+
/** The name of the key the owner removed while this lane's job ran on it: the job goes on on the next key (§6.10). */
|
|
171
|
+
keyRemoved?: string | null }
|
|
172
|
+
const parkedLane = (l: Lane): boolean => (l.parked ?? 0) > 0;
|
|
173
|
+
const newLane = (): Lane => ({ running: null, session: null, abort: null, provider: null, parked: 0, pause: null, keyRemoved: null });
|
|
174
|
+
|
|
175
|
+
/** One key as `Keeper` → `Model provider` lists it (§6.10; CKC-03 AC-33, AC-34). Never its value. */
|
|
176
|
+
export interface KeyView {
|
|
177
|
+
readonly id: string;
|
|
178
|
+
readonly name: string;
|
|
179
|
+
readonly provider: string;
|
|
180
|
+
readonly providerName: string;
|
|
181
|
+
/** Where it comes from: saved in ProjectKeeper, an environment variable, pi's own login, the settings' extra keys. */
|
|
182
|
+
readonly from: 'ProjectKeeper' | 'Environment' | 'pi login' | 'Settings' | 'Outside ProjectKeeper';
|
|
183
|
+
readonly fromDetail: string;
|
|
184
|
+
/** The full mask, for a key saved here; null for one configured outside. */
|
|
185
|
+
readonly mask: string | null;
|
|
186
|
+
readonly removable: boolean;
|
|
187
|
+
readonly addedAt: string | null;
|
|
188
|
+
readonly replacedAt: string | null;
|
|
189
|
+
readonly state: { readonly status: KeyCheck['status'] | 'No credentials'; readonly reason: string | null; readonly until: string | null; readonly checkedAt: string | null; readonly how: string | null; readonly quota: string | null; readonly quotaReported: boolean };
|
|
190
|
+
/** Jobs it carries now, across projects. */
|
|
191
|
+
readonly running: number;
|
|
192
|
+
/** Automatic jobs it may run at once; `ownLanes` when set for this key alone. */
|
|
193
|
+
readonly lanes: number;
|
|
194
|
+
readonly ownLanes: boolean;
|
|
195
|
+
readonly models: readonly { readonly id: string; readonly priced: boolean }[];
|
|
196
|
+
}
|
|
197
|
+
/** The lanes of one project: the owner's own lane, the lanes of automatic work, and the lanes of subagents that owner
|
|
198
|
+
* work sent — the owner's lane is held by the job waiting for them, so they cannot run in it (batch C1, item 6). */
|
|
199
|
+
interface ProjectLanes { owner: Lane; auto: Lane[]; delegated: Lane[] }
|
|
200
|
+
|
|
201
|
+
const zeroUsage = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: null });
|
|
202
|
+
const costSum = (a: number | null, b: number | null): number | null => (a === null && b === null ? null : (a ?? 0) + (b ?? 0));
|
|
203
|
+
/** A job's usage after a run that went on from `prior`: what the run's session spent since `baseline`, added to it. */
|
|
204
|
+
function usageAfter(prior: Usage, now: Usage, baseline: Usage): Usage {
|
|
205
|
+
const d = (k: 'input' | 'output' | 'cacheRead' | 'cacheWrite') => prior[k] + Math.max(0, now[k] - baseline[k]);
|
|
206
|
+
const cost = now.cost === null ? prior.cost : costSum(prior.cost, Math.max(0, now.cost - (baseline.cost ?? 0)));
|
|
207
|
+
return { input: d('input'), output: d('output'), cacheRead: d('cacheRead'), cacheWrite: d('cacheWrite'), cost };
|
|
208
|
+
}
|
|
209
|
+
function timingSum(a: StepTiming, b: StepTiming): StepTiming {
|
|
210
|
+
return { wallMs: a.wallMs + b.wallMs, generationMs: a.generationMs + b.generationMs, toolMs: a.toolMs + b.toolMs, queueMs: a.queueMs + b.queueMs, parseRetryMs: a.parseRetryMs + b.parseRetryMs, otherMs: a.otherMs + b.otherMs };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The message an interrupted job's own session is continued with, in place of its task (D99): the task, the preamble and
|
|
215
|
+
* everything the job read and reasoned are in the session already.
|
|
216
|
+
*/
|
|
217
|
+
export function continuationMessage(why: NonNullable<KeeperJob['resume']>['why'], model: { provider: string; id: string }, note: string | null = null): string {
|
|
218
|
+
const cause = why === 'provider'
|
|
219
|
+
? 'The model provider interrupted this work (a rate limit or an exhausted quota).'
|
|
220
|
+
: why === 'restart'
|
|
221
|
+
? 'ProjectKeeper restarted while this work was running. A tool call that was running then may show no result: check what it did before doing it again.'
|
|
222
|
+
: why === 'pause'
|
|
223
|
+
? 'The owner paused organizing while this work was running, and has resumed it. A tool call that was running then may show no result: check what it did before doing it again.'
|
|
224
|
+
: 'This work ended before its task was done, and the program runs it again.';
|
|
225
|
+
return `${cause} It goes on now in this same session, on ${model.provider}/${model.id}. Continue where you left off: what you wrote with the tools is saved, and your task is above.${note ? `\n\n${note}` : ''}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Whether a job's interrupted run can go on in its own session: a model's job with a saved session, and not a
|
|
229
|
+
* conversation turn, which keeps its live session by key (§6.8). */
|
|
230
|
+
function continuable(job: KeeperJob): boolean {
|
|
231
|
+
return job.agent !== 'program' && !!job.sessionFile && !((job.task as { sessionKey?: string | null } | null)?.sessionKey);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Open an interrupted job's saved pi session to continue it, or say why it cannot be: its file is gone, cannot be read as
|
|
236
|
+
* a pi session, or holds none of the job's work. Never creates or repairs anything for a file it cannot use. `model` is
|
|
237
|
+
* the model the session last recorded, so a continuation on another key records the change.
|
|
238
|
+
*/
|
|
239
|
+
export function openSavedSession(sessionFile: string, cwd: string): { manager: SessionManager; model: { provider: string; modelId: string } | null } | { reason: string } {
|
|
240
|
+
if (!existsSync(sessionFile)) return { reason: `its saved session ${sessionFile} is gone` };
|
|
241
|
+
let manager: SessionManager;
|
|
242
|
+
try { manager = SessionManager.open(sessionFile, undefined, cwd); } catch (e) { return { reason: `its saved session ${sessionFile} cannot be read (${(e as Error).message})` }; }
|
|
243
|
+
let context: ReturnType<SessionManager['buildSessionContext']>;
|
|
244
|
+
try { context = manager.buildSessionContext(); } catch (e) { return { reason: `its saved session ${sessionFile} cannot be read (${(e as Error).message})` }; }
|
|
245
|
+
if (!context.messages.some((m) => m.role === 'user')) return { reason: `its saved session ${sessionFile} holds none of its work` };
|
|
246
|
+
return { manager, model: context.model };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export type ErrorKind = 'quota' | 'ratelimit' | 'unavailable' | 'error';
|
|
250
|
+
export function classifyError(message: string): ErrorKind {
|
|
251
|
+
// Z.ai and Zhipu send every limit as HTTP 429 and tell them apart by code. 1302, 1303 and 1305 are per-request rate
|
|
252
|
+
// limits: the key rests RATE_LIMIT_REST_MS and the other keys take the work. 1304 (daily), 1308 (the Coding Plan's five-hour window), 1309 (plan expired)
|
|
253
|
+
// and 1310 (weekly or monthly) take the key out of use until the time the message names.
|
|
254
|
+
const code = /"code"\s*:\s*"?(\d{4})\b/.exec(message)?.[1];
|
|
255
|
+
if (code === '1302' || code === '1303' || code === '1305') return 'ratelimit';
|
|
256
|
+
if (code === '1304' || code === '1308' || code === '1309' || code === '1310') return 'quota';
|
|
257
|
+
if (/quota|exhausted|insufficient|credit|limit exhausted|usage limit|使用上限/i.test(message)) return 'quota';
|
|
258
|
+
// A per-request rate limit (HTTP 429 "too many requests"): back off, do not change provider.
|
|
259
|
+
if (/rate.?limit|too many requests|429/i.test(message)) return 'ratelimit';
|
|
260
|
+
if (/ECONNREFUSED|ENOTFOUND|fetch failed|network|5\d\d|overloaded|unavailable|timeout/i.test(message)) return 'unavailable';
|
|
261
|
+
return 'error';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** An assistant message as pi's events carry it, as far as the runtime reads it. */
|
|
265
|
+
type Reply = { readonly content?: readonly { readonly type: string; readonly text?: string }[]; readonly errorMessage?: string; readonly stopReason?: string; readonly timestamp?: number };
|
|
266
|
+
|
|
267
|
+
/** What a job records when it ends because the same call kept being refused (CKC-03 AC-30): which tool, what error,
|
|
268
|
+
* in how many turns, and how many other calls came back refused with it — the steps themselves are in its activity. */
|
|
269
|
+
export function repeatedRefusalError(r: RepeatedRefusal): string {
|
|
270
|
+
const error = r.error.length > 300 ? `${r.error.slice(0, 300)}…` : r.error;
|
|
271
|
+
const alongside = r.alongside ? ` (${r.alongside} more refused call${r.alongside === 1 ? ' was' : 's were'} re-sent with it)` : '';
|
|
272
|
+
return `Ended after the model sent the same call to ${r.tool} in ${r.turns} turns in a row, refused the same way each time with nothing else run in those turns${alongside} — the limit is ${REPEATED_REFUSAL_LIMIT} turns — instead of letting it loop until the step's time limit. The refusal: ${error}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The order queued jobs start in: priority first, then who has waited longest. A job moves up one step for every ten
|
|
277
|
+
* minutes it has waited, so lower-priority work is not starved while higher-priority work keeps arriving — seen on
|
|
278
|
+
* 2026-09-18, when every edit to a plan document queued a framing job ahead of the change follow-up and the follow-up
|
|
279
|
+
* waited twenty minutes without a lane. Owner work has its own lane, so this only orders automatic work among itself.
|
|
280
|
+
*/
|
|
281
|
+
export const AGING_STEP_MS = 10 * 60_000;
|
|
282
|
+
export function queueOrder<T extends { readonly priority: number; readonly queuedAt: string }>(jobs: readonly T[], now: number): T[] {
|
|
283
|
+
const effective = (j: T) => j.priority - Math.floor(Math.max(0, now - Date.parse(j.queuedAt)) / AGING_STEP_MS);
|
|
284
|
+
return [...jobs].sort((a, b) => effective(a) - effective(b) || a.queuedAt.localeCompare(b.queuedAt));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** How long a key that answered a rate limit (429) rests before it is tried again (owner 2026-09-28: 「限流了过10分钟再试一下」). */
|
|
288
|
+
export const RATE_LIMIT_REST_MS = 10 * 60_000;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Rounds of the material method (D59) are not run any more: the clerk method replaced them (Spec v3.0 §3.3), and their
|
|
292
|
+
* prompts name tools that no longer exist. A home a build before increment K left with such a round waiting — queued,
|
|
293
|
+
* paused or waiting for quota — has it stopped at start, with the reason, instead of running it. Returns how many.
|
|
294
|
+
*/
|
|
295
|
+
/**
|
|
296
|
+
* Work a previous process left waiting for quota was to be queued again by a quota or rate-limit timer, and those timers
|
|
297
|
+
* did not outlive that process: at start it is queued now. If the quota is still out, it waits again with a new timer
|
|
298
|
+
* (Spec §3.10: after a restart, automatic work goes on from its saved progress). Returns how many.
|
|
299
|
+
*/
|
|
300
|
+
export function requeueWaitingForQuota(store: ProjectStore): number {
|
|
301
|
+
const waiting = store.jobs.filter((j) => j.status === 'Waiting for quota' && j.agent !== 'program' && !isReplacedRound(j));
|
|
302
|
+
for (const job of waiting) store.jobs.put({ ...job, status: 'Queued', error: null }, { jobId: job.id, summary: 'Queued again: ProjectKeeper restarted while it waited for quota' });
|
|
303
|
+
return waiting.length;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Work that finished carries no error. A run used not to clear the answer of the run before it (a rate limit or a quota
|
|
308
|
+
* answer that sent the job back to the queue), so work that then finished still showed that answer; at start it is
|
|
309
|
+
* cleared. Returns how many.
|
|
310
|
+
*/
|
|
311
|
+
export function clearErrorsOfFinishedWork(store: ProjectStore): number {
|
|
312
|
+
const stale = store.jobs.filter((j) => j.status === 'Done' && !!j.error);
|
|
313
|
+
for (const job of stale) store.jobs.put({ ...job, error: null }, { jobId: job.id, summary: 'Cleared an error left from an earlier run of this finished work' });
|
|
314
|
+
return stale.length;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function stopReplacedRounds(store: ProjectStore, at = new Date().toISOString()): number {
|
|
318
|
+
const waiting = store.jobs.filter((j) => (j.status === 'Queued' || j.status === 'Paused' || j.status === 'Waiting for quota') && isReplacedRound(j));
|
|
319
|
+
for (const job of waiting) {
|
|
320
|
+
store.jobs.put({ ...job, status: 'Stopped', endedAt: at, error: 'A round of the material method, which the clerk method replaced; not run' }, { jobId: job.id, summary: 'Replaced round not run' });
|
|
321
|
+
}
|
|
322
|
+
return waiting.length;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export class KeeperRuntime extends EventEmitter {
|
|
326
|
+
private modelRuntime: ModelRuntime | null = null;
|
|
327
|
+
private readonly lanes = new Map<string, ProjectLanes>();
|
|
328
|
+
/** Automatic organizing runs this many jobs at once (each its own pi session). Material batches are independent,
|
|
329
|
+
* so the useful maximum is the number of batches; the setting changes without a restart (§3.10: visible, adjustable). */
|
|
330
|
+
/** Automatic jobs per key (trial setting, owner 2026-09-17: one key, five lanes). */
|
|
331
|
+
get lanesPerKey(): number {
|
|
332
|
+
const n = this.workspace.settings.organizingLanesPerKey ?? Number(process.env.PK_LANES_PER_KEY ?? 3);
|
|
333
|
+
return Math.min(32, Math.max(1, Math.floor(Number(n) || 3)));
|
|
334
|
+
}
|
|
335
|
+
/** Automatic jobs at once on one key: its own setting, else the setting for every key (§6.10 每把 key 同时跑几路). */
|
|
336
|
+
lanesFor(provider: string): number {
|
|
337
|
+
const own = this.workspace.settings.keyLanes?.[provider];
|
|
338
|
+
return own === undefined ? this.lanesPerKey : Math.min(32, Math.max(1, Math.floor(Number(own) || 1)));
|
|
339
|
+
}
|
|
340
|
+
/** The route a project runs on (route.ts): its own, or the machine's settings. Without a project, the machine's. */
|
|
341
|
+
routeFor(projectId: string | null = null): Route {
|
|
342
|
+
let project = null;
|
|
343
|
+
if (projectId) { try { project = this.projectOf(projectId); } catch { project = null; } }
|
|
344
|
+
return routeOf(project, this.workspace.settings, this.piDefault());
|
|
345
|
+
}
|
|
346
|
+
private hasModel = (key: string, model: string): boolean => { try { return Boolean(this.models.getModel(key, model)); } catch { return false; } };
|
|
347
|
+
/** The keys automatic work of a project is spread over, in its route's order (route.ts `routeKeys`), one entry per key. */
|
|
348
|
+
private keyChoices(projectId: string | null = null): RouteKey[] {
|
|
349
|
+
return routeKeys(this.routeFor(projectId), [...this.credentialed], this.hasModel);
|
|
350
|
+
}
|
|
351
|
+
private keyUsable(provider: string): boolean { return this.credentialed.has(provider) && !this.exhausted(provider); }
|
|
352
|
+
private keyCapacity(provider: string): number { return this.keyUsable(provider) && !this.resting(provider) ? this.lanesFor(provider) : 0; }
|
|
353
|
+
/** A key that answered a rate limit rests until its time is up; its lanes take no new work meanwhile. */
|
|
354
|
+
private resting(provider: string): boolean { return (this.restingUntil.get(provider) ?? 0) > Date.now(); }
|
|
355
|
+
/** Total automatic lanes across the usable keys of a project's route; the planner fills this many. */
|
|
356
|
+
autoLaneCountFor(projectId: string | null = null): number { return Math.max(1, this.keyChoices(projectId).reduce((n, k) => n + this.keyCapacity(k.provider), 0)); }
|
|
357
|
+
get autoLaneCount(): number { return this.autoLaneCountFor(null); }
|
|
358
|
+
/** How many automatic jobs each key carries now, across projects. */
|
|
359
|
+
private runningByKey(): Map<string, number> {
|
|
360
|
+
const running = new Map<string, number>();
|
|
361
|
+
for (const l of this.lanes.values()) for (const lane of [...l.auto, l.owner, ...l.delegated]) if (lane.running && lane.provider && !parkedLane(lane)) running.set(lane.provider, (running.get(lane.provider) ?? 0) + 1);
|
|
362
|
+
return running;
|
|
363
|
+
}
|
|
364
|
+
laneState(projectId: string | null = null): { lanesPerKey: number; total: number; keys: { provider: string; id: string; lanes: number; capacity: number; running: number; rateLimitedAt: string | null; exhaustedUntil: string | null; credentials: boolean }[] } {
|
|
365
|
+
const running = this.runningByKey();
|
|
366
|
+
const keys = this.keyChoices(projectId).map((k) => ({ provider: k.provider, id: k.id, lanes: this.lanesFor(k.provider), capacity: this.keyCapacity(k.provider), running: running.get(k.provider) ?? 0, rateLimitedAt: this.resting(k.provider) ? new Date(this.lastRateLimitAt.get(k.provider) ?? 0).toISOString() : null, exhaustedUntil: this.exhausted(k.provider) ? this.quotaExhausted.get(k.provider)!.until : null, credentials: this.credentialed.has(k.provider) }));
|
|
367
|
+
return { lanesPerKey: this.lanesPerKey, total: this.autoLaneCountFor(projectId), keys };
|
|
368
|
+
}
|
|
369
|
+
setLanesPerKey(n: number): number {
|
|
370
|
+
this.workspace.setSettings({ organizingLanesPerKey: Math.min(32, Math.max(1, Math.floor(n))) });
|
|
371
|
+
for (const p of this.workspace.list()) setImmediate(() => this.pump(p.id));
|
|
372
|
+
return this.lanesPerKey;
|
|
373
|
+
}
|
|
374
|
+
/** One key's own number of jobs at once; `null` gives it back to the setting for every key. */
|
|
375
|
+
setKeyLanes(provider: string, n: number | null): number {
|
|
376
|
+
const next: Record<string, number> = { ...(this.workspace.settings.keyLanes ?? {}) };
|
|
377
|
+
if (n === null) delete next[provider]; else next[provider] = Math.min(32, Math.max(1, Math.floor(n)));
|
|
378
|
+
this.workspace.setSettings({ keyLanes: next });
|
|
379
|
+
for (const p of this.workspace.list()) setImmediate(() => this.pump(p.id));
|
|
380
|
+
return this.lanesFor(provider);
|
|
381
|
+
}
|
|
382
|
+
private readonly resourceChanges = new Map<string, { at: string; jobId: string; detail: string }[]>();
|
|
383
|
+
private readonly lastError = new Map<string, { at: string; message: string; kind: ErrorKind }>();
|
|
384
|
+
/** Rate-limit rest: until when each key that answered 429 is left alone (§6.9: visible in the lane state, never silent). */
|
|
385
|
+
private readonly restingUntil = new Map<string, number>();
|
|
386
|
+
private readonly lastRateLimitAt = new Map<string, number>();
|
|
387
|
+
/** Providers with credentials, refreshed by providerState(); the synchronous pump reads it. */
|
|
388
|
+
private credentialed = new Set<string>();
|
|
389
|
+
/** The model providerState() resolved last (the chosen one, a stand-in, or the first available); automatic work uses it when no configured key is usable. */
|
|
390
|
+
private resolvedModel: { provider: string; id: string; thinking: string | null } | null = null;
|
|
391
|
+
private restWakeTimer: NodeJS.Timeout | null = null;
|
|
392
|
+
private fallback: ProviderFallback | null = null;
|
|
393
|
+
private readonly quotaExhausted = new Map<string, { until: string; message: string }>();
|
|
394
|
+
private readonly providerSwitches: { at: string; from: string; to: string; reason: string; until: string }[] = [];
|
|
395
|
+
private readonly quotaRetryTimers = new Map<string, NodeJS.Timeout>();
|
|
396
|
+
private readonly workspace: Workspace;
|
|
397
|
+
private readonly storeOf: (projectId: string) => ProjectStore;
|
|
398
|
+
private readonly projectOf: (projectId: string) => Project;
|
|
399
|
+
|
|
400
|
+
/** The owner's own keys (own-keys.ts): kept in ProjectKeeper's home, each run under a provider id of its own. */
|
|
401
|
+
readonly ownKeys: OwnKeyStore;
|
|
402
|
+
/** The last check of each key (key-check.ts), by the provider id it runs under. In memory: a restart checks again on `Check`. */
|
|
403
|
+
private readonly keyChecks = new Map<string, KeyCheck>();
|
|
404
|
+
|
|
405
|
+
constructor(workspace: Workspace, storeOf: (id: string) => ProjectStore, projectOf: (id: string) => Project) {
|
|
406
|
+
super();
|
|
407
|
+
// Every lane the main agent sends is waited for by a listener of its own (D99: five or more at once).
|
|
408
|
+
this.setMaxListeners(0);
|
|
409
|
+
this.workspace = workspace;
|
|
410
|
+
this.storeOf = storeOf;
|
|
411
|
+
this.projectOf = projectOf;
|
|
412
|
+
this.ownKeys = new OwnKeyStore(workspace.home);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async init(): Promise<void> {
|
|
416
|
+
this.modelRuntime = await ModelRuntime.create({ refreshOnCreate: false });
|
|
417
|
+
this.registerExtraKeys();
|
|
418
|
+
for (const k of this.ownKeys.list()) await this.registerOwnKey(k);
|
|
419
|
+
await this.providerState().catch(() => undefined);
|
|
420
|
+
// Jobs left Running by a previous process are not running now; jobs it left Queued still carry
|
|
421
|
+
// their prompt, so they start here — otherwise the planner waits forever on a job nobody runs.
|
|
422
|
+
// A model's job that is queued again later goes on in its own session (D99); a conversation keeps its own.
|
|
423
|
+
for (const p of this.workspace.list()) {
|
|
424
|
+
const store = this.storeOf(p.id);
|
|
425
|
+
for (const job of store.jobs.filter((j) => j.status === 'Running')) {
|
|
426
|
+
const at = new Date().toISOString();
|
|
427
|
+
const resume = continuable(job) ? { why: 'restart' as const, since: at } : job.resume ?? null;
|
|
428
|
+
store.jobs.put({ ...job, status: 'Stopped', endedAt: at, error: 'ProjectKeeper restarted while this work was running', resume });
|
|
429
|
+
}
|
|
430
|
+
stopReplacedRounds(store);
|
|
431
|
+
requeueWaitingForQuota(store);
|
|
432
|
+
clearErrorsOfFinishedWork(store);
|
|
433
|
+
linkPutOutDeliveries(store);
|
|
434
|
+
// D99: a breakpoint the program lit before a lane looked and the spot-check confirmed is a candidate again.
|
|
435
|
+
unlightUnchecked(store);
|
|
436
|
+
if (store.jobs.filter((j) => j.status === 'Queued').length > 0) setImmediate(() => this.pump(p.id));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** settings.extraKeys: each key is registered as a provider of its own — with the endpoint and models of the provider
|
|
441
|
+
* it is like, or, for a provider pi does not know, with the endpoint, API and models the key brings. A key that cannot
|
|
442
|
+
* be registered shows as a key without credentials in the lane state. */
|
|
443
|
+
private registerExtraKeys(): void {
|
|
444
|
+
for (const k of this.workspace.settings.extraKeys ?? []) {
|
|
445
|
+
if (this.models.getProvider(k.provider)) continue;
|
|
446
|
+
type ProviderModels = NonNullable<Parameters<ModelRuntime['registerProvider']>[1]['models']>;
|
|
447
|
+
const own = k.baseUrl && k.api && k.models?.length ? k : null;
|
|
448
|
+
const models: ProviderModels = own
|
|
449
|
+
? own.models!.map((m) => ({
|
|
450
|
+
id: m.id, name: m.name ?? m.id, api: own.api as ProviderModels[number]['api'], baseUrl: own.baseUrl!, reasoning: m.reasoning ?? false,
|
|
451
|
+
...(m.thinkingLevelMap ? { thinkingLevelMap: { ...m.thinkingLevelMap } as ProviderModels[number]['thinkingLevelMap'] } : {}),
|
|
452
|
+
input: [...(m.input ?? ['text'])], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, ...m.cost },
|
|
453
|
+
contextWindow: m.contextWindow, maxTokens: m.maxTokens,
|
|
454
|
+
...(m.compat ? { compat: { ...m.compat } as ProviderModels[number]['compat'] } : {}),
|
|
455
|
+
}))
|
|
456
|
+
: (k.like ? this.models.getModels(k.like) : []).map((m) => ({ id: m.id, name: m.name, api: m.api, baseUrl: m.baseUrl, reasoning: m.reasoning, thinkingLevelMap: m.thinkingLevelMap, input: [...m.input], cost: { ...m.cost }, contextWindow: m.contextWindow, maxTokens: m.maxTokens, ...(m.compat ? { compat: m.compat } : {}) }));
|
|
457
|
+
if (!models.length) continue;
|
|
458
|
+
try {
|
|
459
|
+
this.models.registerProvider(k.provider, {
|
|
460
|
+
name: k.name ?? `${k.like ?? k.provider} (${k.apiKeyEnv})`,
|
|
461
|
+
baseUrl: models[0]!.baseUrl,
|
|
462
|
+
api: models[0]!.api,
|
|
463
|
+
apiKey: '${' + k.apiKeyEnv + '}',
|
|
464
|
+
models,
|
|
465
|
+
});
|
|
466
|
+
} catch { /* shown as a key without credentials */ }
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** The models of a provider pi knows, as a provider of a key's own declares them. */
|
|
471
|
+
private modelsLike(provider: string): NonNullable<Parameters<ModelRuntime['registerProvider']>[1]['models']> {
|
|
472
|
+
return this.models.getModels(provider).map((m) => ({ id: m.id, name: m.name, api: m.api, baseUrl: m.baseUrl, reasoning: m.reasoning, thinkingLevelMap: m.thinkingLevelMap, input: [...m.input], cost: { ...m.cost }, contextWindow: m.contextWindow, maxTokens: m.maxTokens, ...(m.compat ? { compat: m.compat } : {}) }));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* One of the owner's own keys, as a provider of its own with the endpoint and models of the provider it is for. The
|
|
477
|
+
* value is handed to pi as a runtime key (`setRuntimeApiKey`): held in memory, never written to pi's auth file, never
|
|
478
|
+
* put into the environment the Keeper's shell commands inherit.
|
|
479
|
+
*/
|
|
480
|
+
private async registerOwnKey(k: OwnKey): Promise<boolean> {
|
|
481
|
+
const models = this.modelsLike(k.provider);
|
|
482
|
+
if (!models.length) return false;
|
|
483
|
+
try {
|
|
484
|
+
if (this.models.getProvider(k.id)) this.models.unregisterProvider(k.id);
|
|
485
|
+
this.models.registerProvider(k.id, { name: `${k.name} (${k.provider})`, baseUrl: models[0]!.baseUrl, api: models[0]!.api, models });
|
|
486
|
+
await this.models.setRuntimeApiKey(k.id, k.value);
|
|
487
|
+
} catch { return false; }
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** The provider a key runs for: an own key's provider, an extra key's `like`, else the key's own id. */
|
|
492
|
+
familyOf(keyId: string): string {
|
|
493
|
+
const own = this.ownKeys.get(keyId);
|
|
494
|
+
if (own) return own.provider;
|
|
495
|
+
const extra = (this.workspace.settings.extraKeys ?? []).find((k) => k.provider === keyId);
|
|
496
|
+
return extra?.like ?? keyId;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** Providers the owner can add a key for: those pi knows with models of their own (not the owner's own keys). */
|
|
500
|
+
keyProviders(): { id: string; name: string }[] {
|
|
501
|
+
const own = new Set(this.ownKeys.list().map((k) => k.id));
|
|
502
|
+
const extra = new Set((this.workspace.settings.extraKeys ?? []).map((k) => k.provider));
|
|
503
|
+
return this.models.getProviders().filter((p) => !own.has(p.id) && !extra.has(p.id) && this.models.getModels(p.id).length > 0)
|
|
504
|
+
.map((p) => ({ id: p.id, name: p.name ?? p.id })).sort((a, b) => a.id.localeCompare(b.id));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Save a new key, register it, and check it at once (§6.10 保存时检查一次). Only the masked key comes back. */
|
|
508
|
+
async addKey(provider: string, name: string | null, value: string): Promise<{ key: PublicOwnKey; check: KeyCheck }> {
|
|
509
|
+
if (!this.models.getModels(provider).length || this.ownKeys.get(provider)) throw new Error(`Unknown provider ${provider}`);
|
|
510
|
+
const key = this.ownKeys.add(provider, name, value);
|
|
511
|
+
await this.registerOwnKey(key);
|
|
512
|
+
await this.refreshCredentials();
|
|
513
|
+
const check = await this.checkKeyNow(key.id);
|
|
514
|
+
this.requeueForKeys();
|
|
515
|
+
return { key: publicKey(key), check };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** A new value (or name) for a saved key; checked again when the value changed. */
|
|
519
|
+
async replaceKey(id: string, value: string | null, name: string | null): Promise<{ key: PublicOwnKey; check: KeyCheck | null }> {
|
|
520
|
+
const key = this.ownKeys.replace(id, value, name);
|
|
521
|
+
if (value) { this.quotaExhausted.delete(id); this.restingUntil.delete(id); }
|
|
522
|
+
await this.registerOwnKey(key);
|
|
523
|
+
await this.refreshCredentials();
|
|
524
|
+
const check = value ? await this.checkKeyNow(id) : this.keyChecks.get(id) ?? null;
|
|
525
|
+
this.requeueForKeys();
|
|
526
|
+
return { key: publicKey(key), check };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Remove a saved key (§6.10 什么时候生效): the work it carries goes on as when a key's quota runs out — each running job
|
|
531
|
+
* stops where it is and is queued again, to go on in its own session on the next key of its route; with no other usable
|
|
532
|
+
* key it waits, saying why. Routes that name it keep the name and pass over it, so the owner sees what to change.
|
|
533
|
+
*/
|
|
534
|
+
async removeKey(id: string): Promise<{ removed: PublicOwnKey | null; rerouted: number }> {
|
|
535
|
+
const key = this.ownKeys.remove(id);
|
|
536
|
+
if (!key) return { removed: null, rerouted: 0 };
|
|
537
|
+
let rerouted = 0;
|
|
538
|
+
for (const l of this.lanes.values()) for (const lane of [...l.auto, l.owner, ...l.delegated]) {
|
|
539
|
+
if (lane.running && lane.provider === id && lane.abort) { lane.keyRemoved = key.name; lane.abort(); rerouted++; }
|
|
540
|
+
}
|
|
541
|
+
await this.models.removeRuntimeApiKey(id).catch(() => undefined);
|
|
542
|
+
try { this.models.unregisterProvider(id); } catch { /* already gone */ }
|
|
543
|
+
this.keyChecks.delete(id); this.quotaExhausted.delete(id); this.restingUntil.delete(id);
|
|
544
|
+
const lanes = { ...(this.workspace.settings.keyLanes ?? {}) };
|
|
545
|
+
if (id in lanes) { delete lanes[id]; this.workspace.setSettings({ keyLanes: lanes }); }
|
|
546
|
+
this.providerSwitches.push({ at: new Date().toISOString(), from: `${key.name} (${id})`, to: '(the next key of each route)', reason: `The key ${key.name} was removed${rerouted ? `; ${rerouted} running job${rerouted === 1 ? '' : 's'} move${rerouted === 1 ? 's' : ''} on` : ''}`, until: '' });
|
|
547
|
+
await this.refreshCredentials();
|
|
548
|
+
for (const p of this.workspace.list()) setImmediate(() => this.pump(p.id));
|
|
549
|
+
return { removed: publicKey(key), rerouted };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Work that waits for a key is queued again: a key was added, its value replaced, or a check found it usable. */
|
|
553
|
+
private requeueForKeys(): void {
|
|
554
|
+
for (const p of this.workspace.list()) {
|
|
555
|
+
const store = this.storeOf(p.id);
|
|
556
|
+
for (const j of store.jobs.filter((x) => x.status === 'Waiting for quota' && !isReplacedRound(x) && x.agent !== 'program')) store.jobs.put({ ...j, status: 'Queued', error: null }, { jobId: j.id, summary: 'Queued again: a key can be used' });
|
|
557
|
+
setImmediate(() => this.pump(p.id));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Change a project's route (§6.10; CKC-03 AC-29, AC-35): its main model, its backup order, a step's own model and
|
|
563
|
+
* thinking (`null` gives the step back to what it follows). The first change copies the route in force — the machine's
|
|
564
|
+
* settings — into the project, so the project goes on from what it ran on. It applies to the jobs that start after it.
|
|
565
|
+
*/
|
|
566
|
+
setRoute(projectId: string, patch: { readonly model?: RouteModelInput; readonly backups?: readonly RouteModelInput[]; readonly steps?: Readonly<Record<string, StepRouteInput | null>> }): Route {
|
|
567
|
+
const project = this.projectOf(projectId);
|
|
568
|
+
const current = this.routeFor(projectId);
|
|
569
|
+
const thinkingOk = (t: string | null | undefined) => t == null || t === '' || t === 'off' || (THINKING_LEVELS as readonly string[]).includes(t);
|
|
570
|
+
// A key the route already names keeps its place when the order is sent back, even while it is removed or not
|
|
571
|
+
// configured in this run (the page sends the whole order, its first row the main key): it is passed over at run time.
|
|
572
|
+
const named = (m: RouteModelInput) => [current.main, ...current.backups].some((x) => x && x.provider === m.provider && x.id === m.id);
|
|
573
|
+
const modelOf = (m: RouteModelInput, what: string): RouteModel => {
|
|
574
|
+
if (!this.hasModel(m.provider, m.id) && !named(m)) throw new Error(`${what}: the key ${m.provider} has no model ${m.id}`);
|
|
575
|
+
if (!thinkingOk(m.thinking)) throw new Error(`${what}: thinking must be off or one of ${THINKING_LEVELS.join(', ')}`);
|
|
576
|
+
return { provider: m.provider, id: m.id, thinking: m.thinking || null };
|
|
577
|
+
};
|
|
578
|
+
// With no main model set anywhere, the project goes on from the key and model it runs on now (the page shows that one).
|
|
579
|
+
const main = patch.model ? modelOf(patch.model, 'Main model') : current.main ?? this.resolvedModel;
|
|
580
|
+
if (!main) throw new Error('Choose a key and a model first');
|
|
581
|
+
const backups = patch.backups ? patch.backups.map((b, i) => modelOf(b, `Backup ${i + 1}`)).filter((b, i, all) => b.provider !== main.provider && all.findIndex((x) => x.provider === b.provider) === i) : current.backups;
|
|
582
|
+
const steps: Record<string, StepRoute> = { ...(current.steps as Record<string, StepRoute>) };
|
|
583
|
+
for (const [kind, value] of Object.entries(patch.steps ?? {})) {
|
|
584
|
+
if (!(ROUTE_STEP_KINDS as readonly string[]).includes(kind)) throw new Error(`${kind} is not a step of a round that takes a model; they are ${ROUTE_STEP_KINDS.join(', ')}`);
|
|
585
|
+
if (value === null) { delete steps[kind]; continue; }
|
|
586
|
+
if (!thinkingOk(value.thinking)) throw new Error(`${kind}: thinking must be off or one of ${THINKING_LEVELS.join(', ')}`);
|
|
587
|
+
if (value.model) {
|
|
588
|
+
const keys = [...this.credentialed].filter((k) => this.hasModel(k, value.model!) && (!value.provider || this.familyOf(k) === value.provider || k === value.provider));
|
|
589
|
+
if (!keys.length) throw new Error(`${kind}: no usable key carries ${value.provider ? `${value.provider}/` : ''}${value.model}`);
|
|
590
|
+
}
|
|
591
|
+
const next: StepRoute = { ...(value.model ? { model: value.model, ...(value.provider ? { provider: value.provider } : {}) } : {}), ...(value.thinking ? { thinking: value.thinking } : {}) };
|
|
592
|
+
if (next.model || next.thinking) steps[kind] = next; else delete steps[kind];
|
|
593
|
+
}
|
|
594
|
+
const route: ProjectRoute = { model: main, backups, steps, savedAt: new Date().toISOString() };
|
|
595
|
+
this.workspace.update({ ...project, route });
|
|
596
|
+
for (const p of this.workspace.list()) setImmediate(() => this.pump(p.id));
|
|
597
|
+
return this.routeFor(projectId);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Take every saved key's value out of a file the runtime's work wrote (a pi session record). */
|
|
601
|
+
private scrubKeys(file: string): void {
|
|
602
|
+
if (!this.ownKeys.list().length) return;
|
|
603
|
+
try {
|
|
604
|
+
const text = readFileSync(file, 'utf8');
|
|
605
|
+
const clean = this.ownKeys.redact(text);
|
|
606
|
+
if (clean !== text) writeFileSync(file, clean, 'utf8');
|
|
607
|
+
} catch { /* not there, or not readable: nothing written */ }
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Check a key now (key-check.ts: one request of one output token). */
|
|
611
|
+
async checkKeyNow(id: string): Promise<KeyCheck> {
|
|
612
|
+
const check = await checkKey(this.models, id, { family: this.familyOf(id), classify: classifyError, resetFrom: quotaResetFrom, redact: (t) => this.ownKeys.redact(t) });
|
|
613
|
+
this.keyChecks.set(id, check);
|
|
614
|
+
// A key the check finds usable again is tried again at once; one found out of quota or limited is left as a job would leave it.
|
|
615
|
+
if (check.status === 'Usable') { this.quotaExhausted.delete(id); this.restingUntil.delete(id); this.requeueForKeys(); }
|
|
616
|
+
return check;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** An own key whose provider pi did not know when it started (one an extension or a test registers later) is registered once it does. */
|
|
620
|
+
private async registerPendingOwnKeys(): Promise<void> {
|
|
621
|
+
for (const k of this.ownKeys.list()) if (!this.models.getProvider(k.id) && this.models.getModels(k.provider).length) await this.registerOwnKey(k);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private async refreshCredentials(): Promise<void> {
|
|
625
|
+
await this.registerPendingOwnKeys();
|
|
626
|
+
const available = await this.models.getAvailable().catch(() => [] as readonly { provider: string }[]);
|
|
627
|
+
this.credentialed = new Set(available.map((m) => m.provider));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Every key this machine has (§6.10 "owner 自己的 key"; CKC-03 AC-33, AC-34): the owner's own, then those configured
|
|
632
|
+
* outside the product — an environment variable, pi's own login, an extra key of the settings — each saying where it
|
|
633
|
+
* comes from. Each with its state (the last check, or what the work found since: out of quota, rate-limited), how many
|
|
634
|
+
* jobs it carries now and how many it may run at once. No value, ever.
|
|
635
|
+
*/
|
|
636
|
+
async keysView(): Promise<KeyView[]> {
|
|
637
|
+
await this.refreshCredentials();
|
|
638
|
+
const running = this.runningByKey();
|
|
639
|
+
const extra = new Map((this.workspace.settings.extraKeys ?? []).map((k) => [k.provider, k] as const));
|
|
640
|
+
const own = this.ownKeys.list();
|
|
641
|
+
const ownIds = new Set(own.map((k) => k.id));
|
|
642
|
+
const out: KeyView[] = [];
|
|
643
|
+
const state = (id: string): KeyView['state'] => {
|
|
644
|
+
const check = this.keyChecks.get(id) ?? null;
|
|
645
|
+
const ex = this.exhausted(id) ? this.quotaExhausted.get(id)! : null;
|
|
646
|
+
const quota = check?.quotaReported ? check.quota : null;
|
|
647
|
+
const reports = Boolean(check?.quotaReported) || this.familyOf(id) in QUOTA_REPORTERS;
|
|
648
|
+
if (!this.credentialed.has(id)) return { status: 'No credentials', reason: 'No key value is configured for it in this run.', until: null, checkedAt: check?.at ?? null, how: check?.how ?? null, quota, quotaReported: reports };
|
|
649
|
+
if (ex) return { status: 'Out of quota', reason: this.ownKeys.redact(ex.message).slice(0, 300), until: ex.until, checkedAt: check?.at ?? null, how: check?.how ?? null, quota, quotaReported: reports };
|
|
650
|
+
if (this.resting(id)) return { status: 'Rate-limited', reason: `Rate-limited at ${new Date(this.lastRateLimitAt.get(id) ?? 0).toISOString()}; tried again after ${new Date(this.restingUntil.get(id)!).toISOString()}`, until: new Date(this.restingUntil.get(id)!).toISOString(), checkedAt: check?.at ?? null, how: check?.how ?? null, quota, quotaReported: reports };
|
|
651
|
+
if (check) return { status: check.status, reason: check.reason, until: check.until, checkedAt: check.at, how: check.how, quota, quotaReported: reports };
|
|
652
|
+
return { status: 'Usable', reason: 'Not checked in this run; press Check to ask the provider.', until: null, checkedAt: null, how: null, quota: null, quotaReported: reports };
|
|
653
|
+
};
|
|
654
|
+
const row = (id: string, base: Omit<KeyView, 'state' | 'running' | 'lanes' | 'ownLanes' | 'models'>): KeyView => ({
|
|
655
|
+
...base, state: state(id), running: running.get(id) ?? 0, lanes: this.lanesFor(id), ownLanes: this.workspace.settings.keyLanes?.[id] !== undefined,
|
|
656
|
+
models: this.models.getModels(id).map((m) => ({ id: m.id, priced: m.cost.input > 0 || m.cost.output > 0 })),
|
|
657
|
+
});
|
|
658
|
+
const providerName = (p: string) => this.models.getProvider(p)?.name ?? p;
|
|
659
|
+
for (const k of own) out.push(row(k.id, { id: k.id, name: k.name, provider: k.provider, providerName: providerName(k.provider), from: 'ProjectKeeper', fromDetail: 'Saved here, on this machine', mask: publicKey(k).mask, removable: true, addedAt: k.addedAt, replacedAt: k.replacedAt }));
|
|
660
|
+
for (const p of this.models.getProviders()) {
|
|
661
|
+
if (ownIds.has(p.id) || !this.models.getModels(p.id).length) continue;
|
|
662
|
+
const ex = extra.get(p.id);
|
|
663
|
+
const check = await this.models.checkAuth(p.id).catch(() => undefined);
|
|
664
|
+
const status = this.models.getProviderAuthStatus(p.id);
|
|
665
|
+
if (!ex && !check && !status.configured) continue;
|
|
666
|
+
const fromDetail = ex ? `Environment variable ${ex.apiKeyEnv} (an extra key in ProjectKeeper's settings, for ${ex.like ?? p.id})`
|
|
667
|
+
: status.source === 'environment' ? `Environment variable ${status.label ?? check?.source ?? ''}`.trim()
|
|
668
|
+
: status.source === 'stored' ? 'pi’s own login (pi’s credential file)'
|
|
669
|
+
: status.source === 'runtime' ? 'Set for this run only'
|
|
670
|
+
: status.source === 'models_json_key' || status.source === 'models_json_command' ? 'pi’s models.json'
|
|
671
|
+
: check?.source ? `${check.source}` : 'Configured outside ProjectKeeper';
|
|
672
|
+
out.push(row(p.id, { id: p.id, name: ex?.name ?? p.name ?? p.id, provider: ex?.like ?? p.id, providerName: providerName(ex?.like ?? p.id), from: ex ? 'Settings' : status.source === 'stored' ? 'pi login' : status.source === 'environment' ? 'Environment' : 'Outside ProjectKeeper', fromDetail, mask: null, removable: false, addedAt: null, replacedAt: null }));
|
|
673
|
+
}
|
|
674
|
+
return out;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
get models(): ModelRuntime { if (!this.modelRuntime) throw new Error('Keeper runtime not initialised'); return this.modelRuntime; }
|
|
678
|
+
get ready(): boolean { return this.modelRuntime !== null; }
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The model the owner's work runs on and what is available. With a project, its route (route.ts); without, the
|
|
682
|
+
* machine's settings. When the main key cannot be used — out of quota, without credentials, removed — the first usable
|
|
683
|
+
* key of the route stands in, shown as a fallback, never silently.
|
|
684
|
+
*/
|
|
685
|
+
async providerState(projectId: string | null = null): Promise<ProviderState> {
|
|
686
|
+
await this.registerPendingOwnKeys();
|
|
687
|
+
const available = (await this.models.getAvailable()).map((m) => ({ provider: m.provider, id: m.id, name: m.name ?? m.id }));
|
|
688
|
+
this.credentialed = new Set(available.map((m) => m.provider));
|
|
689
|
+
const route = this.routeFor(projectId);
|
|
690
|
+
const primary = route.main;
|
|
691
|
+
// Owner work and the display use the chosen model; when its key cannot be used, the first usable key stands in (visible as a fallback).
|
|
692
|
+
const standIn = this.keyChoices(projectId).find((k) => this.keyUsable(k.provider));
|
|
693
|
+
const chosen = primary && this.keyUsable(primary.provider) ? primary : standIn ?? primary;
|
|
694
|
+
const ex = primary ? this.quotaExhausted.get(primary.provider) : undefined;
|
|
695
|
+
const standing = Boolean(chosen && primary && chosen.provider !== primary.provider);
|
|
696
|
+
const why = ex ? ex.message.slice(0, 200) : primary && !this.credentialed.has(primary.provider) ? `The key ${primary.provider} has no credentials (removed, or not configured in this run)` : '';
|
|
697
|
+
let fallback: ProviderFallback | null = null;
|
|
698
|
+
if (!route.own) {
|
|
699
|
+
// The machine's route keeps its record of switches across calls.
|
|
700
|
+
if (this.fallback && !(standing && ex)) {
|
|
701
|
+
this.providerSwitches.push({ at: new Date().toISOString(), from: `${this.fallback.provider}/${this.fallback.id}`, to: primary ? `${primary.provider}/${primary.id}` : '', reason: 'The chosen provider is tried again after its quota rest; back to it', until: '' });
|
|
702
|
+
this.fallback = null;
|
|
703
|
+
}
|
|
704
|
+
if (standing && ex) this.fallback = { provider: chosen!.provider, id: chosen!.id, from: { provider: primary!.provider, id: primary!.id }, reason: why, since: this.fallback?.since ?? new Date().toISOString(), until: ex.until };
|
|
705
|
+
fallback = this.fallback ?? (standing ? { provider: chosen!.provider, id: chosen!.id, from: { provider: primary!.provider, id: primary!.id }, reason: why, since: new Date().toISOString(), until: '' } : null);
|
|
706
|
+
} else if (standing) {
|
|
707
|
+
fallback = { provider: chosen!.provider, id: chosen!.id, from: { provider: primary!.provider, id: primary!.id }, reason: why, since: new Date().toISOString(), until: ex?.until ?? '' };
|
|
708
|
+
}
|
|
709
|
+
const model = chosen && available.some((m) => m.provider === chosen.provider && m.id === chosen.id) ? { provider: chosen.provider, id: chosen.id, thinking: chosen.thinking ?? null }
|
|
710
|
+
: available[0] ? { provider: available[0].provider, id: available[0].id, thinking: chosen?.thinking ?? null } : null;
|
|
711
|
+
const extra = { backups: route.backups, fallback, switches: this.providerSwitches.slice(-10) };
|
|
712
|
+
if (!projectId || !route.own) this.resolvedModel = model;
|
|
713
|
+
if (!model) return { connected: false, reason: 'No usable key: add a key in Keeper → Model provider, or run `pi` in a terminal and use /login.', model: null, available, ...extra };
|
|
714
|
+
return { connected: true, reason: null, model, available, ...extra };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private exhausted(provider: string): boolean {
|
|
718
|
+
const e = this.quotaExhausted.get(provider);
|
|
719
|
+
return e !== undefined && Date.parse(e.until) > Date.now();
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* A key's quota is exhausted: mark it until its reset time (an hour when none is given) but no longer than
|
|
724
|
+
* RATE_LIMIT_REST_MS, record the switch, and say whether other keys remain. The owner can restore a key's quota by
|
|
725
|
+
* hand, and a key used by the owner's own agents comes back sooner than its window: it is tried again after the rest,
|
|
726
|
+
* and a key still out of quota says so again (owner 2026-09-28: 「试了可以用就用,限流了过10分钟再试一下」).
|
|
727
|
+
*/
|
|
728
|
+
private async onQuotaExhausted(projectId: string, provider: string | null, message: string): Promise<boolean> {
|
|
729
|
+
if (!provider) return false;
|
|
730
|
+
const reset = quotaResetFrom(message, this.familyOf(provider)) ?? new Date(Date.now() + 60 * 60_000).toISOString();
|
|
731
|
+
const until = new Date(Math.min(Date.parse(reset), Date.now() + RATE_LIMIT_REST_MS)).toISOString();
|
|
732
|
+
// Several lanes on one key hit its limit at once; the switch is recorded once.
|
|
733
|
+
const already = this.exhausted(provider);
|
|
734
|
+
this.quotaExhausted.set(provider, { until, message });
|
|
735
|
+
await this.providerState(projectId).catch(() => undefined);
|
|
736
|
+
const others = this.keyChoices(projectId).filter((k) => k.provider !== provider && this.keyUsable(k.provider));
|
|
737
|
+
if (!already) this.providerSwitches.push({ at: new Date().toISOString(), from: provider, to: others.length ? others.map((k) => `${k.provider}/${k.id}`).join(', ') : '(no usable key left)', reason: message.slice(0, 200), until });
|
|
738
|
+
return others.length > 0;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Every key with credentials is inside its quota window: automatic work waits for the first one to come back, then the
|
|
742
|
+
* work that was waiting for quota is queued again (owner 2026-09-18: when a key reaches its five-hour limit, move to
|
|
743
|
+
* the next; keys become usable again later). */
|
|
744
|
+
private keyReturnTimer: NodeJS.Timeout | null = null;
|
|
745
|
+
private wakeWhenKeyReturns(at: number): void {
|
|
746
|
+
if (this.keyReturnTimer) return;
|
|
747
|
+
const timer = setTimeout(() => {
|
|
748
|
+
this.keyReturnTimer = null;
|
|
749
|
+
void this.providerState().catch(() => undefined).then(() => {
|
|
750
|
+
for (const p of this.workspace.list()) {
|
|
751
|
+
const store = this.storeOf(p.id);
|
|
752
|
+
for (const job of store.jobs.filter((j) => j.status === 'Waiting for quota')) store.jobs.put({ ...job, status: 'Queued', error: null }, { jobId: job.id, summary: 'Requeued: a key is past its quota window' });
|
|
753
|
+
this.pump(p.id);
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}, Math.max(1_000, at - Date.now() + 1_000));
|
|
757
|
+
timer.unref?.();
|
|
758
|
+
this.keyReturnTimer = timer;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private requeueWaiting(projectId: string): void {
|
|
762
|
+
const store = this.storeOf(projectId);
|
|
763
|
+
for (const j of store.jobs.filter((x) => x.status === 'Waiting for quota')) store.jobs.put({ ...j, status: 'Queued', error: null }, { jobId: j.id, summary: 'Requeued on the remaining keys' });
|
|
764
|
+
this.emitEvent({ projectId, jobId: '', kind: 'job', data: { status: 'provider-switched', to: this.fallback ? `${this.fallback.provider}/${this.fallback.id}` : null } });
|
|
765
|
+
setImmediate(() => this.pump(projectId));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** pi's own default (settings.json defaultProvider/defaultModel), so the workbench and a plain `pi` start on the same model until the owner picks one here. */
|
|
769
|
+
private piDefault(): { provider: string; id: string; thinking: string | null } | null {
|
|
770
|
+
try {
|
|
771
|
+
const s = SettingsManager.create(process.cwd(), getAgentDir());
|
|
772
|
+
const provider = s.getDefaultProvider();
|
|
773
|
+
const id = s.getDefaultModel();
|
|
774
|
+
return provider && id ? { provider, id, thinking: null } : null;
|
|
775
|
+
} catch { return null; }
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
setModel(choice: { provider: string; id: string; thinking: string | null }, backups?: readonly { provider: string; id: string; thinking: string | null }[]): void {
|
|
779
|
+
this.workspace.setSettings({ model: choice, ...(backups ? { modelBackups: backups } : {}) });
|
|
780
|
+
this.fallback = null; // the owner's explicit choice ends any backup in use
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** §6.9 overall status for one project. */
|
|
784
|
+
async status(projectId: string): Promise<{ status: KeeperStatus; reason: string | null; detail: string | null }> {
|
|
785
|
+
const provider = await this.providerState(projectId);
|
|
786
|
+
if (!provider.connected) return { status: 'Not connected', reason: provider.reason, detail: null };
|
|
787
|
+
const lanes = this.lane(projectId);
|
|
788
|
+
const running = [lanes.owner.running, ...lanes.auto.map((l) => l.running), ...lanes.delegated.map((l) => l.running)].filter((j): j is KeeperJob => j !== null);
|
|
789
|
+
const err = this.lastError.get(projectId);
|
|
790
|
+
// §6.9: `Working on your request` is the delegated or authorized work only; answering a question is `Working`.
|
|
791
|
+
const request = running.find((j) => j.kind === 'Your request');
|
|
792
|
+
if (request) return { status: 'Working on your request', reason: request.requestBasis?.label ?? request.kind, detail: request.scope.label };
|
|
793
|
+
if (running.length) return { status: 'Working', reason: null, detail: running.map((j) => `${j.kind}: ${j.scope.label}`).join(' · ') };
|
|
794
|
+
const store = this.storeOf(projectId);
|
|
795
|
+
if (store.jobs.find((j) => j.status === 'Waiting for quota')) return { status: 'Waiting for quota', reason: err?.message ?? null, detail: 'Queued work resumes when quota is back' };
|
|
796
|
+
if (err && err.kind === 'unavailable' && Date.now() - Date.parse(err.at) < 10 * 60_000) return { status: 'Unavailable', reason: err.message, detail: null };
|
|
797
|
+
if (organizingHeld(this.projectOf(projectId))) return { status: 'Organizing paused', reason: null, detail: null };
|
|
798
|
+
return { status: 'Idle', reason: null, detail: null };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
private lane(projectId: string): ProjectLanes {
|
|
802
|
+
let l = this.lanes.get(projectId);
|
|
803
|
+
if (!l) { l = { owner: newLane(), auto: [], delegated: [] }; this.lanes.set(projectId, l); }
|
|
804
|
+
while (l.auto.length < this.autoLaneCountFor(projectId)) l.auto.push(newLane());
|
|
805
|
+
return l;
|
|
806
|
+
}
|
|
807
|
+
private allLanes(projectId: string): Lane[] {
|
|
808
|
+
const l = this.lane(projectId);
|
|
809
|
+
return [l.owner, ...l.auto, ...l.delegated];
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** Queue a job (§1.13). Returns it immediately; it runs when its lane is free. */
|
|
813
|
+
enqueue(projectId: string, request: JobRequest): KeeperJob {
|
|
814
|
+
const store = this.storeOf(projectId);
|
|
815
|
+
const paused = request.initiator === 'auto' && !request.bypassHold && organizingHeld(this.projectOf(projectId));
|
|
816
|
+
const job: KeeperJob = {
|
|
817
|
+
id: newId('job'), projectId, kind: request.kind, initiator: request.initiator, scope: request.scope,
|
|
818
|
+
status: paused ? 'Paused' : 'Queued', queuedAt: new Date().toISOString(), startedAt: null, endedAt: null, savedResults: [], usage: zeroUsage(),
|
|
819
|
+
agent: 'pi', model: null, sessionFile: null, sessionId: null, steps: [], error: null, requestBasis: request.requestBasis ?? null,
|
|
820
|
+
parentJobId: request.parentJobId ?? null, resultText: null, step: request.step ?? null,
|
|
821
|
+
priority: request.priority ?? (request.initiator === 'owner' ? 0 : request.kind === 'Product re-look' ? 2 : 3),
|
|
822
|
+
task: { prompt: request.prompt, stream: request.stream === true, timeoutMs: request.timeoutMs ?? null, extra: request.task ?? null, hasExtraTools: (request.extraTools?.length ?? 0) > 0, materials: request.materials ?? [], judgementId: request.judgementId ?? null, sessionKey: request.sessionKey ?? null, ownerSourceId: request.ownerSourceId ?? null, conversation: request.conversation === true },
|
|
823
|
+
};
|
|
824
|
+
store.jobs.put(job, { jobId: job.id, summary: `${job.kind} queued: ${job.scope.label}` });
|
|
825
|
+
if (request.extraTools?.length) this.extraTools.set(job.id, request.extraTools);
|
|
826
|
+
if (request.bypassHold) this.bypassing.add(job.id);
|
|
827
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'job', data: { status: job.status } });
|
|
828
|
+
setImmediate(() => this.pump(projectId));
|
|
829
|
+
return job;
|
|
830
|
+
}
|
|
831
|
+
private readonly extraTools = new Map<string, readonly ToolDefinition[]>();
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Record a job the program does itself — a round's ledger step, a step with nothing to do, a round's root — so the
|
|
835
|
+
* Keeper view shows it in the round's tree with its time (Spec §3.10, CKC-23 AC-1). It is written as Running and never
|
|
836
|
+
* queued, so no lane picks it up.
|
|
837
|
+
*/
|
|
838
|
+
recordProgramJob(projectId: string, request: ProgramJobRequest): KeeperJob {
|
|
839
|
+
const store = this.storeOf(projectId);
|
|
840
|
+
const now = new Date().toISOString();
|
|
841
|
+
const job: KeeperJob = {
|
|
842
|
+
id: newId('job'), projectId, kind: request.kind, initiator: 'auto', scope: request.scope, status: 'Running', queuedAt: now, startedAt: now,
|
|
843
|
+
endedAt: null, savedResults: [], usage: zeroUsage(), agent: 'program', model: null, sessionFile: null, sessionId: null, steps: [], error: null,
|
|
844
|
+
requestBasis: null, parentJobId: request.parentJobId, resultText: null, priority: 1, task: { prompt: '', stream: false, timeoutMs: null, extra: request.task ?? null }, step: request.step ?? null,
|
|
845
|
+
};
|
|
846
|
+
store.jobs.put(job, { jobId: job.id, summary: `${job.scope.label} started` });
|
|
847
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'job', data: { status: job.status } });
|
|
848
|
+
return job;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The kind of clerk-method round a step belongs to (its tools differ in a Follow up, Spec §3.8). */
|
|
852
|
+
private clerkRoundKind(store: ProjectStore, job: KeeperJob): 'First usable' | 'Deepen' | 'Follow up' {
|
|
853
|
+
return (job.step ? store.clerkRounds.get(job.step.roundId)?.kind : undefined) ?? 'First usable';
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** End a job the program did itself. */
|
|
857
|
+
endProgramJob(projectId: string, jobId: string, status: 'Done' | 'Failed' | 'Stopped', patch: Partial<Pick<KeeperJob, 'resultText' | 'error' | 'timing'>> = {}): void {
|
|
858
|
+
const store = this.storeOf(projectId);
|
|
859
|
+
const job = store.jobs.get(jobId);
|
|
860
|
+
if (!job) return;
|
|
861
|
+
const endedAt = new Date().toISOString();
|
|
862
|
+
store.jobs.put({ ...job, ...patch, status, endedAt }, { jobId, summary: `${job.scope.label} ${status}` });
|
|
863
|
+
this.emitEvent({ projectId, jobId, kind: 'done', data: { status, resultText: patch.resultText ?? null, kind: job.kind } });
|
|
864
|
+
}
|
|
865
|
+
/** Jobs that start although automatic organizing is held: subagents of a job that was already running. In memory
|
|
866
|
+
* only — after a restart their parents are not running either, so nothing is waiting for them. */
|
|
867
|
+
private readonly bypassing = new Set<string>();
|
|
868
|
+
/** Live conversation sessions by key: the pi session and the tool context its tools read. */
|
|
869
|
+
private readonly live = new Map<string, { session: AgentSession; ctx: ToolContext; boundary: ReadBoundary; turns: number }>();
|
|
870
|
+
readonly branchingSupported = true;
|
|
871
|
+
/** Hooks the services attach (the runtime does not know the organizing service). */
|
|
872
|
+
readonly hooks: {
|
|
873
|
+
requestRelook?: (projectId: string, scope: { kind: 'project' | 'area' | 'thread'; id: string | null }, reason: string) => void;
|
|
874
|
+
/** The project's ledger (§1.16), for the evidence the clerk method's writers cite by ledger id. */
|
|
875
|
+
ledgerFor?: (projectId: string) => LedgerHook | null;
|
|
876
|
+
/** D99: the main agent entered a stage (`pk_stage`): the planner recomputes the breakpoint candidates and gives the
|
|
877
|
+
* program's lists for that stage (clerk.ts `stageEntered`). */
|
|
878
|
+
stageEntered?: (projectId: string, roundId: string, stage: ClerkStage) => Promise<{ readonly note: string | null }> | { readonly note: string | null };
|
|
879
|
+
/** D107: the writer a table follow-up's session hands its table to (organize/table-follow-up.ts). */
|
|
880
|
+
tableWriter?: (projectId: string, jobId: string) => TableWriter;
|
|
881
|
+
} = {};
|
|
882
|
+
|
|
883
|
+
hasLiveSession(key: string): boolean { return this.live.has(key); }
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* The model and current context of one Keeper conversation. This is deliberately separate from a job's `usage`:
|
|
887
|
+
* job usage is cumulative billing across every request in that job, while context usage is the active branch that
|
|
888
|
+
* would be sent on the next request. Reading this method never creates a session and never calls a model provider.
|
|
889
|
+
*/
|
|
890
|
+
chatTelemetry(key: string | null, saved: SavedChatSession | null = null): ChatTelemetry {
|
|
891
|
+
const defaultRef = (): { provider: string; id: string } | null => {
|
|
892
|
+
if (this.fallback) return { provider: this.fallback.provider, id: this.fallback.id };
|
|
893
|
+
return this.workspace.settings.model ?? this.resolvedModel ?? this.piDefault();
|
|
894
|
+
};
|
|
895
|
+
const describe = (ref: { provider: string; id: string; name?: string; contextWindow?: number } | null) => {
|
|
896
|
+
if (!ref) return { model: null, limit: null };
|
|
897
|
+
let configured: ReturnType<ModelRuntime['getModel']>;
|
|
898
|
+
try { configured = this.models.getModel(ref.provider, ref.id); } catch { configured = undefined; }
|
|
899
|
+
const explicitLimit = Number(ref.contextWindow);
|
|
900
|
+
const configuredLimit = Number(configured?.contextWindow);
|
|
901
|
+
const limit = Number.isFinite(explicitLimit) && explicitLimit > 0 ? explicitLimit
|
|
902
|
+
: Number.isFinite(configuredLimit) && configuredLimit > 0 ? configuredLimit : null;
|
|
903
|
+
return {
|
|
904
|
+
model: { provider: ref.provider, id: ref.id, name: ref.name?.trim() || configured?.name || ref.id },
|
|
905
|
+
limit,
|
|
906
|
+
};
|
|
907
|
+
};
|
|
908
|
+
const context = (source: ChatTelemetry['context']['source'], usedTokens: number | null, limitTokens: number | null, percent: number | null, estimated: boolean): ChatTelemetry['context'] => ({
|
|
909
|
+
usedTokens: usedTokens !== null && Number.isFinite(usedTokens) && usedTokens > 0 ? usedTokens : null,
|
|
910
|
+
limitTokens: limitTokens !== null && Number.isFinite(limitTokens) && limitTokens > 0 ? limitTokens : null,
|
|
911
|
+
percent: percent !== null && Number.isFinite(percent) && usedTokens !== null && usedTokens > 0 ? percent : null,
|
|
912
|
+
estimated: usedTokens !== null && usedTokens > 0 && estimated,
|
|
913
|
+
source,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const live = key ? this.live.get(key) : undefined;
|
|
917
|
+
if (live) {
|
|
918
|
+
const active = live.session.model;
|
|
919
|
+
const described = describe(active ? { provider: active.provider, id: active.id, name: active.name, contextWindow: active.contextWindow } : saved?.model ?? defaultRef());
|
|
920
|
+
try {
|
|
921
|
+
const usage = live.session.getContextUsage();
|
|
922
|
+
// A just-created or failed-before-response session can calculate zero. That is no observation, not "0 used".
|
|
923
|
+
const hasExactResponse = live.session.messages.some((message) => message.role === 'assistant'
|
|
924
|
+
&& message.stopReason !== 'aborted' && message.stopReason !== 'error'
|
|
925
|
+
&& calculateContextTokens(message.usage) > 0);
|
|
926
|
+
const used = hasExactResponse && usage?.tokens !== null && usage?.tokens !== undefined && usage.tokens > 0 ? usage.tokens : null;
|
|
927
|
+
const estimate = used === null ? null : estimateContextTokens(live.session.messages);
|
|
928
|
+
const limit = usage && usage.contextWindow > 0 ? usage.contextWindow : described.limit;
|
|
929
|
+
const percent = used === null ? null : usage?.percent ?? (limit ? (used / limit) * 100 : null);
|
|
930
|
+
return { model: described.model, context: context('live', used, limit, percent, (estimate?.trailingTokens ?? 0) > 0) };
|
|
931
|
+
} catch {
|
|
932
|
+
return { model: described.model, context: context('live', null, described.limit, null, false) };
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const fallbackModel = describe(saved?.model ?? (saved ? null : defaultRef()));
|
|
937
|
+
if (!saved?.sessionFile) return { model: fallbackModel.model, context: context('unavailable', null, fallbackModel.limit, null, false) };
|
|
938
|
+
|
|
939
|
+
try {
|
|
940
|
+
// Never open a persisted manager for telemetry: pi repairs empty/legacy files when opening them. Parse bytes,
|
|
941
|
+
// require a real header, then let a non-persisting manager migrate/index only the in-memory objects.
|
|
942
|
+
const entries = parseSessionEntries(readFileSync(saved.sessionFile, 'utf8'));
|
|
943
|
+
const header = entries[0];
|
|
944
|
+
if (!header || header.type !== 'session' || typeof header.id !== 'string' || typeof header.cwd !== 'string') throw new Error('Invalid saved session');
|
|
945
|
+
const manager = SessionManager.inMemory(header.cwd, undefined, entries);
|
|
946
|
+
const leafId = saved.leafId ?? manager.getLeafId();
|
|
947
|
+
if (saved.leafId && !manager.getEntry(saved.leafId)) throw new Error('The recorded branch leaf is absent from the saved session');
|
|
948
|
+
const branch = manager.getBranch(leafId ?? undefined);
|
|
949
|
+
const projected = buildSessionProjection(manager.getEntries(), leafId);
|
|
950
|
+
const lastModelChange = branch.findLastIndex((entry) => entry.type === 'model_change');
|
|
951
|
+
const assistantEntry = projected.entries.findLast((entry) => entry.messages.some((message) => message.role === 'assistant'));
|
|
952
|
+
const lastAssistant = assistantEntry ? branch.findIndex((entry) => entry.id === assistantEntry.sourceEntry.id) : -1;
|
|
953
|
+
const assistant = assistantEntry?.messages.findLast((message) => message.role === 'assistant');
|
|
954
|
+
// The response names the provider that actually answered. A later explicit model change names the model that
|
|
955
|
+
// would answer next and therefore wins when it is newer than that response.
|
|
956
|
+
const branchModel = lastModelChange > lastAssistant && projected.model
|
|
957
|
+
? { provider: projected.model.provider, id: projected.model.modelId }
|
|
958
|
+
: assistant ? { provider: assistant.provider, id: assistant.model }
|
|
959
|
+
: projected.model ? { provider: projected.model.provider, id: projected.model.modelId } : saved.model;
|
|
960
|
+
const described = describe(branchModel);
|
|
961
|
+
|
|
962
|
+
const compact = getLatestCompactionEntry(branch);
|
|
963
|
+
const boundary = compact ? branch.lastIndexOf(compact) : -1;
|
|
964
|
+
const projectedResponses = new Set(projected.entries.filter((entry) => entry.messages.some((message) => message.role === 'assistant'
|
|
965
|
+
&& message.stopReason !== 'aborted' && message.stopReason !== 'error' && calculateContextTokens(message.usage) > 0)).map((entry) => entry.sourceEntry.id));
|
|
966
|
+
const exactResponse = branch.slice(boundary + 1).some((entry) => projectedResponses.has(entry.id));
|
|
967
|
+
// Match pi's compaction boundary: until a successful response follows the summary, the token count is unknown.
|
|
968
|
+
if (!exactResponse) return { model: described.model, context: context('saved', null, described.limit, null, false) };
|
|
969
|
+
const estimate = estimateContextTokens(projected.messages);
|
|
970
|
+
let usageEntryId: string | undefined;
|
|
971
|
+
let messageIndex = 0;
|
|
972
|
+
for (const entry of projected.entries) {
|
|
973
|
+
messageIndex += entry.messages.length;
|
|
974
|
+
if (estimate.lastUsageIndex !== null && estimate.lastUsageIndex < messageIndex) { usageEntryId = entry.sourceEntry.id; break; }
|
|
975
|
+
}
|
|
976
|
+
const usageEntryIndex = usageEntryId ? branch.findIndex((entry) => entry.id === usageEntryId) : -1;
|
|
977
|
+
const invalidatingIndex = branch.findLastIndex((entry) => entry.type === 'context_edit' || entry.type === 'compaction');
|
|
978
|
+
// A context edit after that response invalidates its reported token count. Match pi's projected-context estimate.
|
|
979
|
+
const currentSystem = getCurrentSystemMessage(projected.messages);
|
|
980
|
+
const estimatedTokens = (currentSystem ? estimateTokens(currentSystem) : 0)
|
|
981
|
+
+ projected.messages.filter((message) => message.role !== 'system').reduce((n, message) => n + estimateTokens(message), 0);
|
|
982
|
+
const useEstimate = usageEntryIndex <= invalidatingIndex;
|
|
983
|
+
const used = (useEstimate ? estimatedTokens : estimate.tokens) || null;
|
|
984
|
+
const percent = used !== null && described.limit ? (used / described.limit) * 100 : null;
|
|
985
|
+
return { model: described.model, context: context('saved', used, described.limit, percent, useEstimate || estimate.trailingTokens > 0) };
|
|
986
|
+
} catch {
|
|
987
|
+
return { model: fallbackModel.model, context: context('unavailable', null, fallbackModel.limit, null, false) };
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Move the live session's leaf back to a recorded point; the next turn continues from there (pi keeps the tree). */
|
|
992
|
+
async branchSession(key: string, leafId: string): Promise<{ ok: boolean; reason: string | null }> {
|
|
993
|
+
const entry = this.live.get(key);
|
|
994
|
+
if (!entry) return { ok: false, reason: 'The session is not live in this run; send a message to continue it first.' };
|
|
995
|
+
if (entry.session.isStreaming) return { ok: false, reason: 'The Keeper is answering; stop or wait first.' };
|
|
996
|
+
try {
|
|
997
|
+
const r = await entry.session.navigateTree(leafId, { summarize: false });
|
|
998
|
+
return r.cancelled ? { ok: false, reason: 'Branching was cancelled.' } : { ok: true, reason: null };
|
|
999
|
+
} catch (e) { return { ok: false, reason: (e as Error).message }; }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
private readonly pumping = new Set<string>();
|
|
1003
|
+
private readonly pumpAgain = new Set<string>();
|
|
1004
|
+
/** Refresh which keys have credentials, then schedule; pumps for one project run one at a time so a job is never started twice. */
|
|
1005
|
+
private pump(projectId: string): void {
|
|
1006
|
+
if (this.pumping.has(projectId)) { this.pumpAgain.add(projectId); return; }
|
|
1007
|
+
this.pumping.add(projectId);
|
|
1008
|
+
void this.providerState().catch(() => undefined).then(() => {
|
|
1009
|
+
try { this.pumpNow(projectId); } finally {
|
|
1010
|
+
this.pumping.delete(projectId);
|
|
1011
|
+
if (this.pumpAgain.delete(projectId)) this.pump(projectId);
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
private pumpNow(projectId: string): void {
|
|
1016
|
+
const store = this.storeOf(projectId);
|
|
1017
|
+
const project = this.projectOf(projectId);
|
|
1018
|
+
const lanes = this.lane(projectId);
|
|
1019
|
+
// A job a lane has already taken is out of the queue, even while the store still reads `Queued`. `run` awaits the
|
|
1020
|
+
// provider state before it writes `Running`, so during that await the job is reserved in its lane but still
|
|
1021
|
+
// queued in the store: the next pump picked it up again and ran the whole job a second time, in a second lane.
|
|
1022
|
+
// Both ran to the end — two subagents, two sets of writes, twice the spend — and nothing reported anything wrong.
|
|
1023
|
+
const taken = new Set([lanes.owner, ...lanes.auto, ...lanes.delegated].flatMap((l) => (l.running ? [l.running.id] : [])));
|
|
1024
|
+
const queued = queueOrder(store.jobs.filter((j) => j.status === 'Queued' && !taken.has(j.id)), Date.now());
|
|
1025
|
+
// Owner work: one lane on the chosen model. Automatic work: up to lanesPerKey jobs on every usable key.
|
|
1026
|
+
const start = (lane: Lane, next: KeeperJob, choice: { provider: string; id: string; thinking: string | null } | null) => {
|
|
1027
|
+
queued.splice(queued.indexOf(next), 1);
|
|
1028
|
+
lane.running = { ...next, status: 'Running' }; // reserve synchronously: two pumps must not start the same job twice
|
|
1029
|
+
lane.provider = choice?.provider ?? null;
|
|
1030
|
+
lane.parked = 0;
|
|
1031
|
+
void this.run(projectId, next, lane, choice);
|
|
1032
|
+
};
|
|
1033
|
+
// A subagent of work that is running now. The owner's work waits in the owner's lane for the subagents it sent,
|
|
1034
|
+
// so those run in lanes of their own on the chosen model — in the owner's lane they would wait for ever on the
|
|
1035
|
+
// job that is waiting for them (batch C1, item 6).
|
|
1036
|
+
const underRunning = (j: KeeperJob) => j.parentJobId !== null && taken.has(j.parentJobId);
|
|
1037
|
+
if (!lanes.owner.running) { const next = queued.find((j) => j.initiator === 'owner' && !underRunning(j)); if (next) start(lanes.owner, next, null); }
|
|
1038
|
+
for (const next of queued.filter((j) => j.initiator === 'owner' && underRunning(j))) {
|
|
1039
|
+
if (lanes.delegated.filter((l) => l.running && !parkedLane(l)).length >= this.lanesPerKey) break;
|
|
1040
|
+
let lane = lanes.delegated.find((l) => !l.running);
|
|
1041
|
+
if (!lane) { lane = newLane(); lanes.delegated.push(lane); }
|
|
1042
|
+
start(lane, next, null);
|
|
1043
|
+
}
|
|
1044
|
+
// While automatic organizing is held, the only automatic work that starts is a subagent of a job already
|
|
1045
|
+
// running: its parent is waiting for it, and holding it would hang the parent rather than save anything.
|
|
1046
|
+
const held = organizingHeld(project);
|
|
1047
|
+
if (held && !queued.some((j) => j.initiator === 'auto' && this.bypassing.has(j.id))) return;
|
|
1048
|
+
// Automatic work goes to the keys of the project's route (route.ts): each job to the keys that have the model it
|
|
1049
|
+
// wants, spread over them up to each key's jobs at once; to the other keys of the order only when none of those is
|
|
1050
|
+
// usable. A key that is full makes its jobs wait; it does not send them to another model.
|
|
1051
|
+
const order = this.keyChoices(projectId);
|
|
1052
|
+
const configured = order.filter((k) => this.keyUsable(k.provider));
|
|
1053
|
+
if (!configured.length) {
|
|
1054
|
+
// Keys that would refuse the work are not sent it; it waits for the first key to come back.
|
|
1055
|
+
const back = order.filter((k) => this.credentialed.has(k.provider) && this.exhausted(k.provider)).map((k) => Date.parse(this.quotaExhausted.get(k.provider)!.until));
|
|
1056
|
+
if (back.length) { this.wakeWhenKeyReturns(Math.min(...back)); return; }
|
|
1057
|
+
}
|
|
1058
|
+
const keys: RouteKey[] = configured.length ? configured : this.resolvedModel ? [this.resolvedModel] : [];
|
|
1059
|
+
const capacityOf = (key: RouteKey) => (configured.length ? this.keyCapacity(key.provider) : this.resting(key.provider) ? 0 : Math.max(1, this.lanesFor(key.provider)));
|
|
1060
|
+
const runningOn = (key: RouteKey) => lanes.auto.filter((l) => l.running && l.provider === key.provider && !parkedLane(l)).length;
|
|
1061
|
+
const route = this.routeFor(projectId);
|
|
1062
|
+
// A step set to a model on a key (or a provider's keys) runs there; when those are out it goes to the other keys that
|
|
1063
|
+
// carry the same model (route.ts `keysForJob`), and the switch is recorded.
|
|
1064
|
+
const look = { familyOf: (k: string) => this.familyOf(k), ready: (k: string) => !this.resting(k), all: [...this.credentialed] };
|
|
1065
|
+
for (const next of queued.filter((j) => j.initiator === 'auto' && (!held || this.bypassing.has(j.id)))) {
|
|
1066
|
+
const want = wantOf(route, next.step?.kind ?? null);
|
|
1067
|
+
const { keys: candidates, switched } = keysForJob(keys, want, this.hasModel, look);
|
|
1068
|
+
const key = candidates.find((k) => runningOn(k) < capacityOf(k));
|
|
1069
|
+
if (!key) continue;
|
|
1070
|
+
this.noteStepSwitch(projectId, next.step?.kind ?? null, want, switched ? key : null, look.familyOf);
|
|
1071
|
+
let lane = lanes.auto.find((l) => !l.running);
|
|
1072
|
+
if (!lane) { lane = newLane(); lanes.auto.push(lane); }
|
|
1073
|
+
start(lane, next, key);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** The key each step of a project went on to while the keys it is set to were out (project|step → key). */
|
|
1078
|
+
private stepSwitched = new Map<string, string>();
|
|
1079
|
+
/**
|
|
1080
|
+
* The switch log (§3.10 切换记录) for a step set to a key: once when its work first goes on to another key with the same
|
|
1081
|
+
* model, again if it moves to a third, and once when it is back on its own keys.
|
|
1082
|
+
*/
|
|
1083
|
+
private noteStepSwitch(projectId: string, step: string | null, want: Want, to: RouteKey | null, familyOf: (k: string) => string): void {
|
|
1084
|
+
if (!want.pin || !want.model) return;
|
|
1085
|
+
const at = `${projectId}|${step ?? 'work'}`;
|
|
1086
|
+
const was = this.stepSwitched.get(at);
|
|
1087
|
+
const pinned = [...this.credentialed, ...this.quotaExhausted.keys()].filter((k, i, all) => all.indexOf(k) === i && onPin(want.pin!, k, familyOf) && this.hasModel(k, want.model!));
|
|
1088
|
+
const label = step ? `The ${step} step` : 'Work';
|
|
1089
|
+
if (to) {
|
|
1090
|
+
if (was === to.provider) return;
|
|
1091
|
+
this.stepSwitched.set(at, to.provider);
|
|
1092
|
+
const back = pinned.flatMap((k) => [this.exhausted(k) ? Date.parse(this.quotaExhausted.get(k)!.until) : null, this.resting(k) ? this.restingUntil.get(k)! : null]).filter((n): n is number => n !== null);
|
|
1093
|
+
const why = pinned.length ? pinned.map((k) => `${k} ${this.exhausted(k) ? 'out of quota' : this.resting(k) ? 'rate-limited' : 'not usable'}`).join(', ') : `no key of ${want.pin.at} can be used`;
|
|
1094
|
+
this.providerSwitches.push({ at: new Date().toISOString(), from: `${want.pin.at}/${want.model}`, to: `${to.provider}/${want.model}`, reason: `${label}: the key it is set to cannot take work now (${why}); it runs on the same model on the next key of the order that carries it`, until: back.length ? new Date(Math.min(...back)).toISOString() : '' });
|
|
1095
|
+
} else if (was) {
|
|
1096
|
+
this.stepSwitched.delete(at);
|
|
1097
|
+
this.providerSwitches.push({ at: new Date().toISOString(), from: `${was}/${want.model}`, to: `${want.pin.at}/${want.model}`, reason: `${label}: the key it is set to can take work again; back to it`, until: '' });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Pause or resume automatic work. Pausing holds what has not started and stops what runs — a round's main job and its
|
|
1103
|
+
* lanes, the whole running tree — each marked to go on in its own session when organizing resumes (D99, E148 D-i: the
|
|
1104
|
+
* lanes used to run on, since they bypass the hold). Resuming queues them again.
|
|
1105
|
+
*/
|
|
1106
|
+
pauseOrganizing(projectId: string, paused: boolean): void {
|
|
1107
|
+
const store = this.storeOf(projectId);
|
|
1108
|
+
for (const job of store.jobs.filter((j) => j.initiator === 'auto' && (paused ? j.status === 'Queued' : j.status === 'Paused'))) {
|
|
1109
|
+
store.jobs.put({ ...job, status: paused ? 'Paused' : 'Queued' });
|
|
1110
|
+
}
|
|
1111
|
+
if (paused) for (const lane of this.lane(projectId).auto) if (lane.running?.initiator === 'auto' && lane.pause) lane.pause();
|
|
1112
|
+
if (!paused) setImmediate(() => this.pump(projectId));
|
|
1113
|
+
this.emitEvent({ projectId, jobId: '', kind: 'status' });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** What a job delegated to (D59 design 1: stopping reaches the subagents, so nothing keeps spending after the owner
|
|
1117
|
+
* stops the work that started it). */
|
|
1118
|
+
private children(projectId: string, jobId: string): KeeperJob[] {
|
|
1119
|
+
return this.storeOf(projectId).jobs.filter((j) => j.parentJobId === jobId);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
stopJob(projectId: string, jobId: string): boolean {
|
|
1123
|
+
const store = this.storeOf(projectId);
|
|
1124
|
+
const job = store.jobs.get(jobId);
|
|
1125
|
+
if (!job) return false;
|
|
1126
|
+
// Stop what it delegated first: a subagent stopped after its parent would go on running with nobody waiting for it.
|
|
1127
|
+
for (const child of this.children(projectId, jobId)) {
|
|
1128
|
+
if (child.status === 'Running' || child.status === 'Queued' || child.status === 'Paused' || child.status === 'Waiting for quota') this.stopJob(projectId, child.id);
|
|
1129
|
+
}
|
|
1130
|
+
if (job.status === 'Queued' || job.status === 'Paused' || job.status === 'Waiting for quota') {
|
|
1131
|
+
store.jobs.put({ ...job, status: 'Stopped', endedAt: new Date().toISOString() }, { jobId, summary: 'Stopped by the owner before it started' });
|
|
1132
|
+
this.emitEvent({ projectId, jobId, kind: 'job', data: { status: 'Stopped' } });
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
for (const lane of this.allLanes(projectId)) {
|
|
1136
|
+
if (lane.running?.id === jobId && lane.abort) { lane.abort(); return true; }
|
|
1137
|
+
}
|
|
1138
|
+
return false;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Continue a stopped job or retry a failed one: it runs again as the same job with the saved results kept. A round of the
|
|
1143
|
+
* method the clerk method replaced is not run again: its prompt names tools that no longer exist (`stopReplacedRounds`).
|
|
1144
|
+
* Nor is a job the program did itself (`recordProgramJob`: a clerk round, its ledger or process step): it has no prompt,
|
|
1145
|
+
* so run again here it would be an empty model run that could end `Done` with the step's work never done.
|
|
1146
|
+
*/
|
|
1147
|
+
restartJob(projectId: string, jobId: string): boolean {
|
|
1148
|
+
const store = this.storeOf(projectId);
|
|
1149
|
+
const job = store.jobs.get(jobId);
|
|
1150
|
+
if (!job || !['Stopped', 'Failed', 'Waiting for quota'].includes(job.status) || isReplacedRound(job) || job.agent === 'program') return false;
|
|
1151
|
+
store.jobs.put({ ...job, status: 'Queued', error: null, endedAt: null }, { jobId, summary: job.status === 'Failed' ? 'Retried' : 'Continued' });
|
|
1152
|
+
setImmediate(() => this.pump(projectId));
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
steer(projectId: string, jobId: string, text: string): boolean {
|
|
1157
|
+
for (const lane of this.allLanes(projectId)) {
|
|
1158
|
+
if (lane.running?.id === jobId && lane.session) { void lane.session.steer(text); return true; }
|
|
1159
|
+
}
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private async run(projectId: string, job: KeeperJob, lane: Lane, choice: { provider: string; id: string; thinking: string | null } | null = null): Promise<void> {
|
|
1164
|
+
const store = this.storeOf(projectId);
|
|
1165
|
+
const project = this.projectOf(projectId);
|
|
1166
|
+
// A job whose task carries no prompt cannot run as a model run (a record written by hand, or a program job that
|
|
1167
|
+
// reached a lane): it fails with the reason; reading `task.prompt` off it used to throw and end the whole process.
|
|
1168
|
+
if (!job.task || typeof (job.task as { prompt?: unknown }).prompt !== 'string') {
|
|
1169
|
+
store.jobs.put({ ...job, status: 'Failed', endedAt: new Date().toISOString(), error: 'This job carries no task to run' }, { jobId: job.id, summary: 'Not run: no task' });
|
|
1170
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'done', data: { status: 'Failed', error: 'This job carries no task to run' } });
|
|
1171
|
+
lane.running = null;
|
|
1172
|
+
setImmediate(() => this.pump(projectId));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const task = job.task as { prompt: string; stream: boolean; timeoutMs: number | null; materials?: readonly { key: string; sourceIds: readonly string[] }[]; judgementId?: string | null; sessionKey?: string | null; ownerSourceId?: string | null; conversation?: boolean };
|
|
1176
|
+
const provider = await this.providerState(projectId);
|
|
1177
|
+
const keyChoice = choice && (this.keyUsable(choice.provider) || (provider.model && choice.provider === provider.model.provider)) ? choice : provider.model;
|
|
1178
|
+
// settings.routes (owner 2026-09-18, arm B): a kind of automatic work can run on another model of the same key's
|
|
1179
|
+
// provider (for example follow-up review and product re-look on glm-5.3 while the rest runs on flash); the lane,
|
|
1180
|
+
// the key and its quota window stay the same.
|
|
1181
|
+
const extra = (job.task as { extra?: { kind?: string; route?: string } | null } | null)?.extra ?? null;
|
|
1182
|
+
const routeKey = extra?.route ?? extra?.kind;
|
|
1183
|
+
const routedId = keyChoice && routeKey ? this.workspace.settings.routes?.[routeKey] : undefined;
|
|
1184
|
+
// The project's route (route.ts; CKC-03 AC-29): a step of a round runs on the model and thinking set for it, or on
|
|
1185
|
+
// what it follows (D103: the synthesis follows the main agent; every other step the main model). It is read here, as
|
|
1186
|
+
// the job starts: a change of route applies to the jobs that start after it, and a running job is not interrupted.
|
|
1187
|
+
// On a key that does not carry the wanted model (a backup standing in), the job runs on that key's own model.
|
|
1188
|
+
const route = this.routeFor(projectId);
|
|
1189
|
+
const stepKind = job.step?.kind ?? null;
|
|
1190
|
+
const want = wantOf(route, stepKind);
|
|
1191
|
+
const stepModelSet = Boolean(stepKind && want.follows !== 'main model' && want.model);
|
|
1192
|
+
const onKey = keyChoice ? modelOnKey({ provider: keyChoice.provider, id: keyChoice.id, thinking: keyChoice.thinking ?? null }, want, this.hasModel) : null;
|
|
1193
|
+
const modelId = !onKey ? undefined
|
|
1194
|
+
: stepModelSet && !onKey.standIn ? onKey.id
|
|
1195
|
+
: routedId && this.hasModel(keyChoice!.provider, routedId) ? routedId
|
|
1196
|
+
: onKey.id;
|
|
1197
|
+
const thinking = onKey && modelId === onKey.id ? onKey.thinking : keyChoice?.thinking ?? null;
|
|
1198
|
+
const chosen = keyChoice ? { ...keyChoice, ...(modelId ? { id: modelId } : {}), thinking } : keyChoice;
|
|
1199
|
+
if (!provider.connected || !chosen) {
|
|
1200
|
+
store.jobs.put({ ...job, status: 'Waiting for quota', error: provider.reason }, { jobId: job.id, summary: 'No model available' });
|
|
1201
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'job', data: { status: 'Waiting for quota' } });
|
|
1202
|
+
lane.running = null;
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const model = this.models.getModel(chosen.provider, chosen.id);
|
|
1206
|
+
if (!model) { store.jobs.put({ ...job, status: 'Failed', error: `Model ${chosen.provider}/${chosen.id} not found` }); lane.running = null; setImmediate(() => this.pump(projectId)); return; }
|
|
1207
|
+
// The owner may have stopped it, or organizing been paused, between the pump taking it and here: it does not start.
|
|
1208
|
+
const stored = store.jobs.get(job.id);
|
|
1209
|
+
if (stored && stored.status !== 'Queued') {
|
|
1210
|
+
lane.running = null; lane.provider = null;
|
|
1211
|
+
setImmediate(() => this.pump(projectId));
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
let session: AgentSession | null = null;
|
|
1215
|
+
let aborted = false;
|
|
1216
|
+
let timedOut = false; // the time limit stopped it, not the owner: the planner goes on with the next round
|
|
1217
|
+
let pausing = false; // the owner paused organizing: it ends Paused and goes on in its session when organizing resumes (D99)
|
|
1218
|
+
lane.running = { ...job, status: 'Running' };
|
|
1219
|
+
lane.provider = model.provider; // the key this job runs on: its jobs are counted, and removing it moves this one on
|
|
1220
|
+
lane.keyRemoved = null;
|
|
1221
|
+
const started = new Date().toISOString();
|
|
1222
|
+
// An interrupted run goes on (D99): in the job's own session when it can be opened, and its usage, time and steps
|
|
1223
|
+
// add to the job's totals. The mark is cleared as the run starts and set again if this run is interrupted too.
|
|
1224
|
+
const resume = job.resume && continuable(job) ? job.resume : null;
|
|
1225
|
+
// A run starts clean: the answer that sent the job back to the queue (a rate limit, a quota) belongs to the run before.
|
|
1226
|
+
let current: KeeperJob = { ...job, status: 'Running', startedAt: resume ? job.startedAt ?? started : started, error: null, model: { provider: model.provider, id: model.id, thinking: chosen.thinking }, resume: null };
|
|
1227
|
+
store.jobs.put(current, { jobId: job.id, summary: `${job.kind} started: ${job.scope.label}` });
|
|
1228
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'job', data: { status: 'Running' } });
|
|
1229
|
+
// The owner's Stop and a pause reach the job from the moment it runs, before its session is open: opening one (the
|
|
1230
|
+
// resource loader, a saved session) takes a while, and a Stop or a pause in that time used to be lost — a lane the
|
|
1231
|
+
// main agent had just sent ran on after its main job was stopped (D99, E148 D-i). One that came before the session
|
|
1232
|
+
// was open ends the run before its prompt.
|
|
1233
|
+
lane.abort = () => { aborted = true; void session?.abort(); };
|
|
1234
|
+
lane.pause = () => { pausing = true; aborted = true; void session?.abort(); };
|
|
1235
|
+
const cwd = project.locations[0]!;
|
|
1236
|
+
const before = await this.contentSnapshot(project);
|
|
1237
|
+
// Others (the conversation's steer records) may write this job's task while it runs; keep theirs.
|
|
1238
|
+
const save = (patch: Partial<KeeperJob>) => {
|
|
1239
|
+
const storedExtra = ((store.jobs.get(current.id)?.task as { extra?: Record<string, unknown> } | null | undefined)?.extra) ?? {};
|
|
1240
|
+
const base = (patch.task ?? current.task) as { extra?: Record<string, unknown> } | null;
|
|
1241
|
+
const task = base && 'followUps' in storedExtra ? { ...base, extra: { ...(base.extra ?? {}), followUps: storedExtra.followUps } } : base;
|
|
1242
|
+
current = { ...current, ...patch, task };
|
|
1243
|
+
store.jobs.put(current);
|
|
1244
|
+
};
|
|
1245
|
+
let switched = false; // a backup provider took over after a quota error; waiting work is requeued once the job is written
|
|
1246
|
+
let rateLimited = false; // the key answered a rate limit: it rests, and waiting work is requeued once the job is written
|
|
1247
|
+
let timerOf: StepTimer | null = null; // stamps the session's events: where this job's time went (§3.10)
|
|
1248
|
+
let lastReply = null as Reply | null; // this job's last reply, as its events showed it
|
|
1249
|
+
let restoreStop: (() => void) | null = null; // puts back the session's own turn-finish hook
|
|
1250
|
+
let startIndex = 0;
|
|
1251
|
+
let continued = false; // this run goes on in the job's own saved session (D99)
|
|
1252
|
+
let freshReason: string | null = null; // why an interrupted job's session could not be continued
|
|
1253
|
+
// What the job had spent before this run, and what its session had recorded when this run began: a continued session
|
|
1254
|
+
// holds the earlier runs' usage too, so only what it spends from here on is added.
|
|
1255
|
+
const priorUsage = resume ? job.usage : zeroUsage();
|
|
1256
|
+
let baselineUsage = zeroUsage();
|
|
1257
|
+
const sessionKey = task.sessionKey ?? null;
|
|
1258
|
+
const liveEntry = sessionKey ? this.live.get(sessionKey) : undefined;
|
|
1259
|
+
// A run the provider interrupted goes on in this session when the job runs again (D99); a conversation keeps its own.
|
|
1260
|
+
const interrupted = (): Partial<KeeperJob> => (session?.sessionFile && !sessionKey ? { resume: { why: 'provider', since: new Date().toISOString() } } : {});
|
|
1261
|
+
// This run's time; a run that went on from an interrupted one adds to the job's time so far, its wait for the queue
|
|
1262
|
+
// counted from when the interrupted run stopped.
|
|
1263
|
+
const timingOf = (timer: StepTimer): StepTiming => {
|
|
1264
|
+
const ended = current.endedAt ?? new Date().toISOString();
|
|
1265
|
+
if (!resume) return timer.finish(current.queuedAt, current.startedAt ?? started, ended);
|
|
1266
|
+
const run = timer.finish(resume.since, started, ended);
|
|
1267
|
+
return job.timing ? timingSum(job.timing, run) : run;
|
|
1268
|
+
};
|
|
1269
|
+
let unsubscribe: (() => void) | null = null;
|
|
1270
|
+
// A job outside a round's steps gets its tools by who it is: a subagent reads and writes only the fact records of its
|
|
1271
|
+
// material; other work gets every pk_* tool but those only a round's step has a round for (roles.ts `toolsFor`).
|
|
1272
|
+
const role = jobRole(job);
|
|
1273
|
+
// D107: a table follow-up reads the project and hands in one table; it is given that writer and the ledger's reads only.
|
|
1274
|
+
const tableJob = extra?.kind === TABLE_FOLLOW_UP;
|
|
1275
|
+
const tableFile = tableJob ? (extra as { tableFile?: string }).tableFile ?? 'the table' : null;
|
|
1276
|
+
try {
|
|
1277
|
+
const perJob: Partial<ToolContext> = {
|
|
1278
|
+
jobId: job.id, jobKind: job.kind, materials: task.materials ?? [], judgementId: task.judgementId ?? null, ownerSourceId: task.ownerSourceId ?? null,
|
|
1279
|
+
// The round a job belongs to, so what it writes is attributed to that round rather than opening one lazily
|
|
1280
|
+
// (D56: one result per round). A step of a clerk-method round carries its round's id.
|
|
1281
|
+
roundId: (job.task as { extra?: { roundId?: string } | null } | null)?.extra?.roundId ?? null,
|
|
1282
|
+
onSaved: (collection, id, label) => { save({ savedResults: [...current.savedResults.filter((r) => r.id !== id), { collection, id, label }] }); },
|
|
1283
|
+
investigate: async (question, hints) => this.investigate(projectId, job.id, question, hints),
|
|
1284
|
+
beginRequest: (input) => {
|
|
1285
|
+
if (!input.authorizationId && !task.conversation) return 'ERROR: outside the owner’s conversation a request needs a standing authorization id.';
|
|
1286
|
+
const basis = input.authorizationId
|
|
1287
|
+
? { kind: 'authorization' as const, ref: input.authorizationId, label: input.label }
|
|
1288
|
+
: { kind: 'delegation' as const, ref: task.ownerSourceId ?? job.id, label: input.label };
|
|
1289
|
+
save({ kind: 'Your request', requestBasis: basis, scope: { ...current.scope, label: input.label } });
|
|
1290
|
+
lane.running = current;
|
|
1291
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'status' });
|
|
1292
|
+
return `Recorded as your request (${basis.kind === 'authorization' ? `under standing authorization ${basis.ref}` : 'delegated in this conversation'}): ${input.label}. You may change project content within this scope; report what you changed and what remains, and name the basis.`;
|
|
1293
|
+
},
|
|
1294
|
+
onInvestigationResult: (result) => { save({ task: { ...(current.task as object), extra: { ...((current.task as { extra?: object }).extra ?? {}), result } } }); },
|
|
1295
|
+
requestRelook: (scope, reason) => { this.hooks.requestRelook?.(projectId, scope, reason); },
|
|
1296
|
+
// D99: the main agent's stages are timed from this job's step timer, and its lanes run through `delegate`.
|
|
1297
|
+
timingSnapshot: () => (timerOf ? timingOf(timerOf) : null),
|
|
1298
|
+
...(job.step?.kind === 'main' ? {
|
|
1299
|
+
lanes: this.laneRunner(projectId, job.id),
|
|
1300
|
+
stageEntered: (roundId: string, stage: ClerkStage) => this.hooks.stageEntered?.(projectId, roundId, stage) ?? { note: null },
|
|
1301
|
+
} : {}),
|
|
1302
|
+
};
|
|
1303
|
+
if (liveEntry) {
|
|
1304
|
+
Object.assign(liveEntry.ctx, perJob);
|
|
1305
|
+
session = liveEntry.session;
|
|
1306
|
+
liveEntry.turns += 1;
|
|
1307
|
+
} else {
|
|
1308
|
+
// A directory whose path is too long for pi's session folder and for any process started in it: said as that,
|
|
1309
|
+
// with what to do, before pi fails on a folder it cannot make (util/paths.ts `keeperPathLimit`).
|
|
1310
|
+
const tooLong = keeperPathLimit(cwd);
|
|
1311
|
+
if (tooLong) throw new Error(tooLong);
|
|
1312
|
+
const settingsManager = SettingsManager.create(cwd, getAgentDir());
|
|
1313
|
+
// Every pi built-in tool is on in every step, the judging ones included (CKC-03 AC-25, D87). They are turned on
|
|
1314
|
+
// through the default-tool setting, not the `tools` option: an allowlist there drops every pk_* tool, and the
|
|
1315
|
+
// read boundary re-provides its guarded versions of exactly the default set (bounds/boundary.ts).
|
|
1316
|
+
settingsManager.applyOverrides({ defaultTools: keeperBuiltinTools() });
|
|
1317
|
+
// Read boundary (Spec §3.1; CKC-03 AC-23, E69): reads and shell stay inside the project's roots, and
|
|
1318
|
+
// credentials stay out of shell subprocesses, without any tool being removed. The loader is built with the
|
|
1319
|
+
// boundary's inline extension, so the boundary reads the loader's resources lazily on the first tool call.
|
|
1320
|
+
// A home that watches its projects is a live project, where the owner and agents write while a command runs:
|
|
1321
|
+
// the shell guard then undoes only what the command names as its writes; watchProjects false is a controlled trial (BQ).
|
|
1322
|
+
const loaderRef: { current: DefaultResourceLoader | null } = { current: null };
|
|
1323
|
+
const boundary = createReadBoundary({
|
|
1324
|
+
project, store, agentDir: getAgentDir(), settingsManager, jobId: job.id,
|
|
1325
|
+
resolveLoader: () => loaderRef.current,
|
|
1326
|
+
onDeny: () => save({ boundaryDenials: (current.boundaryDenials ?? 0) + 1 }),
|
|
1327
|
+
watchesProjects: () => this.workspace.settings.watchProjects !== false,
|
|
1328
|
+
});
|
|
1329
|
+
// The clerk round's main agent, lanes, synthesis and spot-check get the method as pi skills (D99, D103; E148 D-b); pi lists them,
|
|
1330
|
+
// and the boundary opens each loaded skill's folder for reading.
|
|
1331
|
+
const loader = new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), settingsManager, extensionFactories: [boundary.extension], additionalSkillPaths: tableJob ? [tableSkillDir()] : clerkSkillPathsFor(job.step?.kind) });
|
|
1332
|
+
loaderRef.current = loader;
|
|
1333
|
+
await loader.reload();
|
|
1334
|
+
// The loader's reload re-reads the settings files and drops overrides; set the tools again for the session.
|
|
1335
|
+
settingsManager.applyOverrides({ defaultTools: keeperBuiltinTools() });
|
|
1336
|
+
const toolCtx: ToolContext = { store, project, model: model.id, jobId: job.id, jobKind: job.kind, step: job.step ?? null, ...perJob };
|
|
1337
|
+
const extra = this.extraTools.get(job.id) ?? [];
|
|
1338
|
+
// The per-role filter governs the pk_* tools; the boundary's re-provided bash/powershell are not pk_* tools, so they
|
|
1339
|
+
// pass through it and every role keeps its shell (roles.ts `toolsFor`).
|
|
1340
|
+
// The clerk method's own writers go to its steps only; any other job would be offered writers that refuse it.
|
|
1341
|
+
const clerk = job.step ? [...clerkTools({ ...toolCtx, step: job.step, ledger: this.hooks.ledgerFor?.(projectId) ?? null }), ...stageTools(toolCtx), ...laneTools(toolCtx)] : [];
|
|
1342
|
+
// D99 (W5): the coverage check and the main agent's accounts; which job is offered them is roles.ts's.
|
|
1343
|
+
if (job.step) clerk.push(...coverageTools(toolCtx));
|
|
1344
|
+
// DA: an owner's line judged to need nothing, by its line reference (owner-lines.ts).
|
|
1345
|
+
if (job.step) clerk.push(...ownerLineTools(toolCtx));
|
|
1346
|
+
// CJ: the main agent's account of a counted entry, by number (the entry completeness gate, entry-gate.ts).
|
|
1347
|
+
if (job.step) clerk.push(...entryGateTools(toolCtx));
|
|
1348
|
+
// CM: bulk placement by number range, and the verdict on a candidate generation (place-tools.ts).
|
|
1349
|
+
if (job.step) clerk.push(...placeTools({ ...toolCtx, ledger: this.hooks.ledgerFor?.(projectId) ?? null }));
|
|
1350
|
+
// Every job can query the ledger (CKC-03 AC-26): the dates, commits, versions, numbers and verdicts it holds are
|
|
1351
|
+
// exact, and asking it is faster than reading them back out of files.
|
|
1352
|
+
const allTools = [...keeperTools(toolCtx), ...roundTools(toolCtx), ...ledgerTools(toolCtx), ...clerk, ...boundary.tools, ...extra];
|
|
1353
|
+
// A step of a clerk-method round gets its step's tools (Spec §3.3: which step writes what); other jobs keep the
|
|
1354
|
+
// per-role filter.
|
|
1355
|
+
// The main job and a lane (D99) are offered what they write in any stage or slot, refused at call time outside
|
|
1356
|
+
// their effective stage (stage-gate.ts).
|
|
1357
|
+
const step = job.step;
|
|
1358
|
+
// A table follow-up (D107) keeps pi's tools and the ledger's reads, and writes only the table, through pk_write_table.
|
|
1359
|
+
const tableWriter = tableJob ? this.hooks.tableWriter?.(projectId, job.id) ?? null : null;
|
|
1360
|
+
const customTools = tableJob
|
|
1361
|
+
? [...allTools.filter((t) => !t.name.startsWith('pk_') || t.name.startsWith('pk_ledger_')), ...(tableWriter ? tableFollowUpTools(tableWriter, (text) => toolCtx.onSaved?.('table', tableFile!, text.slice(0, 160))) : [])]
|
|
1362
|
+
: step ? stepToolsFor(allTools, step.kind, this.clerkRoundKind(store, job), step.lane ?? null, (tool, args) => writeRefusal(store, step, tool, args)) : toolsFor(allTools, role);
|
|
1363
|
+
const thinkingLevel = (chosen.thinking ?? 'medium') as 'medium';
|
|
1364
|
+
const createSession = (sessionManager: SessionManager) => createAgentSession({
|
|
1365
|
+
cwd, agentDir: getAgentDir(), modelRuntime: this.models, model, thinkingLevel, customTools, resourceLoader: loader, settingsManager, sessionManager,
|
|
1366
|
+
});
|
|
1367
|
+
// An interrupted job goes on in its own session, on the key it is given now (D99). pi builds the session's
|
|
1368
|
+
// messages from the file; the model and thinking level passed in take effect, and a model other than the one the
|
|
1369
|
+
// session recorded is recorded as a change, so the file says which key answered what. The interrupted turn needs
|
|
1370
|
+
// nothing done to it: pi-ai leaves an errored or aborted assistant message out of every request it builds, and
|
|
1371
|
+
// gives a tool call left without a result (cut short by a restart) a synthetic error result (transform-messages.ts).
|
|
1372
|
+
let created: Awaited<ReturnType<typeof createAgentSession>> | null = null;
|
|
1373
|
+
if (resume) {
|
|
1374
|
+
const opened = openSavedSession(job.sessionFile!, cwd);
|
|
1375
|
+
if ('reason' in opened) {
|
|
1376
|
+
freshReason = opened.reason;
|
|
1377
|
+
} else {
|
|
1378
|
+
try {
|
|
1379
|
+
created = await createSession(opened.manager);
|
|
1380
|
+
if (!opened.model || opened.model.provider !== model.provider || opened.model.modelId !== model.id) await created.session.setModel(model);
|
|
1381
|
+
created.session.setThinkingLevel(thinkingLevel);
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
try { created?.session.dispose(); } catch { /* ignore */ }
|
|
1384
|
+
created = null;
|
|
1385
|
+
freshReason = `its saved session could not be continued on ${model.provider}/${model.id} (${(e as Error).message})`;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (!created) created = await createSession(SessionManager.create(cwd));
|
|
1390
|
+
continued = resume !== null && freshReason === null;
|
|
1391
|
+
session = created.session;
|
|
1392
|
+
if (resume) {
|
|
1393
|
+
store.jobs.put(current, {
|
|
1394
|
+
jobId: job.id,
|
|
1395
|
+
summary: continued
|
|
1396
|
+
? `${job.kind}: continues its own session on ${model.provider}/${model.id} after ${resume.why === 'provider' ? 'the model provider interrupted it' : resume.why === 'restart' ? 'ProjectKeeper restarted' : resume.why === 'pause' ? 'organizing was paused' : 'it ended before its task was done'}`
|
|
1397
|
+
: `${job.kind}: starts afresh — ${freshReason}`,
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
// Record the thinking level that took effect: a model maps what it does not support (glm-5.3 has low, high, max;
|
|
1401
|
+
// `medium` runs as `high`), and the Keeper view shows what actually ran (CKC-03 AC-29).
|
|
1402
|
+
const effective = (session as unknown as { thinkingLevel?: string }).thinkingLevel;
|
|
1403
|
+
if (effective && current.model && effective !== current.model.thinking) save({ model: { ...current.model, thinking: effective } });
|
|
1404
|
+
if (sessionKey) this.live.set(sessionKey, { session, ctx: toolCtx, boundary, turns: 1 });
|
|
1405
|
+
}
|
|
1406
|
+
lane.session = session;
|
|
1407
|
+
if (continued) baselineUsage = this.usageOf(session);
|
|
1408
|
+
const liveSession = session;
|
|
1409
|
+
const runUsage = (): Usage => usageAfter(priorUsage, this.usageOf(liveSession), baselineUsage);
|
|
1410
|
+
save({ sessionFile: session.sessionFile ?? null, sessionId: session.sessionId });
|
|
1411
|
+
let messageId = newId('msg');
|
|
1412
|
+
// Which step each tool call opened. Parallel calls end in any order, so a result is filed by the call it belongs
|
|
1413
|
+
// to; filing it on the last step with the same tool name gave the second of two parallel investigations the
|
|
1414
|
+
// first one's answer and left the first with none.
|
|
1415
|
+
const stepOfCall = new Map<string, number>();
|
|
1416
|
+
// Each call's arguments, kept until it ends: what it read is worked out from them and its result (bounds/reads.ts),
|
|
1417
|
+
// since the step's target keeps only the first 160 characters of a command (Spec §1.11; CKC-13 AC-8).
|
|
1418
|
+
const argsOfCall = new Map<string, Record<string, unknown>>();
|
|
1419
|
+
const readContext = { cwd, repoRoot: (repo: string) => project.scope.find((i) => i.id === repo)?.path ?? (isAbsolute(repo) ? repo : null) };
|
|
1420
|
+
// Where this job's time goes (Spec §3.10, D81): pi's events carry no times, so they are stamped as they arrive.
|
|
1421
|
+
const stepTimer = new StepTimer();
|
|
1422
|
+
timerOf = stepTimer;
|
|
1423
|
+
// The same call refused the same way again and again: pi would keep asking the model until the step's time
|
|
1424
|
+
// limit. Each turn is read whole as it ends, its reply and every call's result (step-timing.ts RefusalStreak):
|
|
1425
|
+
// once the same call has come back refused the same way in REPEATED_REFUSAL_LIMIT turns in a row, with nothing
|
|
1426
|
+
// else run in them, the loop stops (QC AX-1). Calls to different targets refused together in one turn count once
|
|
1427
|
+
// each, not as one call refused that many times (test-D-1).
|
|
1428
|
+
const refusals = new RefusalStreak();
|
|
1429
|
+
const agent = session.agent;
|
|
1430
|
+
const priorFinishTurn = agent.finishTurn;
|
|
1431
|
+
agent.finishTurn = async (turn, signal) => {
|
|
1432
|
+
const prior = await priorFinishTurn?.(turn, signal);
|
|
1433
|
+
refusals.turnEnded(guardTurn(turn));
|
|
1434
|
+
if (turn.message.stopReason !== 'error' && turn.message.stopReason !== 'aborted' && refusals.reached !== null) return { action: 'end' };
|
|
1435
|
+
return prior ?? undefined;
|
|
1436
|
+
};
|
|
1437
|
+
restoreStop = () => { agent.finishTurn = priorFinishTurn; };
|
|
1438
|
+
unsubscribe = session.subscribe((event) => {
|
|
1439
|
+
const at = Date.now();
|
|
1440
|
+
if (event.type === 'message_end' && (event as { message?: { role?: string } }).message?.role === 'assistant') {
|
|
1441
|
+
const reply = (event as { message: Reply }).message;
|
|
1442
|
+
stepTimer.assistantEnded(reply, at);
|
|
1443
|
+
lastReply = reply;
|
|
1444
|
+
} else if (event.type === 'tool_execution_start') {
|
|
1445
|
+
stepTimer.toolStarted(at);
|
|
1446
|
+
} else if (event.type === 'tool_execution_end') {
|
|
1447
|
+
const e = event as { toolName: string; isError?: boolean; result?: { content?: { type: string; text?: string }[] } };
|
|
1448
|
+
const text = e.result?.content?.find((c) => c.type === 'text')?.text ?? '';
|
|
1449
|
+
stepTimer.toolEnded(at, e.isError === true, text);
|
|
1450
|
+
} else if ((event as { type: string }).type === 'auto_retry_start') {
|
|
1451
|
+
stepTimer.retryWait((event as unknown as { delayMs?: number }).delayMs ?? 0);
|
|
1452
|
+
}
|
|
1453
|
+
if (event.type === 'message_update' && task.stream) {
|
|
1454
|
+
const e = event.assistantMessageEvent as { type: string; delta?: string };
|
|
1455
|
+
if (e.type === 'text_delta' && e.delta) this.emitEvent({ projectId, jobId: job.id, kind: 'delta', data: { messageId, text: e.delta } });
|
|
1456
|
+
} else if (event.type === 'message_start' && (event as { message?: { role?: string } }).message?.role === 'assistant') {
|
|
1457
|
+
messageId = newId('msg');
|
|
1458
|
+
} else if (event.type === 'tool_execution_start') {
|
|
1459
|
+
const e = event as { toolCallId?: string; toolName: string; args: Record<string, unknown> };
|
|
1460
|
+
const keys = Array.isArray(e.args?.keys) ? (e.args.keys as unknown[]).join(', ') : undefined;
|
|
1461
|
+
const target = String(e.args?.path ?? e.args?.file_path ?? e.args?.command ?? e.args?.id ?? e.args?.query ?? e.args?.question ?? keys ?? e.args?.title ?? e.args?.name ?? '').slice(0, 160);
|
|
1462
|
+
if (e.toolCallId) stepOfCall.set(e.toolCallId, current.steps.length);
|
|
1463
|
+
if (e.toolCallId && READING_TOOLS.has(e.toolName)) argsOfCall.set(e.toolCallId, e.args ?? {});
|
|
1464
|
+
save({ steps: [...current.steps, { at: new Date().toISOString(), tool: e.toolName, target, summary: '', isError: false }] });
|
|
1465
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'step', data: { tool: e.toolName, target } });
|
|
1466
|
+
} else if (event.type === 'tool_execution_end') {
|
|
1467
|
+
const e = event as { toolCallId?: string; toolName: string; isError: boolean; result?: { content?: { type: string; text?: string }[] } };
|
|
1468
|
+
const steps = [...current.steps];
|
|
1469
|
+
const own = e.toolCallId !== undefined ? stepOfCall.get(e.toolCallId) : undefined;
|
|
1470
|
+
const args = e.toolCallId !== undefined ? argsOfCall.get(e.toolCallId) : undefined;
|
|
1471
|
+
if (e.toolCallId !== undefined) { stepOfCall.delete(e.toolCallId); argsOfCall.delete(e.toolCallId); }
|
|
1472
|
+
const idx = own ?? steps.map((s) => s.tool).lastIndexOf(e.toolName);
|
|
1473
|
+
if (idx >= 0 && idx < steps.length) {
|
|
1474
|
+
const textPart = e.result?.content?.find((c) => c.type === 'text')?.text ?? '';
|
|
1475
|
+
// What the call read, as it ended (a call whose arguments were not kept records none, and counts by its target).
|
|
1476
|
+
const reads = args ? stepReads(e.toolName, args, textPart, e.isError, readContext) : null;
|
|
1477
|
+
steps[idx] = { ...steps[idx]!, summary: textPart.replace(/\s+/g, ' ').slice(0, 160), isError: e.isError, ...(reads ? { reads } : {}) };
|
|
1478
|
+
save({ steps });
|
|
1479
|
+
}
|
|
1480
|
+
} else if (event.type === 'message_end') {
|
|
1481
|
+
save({ usage: runUsage() });
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
const preamble = tableJob ? tableFollowUpPreamble({ projectName: project.name, projectRoot: cwd, jobId: job.id }) : keeperPreamble({ projectName: project.name, projectRoot: cwd, language: project.language, jobKind: job.kind, jobId: job.id, conversation: task.conversation === true, subagentOf: role === 'subagent' && job.step?.kind !== 'lane' && job.step?.kind !== 'main' && job.step?.kind !== 'synthesis' && job.step?.kind !== 'spot-check' ? job.parentJobId : null, laneOf: job.step?.kind === 'lane' ? job.parentJobId : null, mayChangeProject: job.requestBasis ? { allowed: true, basis: job.requestBasis.label } : { allowed: false, basis: null }, authorizations: store.authorizations.filter((a) => !a.revokedAt).map((a) => ({ id: a.id, scope: a.scope })) });
|
|
1485
|
+
const timeoutMs = task.timeoutMs ?? (job.initiator === 'owner' ? null : 30 * 60_000);
|
|
1486
|
+
const timer = timeoutMs ? setTimeout(() => { aborted = true; timedOut = true; void session?.abort(); }, timeoutMs) : null;
|
|
1487
|
+
try {
|
|
1488
|
+
// A live session may still be settling after its previous turn; wait, then queue as a follow-up if it never settles.
|
|
1489
|
+
if (liveEntry) {
|
|
1490
|
+
for (let i = 0; i < 200 && session.isStreaming; i++) await new Promise((r) => setTimeout(r, 50));
|
|
1491
|
+
liveEntry.boundary.setJobId(job.id, project, () => save({ boundaryDenials: (current.boundaryDenials ?? 0) + 1 }));
|
|
1492
|
+
}
|
|
1493
|
+
startIndex = session.messages.length;
|
|
1494
|
+
const text = liveEntry ? task.prompt : continued ? continuationMessage(resume!.why, model, resume!.note ?? null) : `${preamble}\n\n${task.prompt}`;
|
|
1495
|
+
if (!aborted) await session.prompt(text, session.isStreaming ? { streamingBehavior: 'followUp' } : undefined);
|
|
1496
|
+
} finally {
|
|
1497
|
+
if (timer) clearTimeout(timer);
|
|
1498
|
+
// A stopped turn can leave steering queued in pi 0.87.1. It belongs to that stopped job, not the next prompt.
|
|
1499
|
+
if (aborted) session.clearQueue();
|
|
1500
|
+
}
|
|
1501
|
+
let leafId: string | null = null;
|
|
1502
|
+
try { leafId = (session as unknown as { sessionManager?: { getLeafId(): string | null } }).sessionManager?.getLeafId() ?? null; } catch { leafId = null; }
|
|
1503
|
+
if (sessionKey) save({ task: { ...(current.task as object), extra: { ...((current.task as { extra?: object }).extra ?? {}), leafId } } });
|
|
1504
|
+
// How the job ended is its last reply as this job's events showed it. `session.messages` is not a record of that:
|
|
1505
|
+
// pi drops a reply cut off at the output limit from it to compact and retry, and when there is nothing to compact
|
|
1506
|
+
// it does not retry — the job was then recorded `Done` with no result (QC AX-1) — and compaction rewrites the
|
|
1507
|
+
// list, so an index into it no longer finds this turn. The list is only the fallback when no reply was seen.
|
|
1508
|
+
// Only this turn's messages count; a live session also holds the earlier turns.
|
|
1509
|
+
const messages = session.messages.slice(startIndex);
|
|
1510
|
+
const lastAssistant = lastReply ?? ([...messages].reverse().find((m) => m.role === 'assistant') as Reply | undefined);
|
|
1511
|
+
// The reply is kept as the model wrote it, its first and last lines included (QC AX-1): trimming belongs where it
|
|
1512
|
+
// is shown or compared. A reply with no words in it is no result at all.
|
|
1513
|
+
const replyText = (lastAssistant?.content ?? []).filter((c) => c.type === 'text').map((c) => c.text ?? '').join('\n');
|
|
1514
|
+
const resultText = replyText.trim() ? replyText : null;
|
|
1515
|
+
// A provider's error may quote what it was sent; a key saved here is taken out before anything is written (§3.1).
|
|
1516
|
+
const errorMessage = lastAssistant?.stopReason === 'error' ? this.ownKeys.redact(lastAssistant.errorMessage ?? 'The model returned an error') : null;
|
|
1517
|
+
// A last reply cut off at the model's output limit is not a finished job (CKC-03 AC-30): it used to be saved as
|
|
1518
|
+
// `Done`, and a truncated report or record passed as complete. A `length` stop stays a failure unless a complete
|
|
1519
|
+
// reply came after it — which would then be the last reply. What it wrote before the cut stays.
|
|
1520
|
+
const cutOff = !errorMessage && lastAssistant?.stopReason === 'length'
|
|
1521
|
+
? `The last reply was cut off at the model's output limit (${current.model?.id ?? 'the model'}): what it wrote before the cut is kept; run the step again`
|
|
1522
|
+
: null;
|
|
1523
|
+
const repeated = refusals.reached;
|
|
1524
|
+
const usage = runUsage();
|
|
1525
|
+
const after = await this.contentSnapshot(project);
|
|
1526
|
+
const changed = this.contentDiff(before, after);
|
|
1527
|
+
// Only a controlled trial (a home that takes in no new changes) can blame a job for what changed while it ran: on a
|
|
1528
|
+
// live project the owner and other agents work at the same time, their commits would be laid at the job's door, and
|
|
1529
|
+
// the ledger takes those changes in anyway. There the shell boundary keeps the Keeper's own writes out (BQ).
|
|
1530
|
+
if (changed.length && !current.requestBasis && this.workspace.settings.watchProjects === false) {
|
|
1531
|
+
const list = this.resourceChanges.get(projectId) ?? [];
|
|
1532
|
+
list.push({ at: new Date().toISOString(), jobId: job.id, detail: `Project content changed during autonomous ${job.kind} (${job.scope.label}): ${changed.slice(0, 8).join('; ')}${changed.length > 8 ? ` +${changed.length - 8} more` : ''}` });
|
|
1533
|
+
this.resourceChanges.set(projectId, list.slice(-50));
|
|
1534
|
+
}
|
|
1535
|
+
if (aborted && pausing && !timedOut) {
|
|
1536
|
+
// Organizing was paused (D99): it goes on in its own session when organizing resumes — at once, when organizing
|
|
1537
|
+
// was resumed while this run was ending (resuming requeues only what already reads Paused).
|
|
1538
|
+
save({ status: organizingHeld(this.projectOf(projectId)) ? 'Paused' : 'Queued', usage, resultText, endedAt: null, ...(session?.sessionFile && !sessionKey ? { resume: { why: 'pause' as const, since: new Date().toISOString() } } : {}) });
|
|
1539
|
+
} else if (aborted && lane.keyRemoved) {
|
|
1540
|
+
// The owner removed the key this job ran on (§6.10): it goes on as when a key runs out of quota — in its own
|
|
1541
|
+
// session on the next usable key of its route; with none left, it waits and says why.
|
|
1542
|
+
const others = this.keyChoices(projectId).filter((k) => k.provider !== current.model?.provider && this.keyUsable(k.provider));
|
|
1543
|
+
const why = `The key ${lane.keyRemoved} was removed while this work ran on it`;
|
|
1544
|
+
save({ status: others.length ? 'Queued' : 'Waiting for quota', error: others.length ? null : `${why}, and no other usable key is left: add a key or choose another in Keeper → Model provider`, usage, resultText, endedAt: null,
|
|
1545
|
+
...(session?.sessionFile && !sessionKey ? { resume: { why: 'provider' as const, since: new Date().toISOString(), note: `${why}; it goes on here.` } } : {}) });
|
|
1546
|
+
} else if (aborted) {
|
|
1547
|
+
// The owner stopped it (or the time limit did): what was produced stays; an abort error is not a failure.
|
|
1548
|
+
save({ status: 'Stopped', usage, resultText, endedAt: new Date().toISOString(), ...(timedOut ? { error: 'Stopped at its time limit' } : {}) });
|
|
1549
|
+
} else if (repeated) {
|
|
1550
|
+
save({ status: 'Failed', error: repeatedRefusalError(repeated), usage, resultText, endedAt: new Date().toISOString() });
|
|
1551
|
+
} else if (errorMessage) {
|
|
1552
|
+
const kind = classifyError(errorMessage);
|
|
1553
|
+
this.lastError.set(projectId, { at: new Date().toISOString(), message: errorMessage, kind });
|
|
1554
|
+
if (kind === 'ratelimit') {
|
|
1555
|
+
save({ status: 'Waiting for quota', error: errorMessage, usage, resultText, endedAt: null, ...interrupted() });
|
|
1556
|
+
rateLimited = true;
|
|
1557
|
+
} else if (kind === 'quota' || kind === 'unavailable') {
|
|
1558
|
+
save({ status: 'Waiting for quota', error: errorMessage, usage, resultText, endedAt: null, ...interrupted() });
|
|
1559
|
+
switched = kind === 'quota' && await this.onQuotaExhausted(projectId, current.model?.provider ?? null, errorMessage);
|
|
1560
|
+
if (!switched) this.scheduleQuotaRetry(projectId);
|
|
1561
|
+
} else {
|
|
1562
|
+
save({ status: 'Failed', error: errorMessage, usage, resultText, endedAt: new Date().toISOString() });
|
|
1563
|
+
}
|
|
1564
|
+
} else if (cutOff) {
|
|
1565
|
+
save({ status: 'Failed', error: cutOff, usage, resultText, endedAt: new Date().toISOString() });
|
|
1566
|
+
} else {
|
|
1567
|
+
save({ status: 'Done', usage, resultText, endedAt: new Date().toISOString() });
|
|
1568
|
+
}
|
|
1569
|
+
if (timerOf) save({ timing: timingOf(timerOf) });
|
|
1570
|
+
store.jobs.put(current, { jobId: job.id, summary: `${job.kind} ${current.status}: ${job.scope.label}` });
|
|
1571
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'done', data: { status: current.status, resultText: current.resultText, messageId, savedResults: current.savedResults, steps: current.steps, kind: current.kind, requestBasis: current.requestBasis, leafId, conversation: task.conversation === true, result: (current.task as { extra?: { result?: unknown } }).extra?.result ?? null } });
|
|
1572
|
+
if (switched) this.requeueWaiting(projectId);
|
|
1573
|
+
// After the job is written: requeued before, the job's own last writes put it back to waiting, and nothing took it again.
|
|
1574
|
+
if (rateLimited) this.onRateLimit(projectId, current.model?.provider ?? null);
|
|
1575
|
+
} catch (error) {
|
|
1576
|
+
const message = this.ownKeys.redact(error instanceof Error ? error.message : String(error));
|
|
1577
|
+
const kind = classifyError(message);
|
|
1578
|
+
this.lastError.set(projectId, { at: new Date().toISOString(), message, kind });
|
|
1579
|
+
const paused = aborted && pausing && !timedOut;
|
|
1580
|
+
const moved = aborted && Boolean(lane.keyRemoved) && this.keyChoices(projectId).some((k) => k.provider !== current.model?.provider && this.keyUsable(k.provider));
|
|
1581
|
+
const status: JobStatus = paused ? (organizingHeld(this.projectOf(projectId)) ? 'Paused' : 'Queued') : moved ? 'Queued' : aborted && lane.keyRemoved ? 'Waiting for quota' : aborted ? 'Stopped' : kind === 'error' ? 'Failed' : 'Waiting for quota';
|
|
1582
|
+
save({ status, error: paused || moved ? null : message, endedAt: status === 'Waiting for quota' || paused || moved ? null : new Date().toISOString(), ...(status === 'Waiting for quota' || moved ? interrupted() : paused && session?.sessionFile && !sessionKey ? { resume: { why: 'pause' as const, since: new Date().toISOString() } } : {}) });
|
|
1583
|
+
if (timerOf) save({ timing: timingOf(timerOf) });
|
|
1584
|
+
store.jobs.put(current, { jobId: job.id, summary: `${job.kind} ${status}: ${message.slice(0, 120)}` });
|
|
1585
|
+
if (status === 'Waiting for quota' && kind === 'ratelimit') {
|
|
1586
|
+
this.onRateLimit(projectId, current.model?.provider ?? null);
|
|
1587
|
+
} else if (status === 'Waiting for quota') {
|
|
1588
|
+
switched = kind === 'quota' && await this.onQuotaExhausted(projectId, current.model?.provider ?? null, message);
|
|
1589
|
+
if (!switched) this.scheduleQuotaRetry(projectId);
|
|
1590
|
+
}
|
|
1591
|
+
this.emitEvent({ projectId, jobId: job.id, kind: 'done', data: { status, error: message } });
|
|
1592
|
+
if (switched) this.requeueWaiting(projectId);
|
|
1593
|
+
} finally {
|
|
1594
|
+
try { unsubscribe?.(); } catch { /* ignore */ }
|
|
1595
|
+
restoreStop?.();
|
|
1596
|
+
if (!sessionKey) { try { session?.dispose(); } catch { /* ignore */ } }
|
|
1597
|
+
// A provider's error may quote the key it was sent, and pi records the error in the session file: a key saved here
|
|
1598
|
+
// is taken out of the record (§3.1: no key value in session records).
|
|
1599
|
+
if (session?.sessionFile) this.scrubKeys(session.sessionFile);
|
|
1600
|
+
lane.running = null; lane.session = null; lane.abort = null; lane.pause = null; lane.keyRemoved = null;
|
|
1601
|
+
this.extraTools.delete(job.id);
|
|
1602
|
+
void store.flush();
|
|
1603
|
+
setImmediate(() => this.pump(projectId));
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* A rate limit (429): that key rests RATE_LIMIT_REST_MS before it is tried again, and the work waiting on it is queued
|
|
1609
|
+
* again at once, so the other keys take it (owner 2026-09-28: 「3个key都可以用,试了可以用就用,限流了过10分钟再试一下」).
|
|
1610
|
+
* When every key rests, the work waits for the first one back. A 429 here is often the owner's own agents sharing the
|
|
1611
|
+
* account, not a broken key. The lane state shows which key rests (§6.9).
|
|
1612
|
+
*/
|
|
1613
|
+
private onRateLimit(projectId: string, provider: string | null): void {
|
|
1614
|
+
const p = provider ?? 'unknown';
|
|
1615
|
+
const at = Date.now();
|
|
1616
|
+
this.lastRateLimitAt.set(p, at);
|
|
1617
|
+
this.restingUntil.set(p, at + RATE_LIMIT_REST_MS);
|
|
1618
|
+
const store = this.storeOf(projectId);
|
|
1619
|
+
for (const job of store.jobs.filter((j) => j.status === 'Waiting for quota' && classifyError(j.error ?? '') === 'ratelimit')) {
|
|
1620
|
+
store.jobs.put({ ...job, status: 'Queued' }, { jobId: job.id, summary: `Queued again: ${p} rests ${RATE_LIMIT_REST_MS / 60_000} minutes after a rate limit; another key takes it` });
|
|
1621
|
+
}
|
|
1622
|
+
setImmediate(() => this.pump(projectId));
|
|
1623
|
+
this.wakeAfterRest();
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** When the first resting key's rest is over, run the pump for every project; then wait for the next one, if any. */
|
|
1627
|
+
private wakeAfterRest(): void {
|
|
1628
|
+
if (this.restWakeTimer) clearTimeout(this.restWakeTimer);
|
|
1629
|
+
this.restWakeTimer = null;
|
|
1630
|
+
const pending = [...this.restingUntil.values()].filter((u) => u > Date.now());
|
|
1631
|
+
if (!pending.length) return;
|
|
1632
|
+
this.restWakeTimer = setTimeout(() => {
|
|
1633
|
+
this.restWakeTimer = null;
|
|
1634
|
+
for (const w of this.workspace.list()) this.pump(w.id);
|
|
1635
|
+
this.wakeAfterRest();
|
|
1636
|
+
}, Math.min(...pending) - Date.now() + 1_000);
|
|
1637
|
+
this.restWakeTimer.unref?.();
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
private scheduleQuotaRetry(projectId: string): void {
|
|
1641
|
+
if (this.quotaRetryTimers.has(projectId)) return;
|
|
1642
|
+
const timer = setTimeout(() => {
|
|
1643
|
+
this.quotaRetryTimers.delete(projectId);
|
|
1644
|
+
const store = this.storeOf(projectId);
|
|
1645
|
+
for (const job of store.jobs.filter((j) => j.status === 'Waiting for quota')) store.jobs.put({ ...job, status: 'Queued' });
|
|
1646
|
+
this.pump(projectId);
|
|
1647
|
+
}, 5 * 60_000);
|
|
1648
|
+
timer.unref?.();
|
|
1649
|
+
this.quotaRetryTimers.set(projectId, timer);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* Run a subagent for a job that is running now, and wait for it (D59 design 1). The subagent belongs to the work
|
|
1654
|
+
* that sent it: it inherits the parent's lane kind, so pausing automatic organizing holds the subagents too and a
|
|
1655
|
+
* Stop on the parent reaches them; owner work's subagents run in lanes of their own on the chosen model (see
|
|
1656
|
+
* `pumpNow`). It queues just ahead of its parent's peers so the parent is not left waiting behind a fresh batch of
|
|
1657
|
+
* other work. Until 2026-09-20 every investigation was enqueued as owner work, competing with the owner's chat.
|
|
1658
|
+
*/
|
|
1659
|
+
private async delegate(projectId: string, parentJobId: string, request: JobRequest): Promise<KeeperJob> {
|
|
1660
|
+
return this.delegated(projectId, parentJobId, { enqueue: request }, 'done').ended;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* A subagent of a running job, queued (`enqueue`), queued again (`restart`: a lane that failed or was interrupted) or
|
|
1665
|
+
* already queued (`wait`), and the parent parked until it answers. `until`: `done` settles on the first `done` event, as
|
|
1666
|
+
* an investigation always has; `end` settles only once it has ended (`waitForEnd`) — a lane the provider interrupted is
|
|
1667
|
+
* moved to another key and still waited for (D99).
|
|
1668
|
+
*/
|
|
1669
|
+
private delegated(projectId: string, parentJobId: string, what: { readonly enqueue: JobRequest } | { readonly restart: string } | { readonly wait: string }, until: 'done' | 'end'): { readonly job: KeeperJob; readonly ended: Promise<KeeperJob> } {
|
|
1670
|
+
const store = this.storeOf(projectId);
|
|
1671
|
+
const parent = store.jobs.get(parentJobId);
|
|
1672
|
+
let job: KeeperJob;
|
|
1673
|
+
if ('enqueue' in what) {
|
|
1674
|
+
job = this.enqueue(projectId, {
|
|
1675
|
+
...what.enqueue, initiator: parent?.initiator ?? what.enqueue.initiator, parentJobId, priority: (parent?.priority ?? 1) - 1,
|
|
1676
|
+
bypassHold: parent?.status === 'Running',
|
|
1677
|
+
});
|
|
1678
|
+
} else {
|
|
1679
|
+
const id = 'restart' in what ? what.restart : what.wait;
|
|
1680
|
+
if (parent?.status === 'Running') this.bypassing.add(id);
|
|
1681
|
+
if ('restart' in what) this.restartJob(projectId, id);
|
|
1682
|
+
job = store.jobs.get(id)!;
|
|
1683
|
+
}
|
|
1684
|
+
// The parent is waiting, not working: its lane carries other work meanwhile, so a main job with several subagents
|
|
1685
|
+
// does not hold lanes idle — and a round cannot wait on itself when its subagents outnumber the lanes. The lane is
|
|
1686
|
+
// released by the last subagent to answer, and only while it still holds the same parent.
|
|
1687
|
+
const parentLane = this.allLanes(projectId).find((l) => l.running?.id === parentJobId);
|
|
1688
|
+
if (parentLane) { parentLane.parked = (parentLane.parked ?? 0) + 1; setImmediate(() => this.pump(projectId)); }
|
|
1689
|
+
const waited = until === 'end' ? this.waitForEnd(projectId, job.id) : this.waitFor(projectId, job.id);
|
|
1690
|
+
const ended = waited.finally(() => { if (parentLane && parentLane.running?.id === parentJobId) parentLane.parked = Math.max(0, (parentLane.parked ?? 1) - 1); });
|
|
1691
|
+
return { job, ended };
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
/** How the main job's lanes run (D99; lane-tools.ts `pk_send_lanes`): through `delegate`, waited for until each has ended. */
|
|
1695
|
+
private laneRunner(projectId: string, mainJobId: string): LaneRunner {
|
|
1696
|
+
return {
|
|
1697
|
+
send: (request) => this.delegated(projectId, mainJobId, { enqueue: { kind: 'Organizing', initiator: 'auto', scope: request.scope, prompt: request.prompt, step: request.step, task: request.task, timeoutMs: request.timeoutMs } }, 'end'),
|
|
1698
|
+
again: (jobId) => this.delegated(projectId, mainJobId, { restart: jobId }, 'end').ended,
|
|
1699
|
+
wait: (jobId) => this.delegated(projectId, mainJobId, { wait: jobId }, 'end').ended,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** A focused investigation: its own session, returns the conclusion and the sources it cites. */
|
|
1704
|
+
async investigate(projectId: string, parentJobId: string, question: string, hints: string[]): Promise<{ conclusion: string; sourceIds: string[]; jobId: string }> {
|
|
1705
|
+
const done = await this.delegate(projectId, parentJobId, {
|
|
1706
|
+
kind: 'Investigation', initiator: 'owner', scope: { kind: 'question', ids: [], label: question.slice(0, 80) },
|
|
1707
|
+
prompt: `Investigate this question for the parent job ${parentJobId} and answer it with evidence.\n\nQuestion: ${question}\n${hints.length ? `Hints: ${hints.join('; ')}\n` : ''}\nRead what you need (sources via pk_list_sources / pk_read_source, project files, code, git history, commands that change nothing). Record any fact you establish that is not yet in the assets with pk_write_fact_record. End with a concise conclusion; list the source ids it rests on as "Sources: id, id".`,
|
|
1708
|
+
});
|
|
1709
|
+
// A subagent that did not finish says so in its place — failed, stopped, or waiting for quota — and what it had written
|
|
1710
|
+
// comes after, marked as unfinished: the job that sent it can send the question again or carry on without it, instead
|
|
1711
|
+
// of reading an empty or half answer as the answer (D59 design 1; QC AH #10: one waiting for quota came back empty).
|
|
1712
|
+
const ended = done.status === 'Done' ? null
|
|
1713
|
+
: done.status === 'Failed' ? `This investigation failed and has no finished answer (${done.error ?? 'no reason recorded'}). Ask again if the answer matters, or say in your reply that it is missing.`
|
|
1714
|
+
: done.status === 'Stopped' ? 'This investigation was stopped before it finished. Say in your reply that its answer is missing.'
|
|
1715
|
+
: done.status === 'Waiting for quota' ? `This investigation is waiting for quota (${done.error ?? 'the provider refused it'}) and has not answered. It runs again when the quota returns and records what it finds in the assets, but its answer does not come back to this job: say in your reply that it is missing, or ask again later.`
|
|
1716
|
+
: `This investigation ended ${done.status} without an answer. Say in your reply that it is missing.`;
|
|
1717
|
+
const text = ended ? `${ended}${done.resultText ? `\nWhat it had written when it ended, not a finished answer:\n${done.resultText}` : ''}` : done.resultText ?? '';
|
|
1718
|
+
const m = /Sources?:\s*([\s\S]+)$/i.exec(text);
|
|
1719
|
+
const sourceIds = m ? (m[1]!.match(/src_[0-9a-f]{16}/g) ?? []) : [];
|
|
1720
|
+
return { conclusion: text, sourceIds: [...new Set(sourceIds)], jobId: done.id };
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Wait until a job has ended (D99): Done, Failed, or Stopped with nothing to go on with. Unlike `waitFor`, `Waiting for
|
|
1725
|
+
* quota` does not end it — the provider interrupted it and it moves to another key (BU) — nor does a pause.
|
|
1726
|
+
*/
|
|
1727
|
+
waitForEnd(projectId: string, jobId: string): Promise<KeeperJob> {
|
|
1728
|
+
const store = this.storeOf(projectId);
|
|
1729
|
+
const ended = (j: KeeperJob | undefined): j is KeeperJob => !!j && (j.status === 'Done' || j.status === 'Failed' || (j.status === 'Stopped' && !j.resume));
|
|
1730
|
+
return new Promise((resolve, reject) => {
|
|
1731
|
+
const first = store.jobs.get(jobId);
|
|
1732
|
+
if (!first) { reject(new Error(`No job ${jobId}`)); return; }
|
|
1733
|
+
if (ended(first)) { resolve(first); return; }
|
|
1734
|
+
const check = (e: KeeperEvent) => {
|
|
1735
|
+
if (e.projectId !== projectId || e.jobId !== jobId || (e.kind !== 'done' && e.kind !== 'job')) return;
|
|
1736
|
+
const j = store.jobs.get(jobId);
|
|
1737
|
+
if (ended(j)) { this.off('event', check); resolve(j); }
|
|
1738
|
+
};
|
|
1739
|
+
this.on('event', check);
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
waitFor(projectId: string, jobId: string): Promise<KeeperJob> {
|
|
1744
|
+
return new Promise((resolve) => {
|
|
1745
|
+
const check = (e: KeeperEvent) => {
|
|
1746
|
+
if (e.projectId === projectId && e.jobId === jobId && e.kind === 'done') {
|
|
1747
|
+
this.off('event', check);
|
|
1748
|
+
resolve(this.storeOf(projectId).jobs.get(jobId)!);
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
this.on('event', check);
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
private usageOf(session: AgentSession): Usage {
|
|
1756
|
+
try {
|
|
1757
|
+
const s = session.getSessionStats();
|
|
1758
|
+
return { input: s.tokens.input, output: s.tokens.output, cacheRead: s.tokens.cacheRead, cacheWrite: s.tokens.cacheWrite, cost: Number.isFinite(s.cost) ? s.cost : null };
|
|
1759
|
+
} catch {
|
|
1760
|
+
return zeroUsage();
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
private async contentSnapshot(project: Project): Promise<Map<string, string>> {
|
|
1765
|
+
const out = new Map<string, string>();
|
|
1766
|
+
const repos = project.scope.filter((i) => i.category === 'Repository' && i.relation !== 'Excluded' && !i.missing);
|
|
1767
|
+
const states = await Promise.all(repos.map(async (repo) => [repo.path, await gitStatusAsync(repo.path)] as const));
|
|
1768
|
+
for (const [path, st] of states) if (st) out.set(path, `${st.head}|${st.dirty.join('\n')}`);
|
|
1769
|
+
return out;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
private contentDiff(before: Map<string, string>, after: Map<string, string>): string[] {
|
|
1773
|
+
const out: string[] = [];
|
|
1774
|
+
for (const [path, value] of after) if (before.get(path) !== value) out.push(`${path}: git state changed`);
|
|
1775
|
+
return out;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
resourceChangesOf(projectId: string): readonly { at: string; jobId: string; detail: string }[] { return this.resourceChanges.get(projectId) ?? []; }
|
|
1779
|
+
|
|
1780
|
+
/** §6.10 resources pi loads for this project, and whether project-local ones are trusted. */
|
|
1781
|
+
async resources(project: Project): Promise<{ resources: { kind: string; name: string; path: string | null }[]; projectTrusted: boolean | null; trustDecision: string }> {
|
|
1782
|
+
const cwd = project.locations[0]!;
|
|
1783
|
+
const settingsManager = SettingsManager.create(cwd, getAgentDir());
|
|
1784
|
+
const loader = new DefaultResourceLoader({ cwd, agentDir: getAgentDir(), settingsManager });
|
|
1785
|
+
await loader.reload();
|
|
1786
|
+
const resources: { kind: string; name: string; path: string | null }[] = [];
|
|
1787
|
+
const base = (p: string) => p.split(/[\\/]/).pop() ?? p;
|
|
1788
|
+
const ext = loader.getExtensions();
|
|
1789
|
+
for (const e of ext.extensions) if (!e.hidden) resources.push({ kind: 'extension', name: base(e.path), path: e.resolvedPath ?? e.path });
|
|
1790
|
+
for (const err of ext.errors) resources.push({ kind: 'extension error', name: err.error, path: err.path });
|
|
1791
|
+
for (const s of loader.getSkills().skills) resources.push({ kind: 'skill', name: s.name, path: s.filePath });
|
|
1792
|
+
for (const p of loader.getPrompts().prompts as { name: string; filePath?: string }[]) resources.push({ kind: 'prompt template', name: p.name, path: p.filePath ?? null });
|
|
1793
|
+
for (const f of loader.getAgentsFiles().agentsFiles) resources.push({ kind: 'context file', name: base(f.path), path: f.path });
|
|
1794
|
+
const sys = loader.getSystemPromptSource();
|
|
1795
|
+
if (sys) resources.push({ kind: 'system prompt', name: base(sys.path), path: sys.path });
|
|
1796
|
+
for (const a of loader.getAppendSystemPromptSources()) resources.push({ kind: 'appended system prompt', name: base(a.path), path: a.path });
|
|
1797
|
+
let trustDecision = 'unknown';
|
|
1798
|
+
try { const d = new ProjectTrustStore(getAgentDir()).get(cwd); trustDecision = d == null ? 'not decided yet' : String(d); } catch { /* keep unknown */ }
|
|
1799
|
+
return { resources, projectTrusted: trustDecision === 'yes' ? true : trustDecision === 'no' ? false : null, trustDecision };
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
capabilities() { return PI_CAPABILITIES; }
|
|
1803
|
+
|
|
1804
|
+
/** Launch native pi in the project directory, resuming a Keeper session when one is given. */
|
|
1805
|
+
openInPi(project: Project, sessionFile: string | null): { message: string } {
|
|
1806
|
+
const cwd = project.locations[0]!;
|
|
1807
|
+
// Off Windows: Terminal on macOS, and elsewhere the command to run — never a claim that something opened when
|
|
1808
|
+
// nothing did (open-in-pi.ts).
|
|
1809
|
+
if (process.platform !== 'win32') return openInPiOffWindows(cwd, sessionFile);
|
|
1810
|
+
const args = ['pi', ...(sessionFile ? ['--session', sessionFile] : [])];
|
|
1811
|
+
try {
|
|
1812
|
+
spawn('cmd.exe', ['/c', 'start', '"ProjectKeeper · pi"', 'cmd', '/k', ...args], { cwd, detached: true, stdio: 'ignore', windowsHide: false, shell: false }).unref();
|
|
1813
|
+
return { message: `Opened pi in ${cwd}${sessionFile ? ' with the Keeper session' : ''}` };
|
|
1814
|
+
} catch (e) {
|
|
1815
|
+
return { message: `Could not open pi: ${(e as Error).message}` };
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
usageSummary(projectId: string): { total: Usage; byJob: { id: string; kind: string; label: string; startedAt: string | null; model: string | null; usage: Usage }[] } {
|
|
1820
|
+
const jobs = this.storeOf(projectId).jobs.all();
|
|
1821
|
+
const total = jobs.reduce<Usage>((acc, j) => ({ input: acc.input + j.usage.input, output: acc.output + j.usage.output, cacheRead: acc.cacheRead + j.usage.cacheRead, cacheWrite: acc.cacheWrite + j.usage.cacheWrite, cost: j.usage.cost == null ? acc.cost : (acc.cost ?? 0) + j.usage.cost }), zeroUsage());
|
|
1822
|
+
return { total, byJob: jobs.filter((j) => j.startedAt).sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? '')).slice(0, 100).map((j) => ({ id: j.id, kind: j.kind, label: j.scope.label, startedAt: j.startedAt, model: j.model ? `${j.model.provider}/${j.model.id}` : null, usage: j.usage })) };
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Stop everything the Keeper is doing for a project and wait until what was running has ended (Spec §3.7 清空:
|
|
1827
|
+
* 有整理正在进行时,先停下再清). What had not started is stopped where it stands; what runs is aborted, and this
|
|
1828
|
+
* returns once no lane of the project holds a job, or after `timeoutMs`. Returns how many jobs it stopped.
|
|
1829
|
+
*/
|
|
1830
|
+
async stopProject(projectId: string, timeoutMs = 60_000): Promise<number> {
|
|
1831
|
+
const store = this.storeOf(projectId);
|
|
1832
|
+
const active = store.jobs.filter((j) => j.status === 'Queued' || j.status === 'Running' || j.status === 'Paused' || j.status === 'Waiting for quota');
|
|
1833
|
+
for (const j of active) {
|
|
1834
|
+
const current = store.jobs.get(j.id);
|
|
1835
|
+
if (!current || !(current.status === 'Queued' || current.status === 'Running' || current.status === 'Paused' || current.status === 'Waiting for quota')) continue;
|
|
1836
|
+
// A record of the program's own work (a round, its ledger step) is in no lane: it is closed where it stands.
|
|
1837
|
+
if (!this.stopJob(projectId, j.id) && current.agent === 'program') store.jobs.put({ ...current, status: 'Stopped', endedAt: new Date().toISOString() }, { jobId: j.id, summary: 'Stopped: the project is being cleared' });
|
|
1838
|
+
}
|
|
1839
|
+
const busy = () => (this.lanes.has(projectId) ? this.allLanes(projectId).some((l) => l.running !== null) : false);
|
|
1840
|
+
const until = Date.now() + timeoutMs;
|
|
1841
|
+
while (busy() && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
|
|
1842
|
+
return active.length;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** Drop what the runtime holds in memory for a project whose assets were cleared: its live conversations and its notices. */
|
|
1846
|
+
forgetProject(projectId: string): void {
|
|
1847
|
+
for (const [key, entry] of [...this.live]) {
|
|
1848
|
+
if (!key.startsWith(`conversation:${projectId}:`)) continue;
|
|
1849
|
+
try { entry.session.dispose(); } catch { /* already gone */ }
|
|
1850
|
+
this.live.delete(key);
|
|
1851
|
+
}
|
|
1852
|
+
this.resourceChanges.delete(projectId);
|
|
1853
|
+
this.lastError.delete(projectId);
|
|
1854
|
+
this.emitEvent({ projectId, jobId: '', kind: 'status' });
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
private emitEvent(event: KeeperEvent): void { this.emit('event', event); }
|
|
1858
|
+
}
|