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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session drafts (Spec v3.0 §3.11, §1.4; D88; CKC-23 AC-18) as the owner drills into a session: one draft per
|
|
3
|
+
* session — the owner's lines verbatim with their kind, what a confirmation answers and what it confirms, the agents'
|
|
4
|
+
* intents and reports as claims — openable wherever a session is reached: from a work item's (or any object's) session
|
|
5
|
+
* sources, and from the session-drafts step of the round that wrote it.
|
|
6
|
+
*
|
|
7
|
+
* GET /api/projects/:id/drafts → { drafts: SessionDraftRef[] } every draft, by when its session began
|
|
8
|
+
* GET /api/projects/:id/drafts?objectId=… → { drafts: SessionDraftRef[] } the sessions among an object's sources,
|
|
9
|
+
* a session not drafted yet listed with `id: null`
|
|
10
|
+
* GET /api/projects/:id/drafts/:key → SessionDraftView key: the draft's id, a session source's
|
|
11
|
+
* id, `<host>:<sessionId>`, or a session id
|
|
12
|
+
*
|
|
13
|
+
* Read only: the drafts are written by the round's session-drafts step (keeper/clerk-tools.ts `pk_write_session_draft`).
|
|
14
|
+
* `registerDraftRoutes` is registered with the other routes (server/api.ts).
|
|
15
|
+
*/
|
|
16
|
+
import { stableId } from '../model/ids.ts';
|
|
17
|
+
import type { SessionDraft } from '../model/k-types.ts';
|
|
18
|
+
import type { Source } from '../model/types.ts';
|
|
19
|
+
import type { SessionDraftRef, SessionDraftView } from '../model/views-k.ts';
|
|
20
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
21
|
+
import type { App } from './app.ts';
|
|
22
|
+
import { HttpError, optionalString, type HttpApp } from './http.ts';
|
|
23
|
+
|
|
24
|
+
type SessionAnchorOf = Extract<Source['anchor'], { kind: 'session' }>;
|
|
25
|
+
|
|
26
|
+
/** The sources that hold one session (its message ranges), in the session's order. */
|
|
27
|
+
function sessionSources(store: ProjectStore, host: string, sessionId: string): Source[] {
|
|
28
|
+
const start = (s: Source) => (s.anchor.kind === 'session' ? s.anchor.messageStart : 0);
|
|
29
|
+
return store.sources.filter((s) => s.anchor.kind === 'session' && s.anchor.host === host && s.anchor.sessionId === sessionId).sort((a, b) => start(a) - start(b));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const draftIdOf = (projectId: string, host: string, sessionId: string): string => stableId('draft', projectId, host, sessionId);
|
|
33
|
+
|
|
34
|
+
function countsOf(d: SessionDraft) {
|
|
35
|
+
const lines = d.ownerLines;
|
|
36
|
+
return {
|
|
37
|
+
lines: lines.length, chat: lines.filter((l) => l.kind === 'Chat').length, decisions: lines.filter((l) => l.kind === 'Decision').length,
|
|
38
|
+
confirmations: lines.filter((l) => l.kind === 'Confirmation').length, unjudged: lines.filter((l) => l.kind === null).length,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A draft as a round step or an object lists it. */
|
|
43
|
+
export function draftRef(store: ProjectStore, d: SessionDraft): SessionDraftRef {
|
|
44
|
+
const c = countsOf(d);
|
|
45
|
+
return {
|
|
46
|
+
id: d.id, host: d.session.host, sessionId: d.session.sessionId, startedAt: d.session.startedAt || null, endedAt: d.session.endedAt || null,
|
|
47
|
+
ownerLines: c.lines, decisions: c.decisions, confirmations: c.confirmations, chat: c.chat, unjudged: c.unjudged, agentSummaries: d.agentSummary.length,
|
|
48
|
+
sourceIds: sessionSources(store, d.session.host, d.session.sessionId).map((s) => s.id), at: d.at,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A session not drafted yet, as a list shows it: its sources, and no draft. */
|
|
53
|
+
function undraftedRef(store: ProjectStore, host: string, sessionId: string): SessionDraftRef {
|
|
54
|
+
const sources = sessionSources(store, host, sessionId);
|
|
55
|
+
const times = sources.map((s) => (s.anchor.kind === 'session' ? s.anchor.at : null)).filter((t): t is string => Boolean(t)).sort();
|
|
56
|
+
return {
|
|
57
|
+
id: null, host, sessionId, startedAt: times[0] ?? null, endedAt: times[times.length - 1] ?? null,
|
|
58
|
+
ownerLines: 0, decisions: 0, confirmations: 0, chat: 0, unjudged: 0, agentSummaries: 0, sourceIds: sources.map((s) => s.id), at: null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const byStart = (a: SessionDraftRef, b: SessionDraftRef) => (a.startedAt ?? '').localeCompare(b.startedAt ?? '') || a.sessionId.localeCompare(b.sessionId);
|
|
63
|
+
|
|
64
|
+
/** Every job that wrote a draft (`writtenBy`), or, for a draft written before that was kept, the one job known. */
|
|
65
|
+
function writersOf(d: SessionDraft): readonly { readonly jobId: string | null; readonly roundId: string | null; readonly at: string }[] {
|
|
66
|
+
return d.writtenBy?.length ? d.writtenBy : [{ jobId: d.jobId, roundId: null, at: d.at }];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The drafts a job wrote — the session-drafts step of a round — including those a later round drafted again. */
|
|
70
|
+
export function draftRefsOfJob(store: ProjectStore, jobId: string): SessionDraftRef[] {
|
|
71
|
+
return store.drafts.filter((d) => writersOf(d).some((w) => w.jobId === jobId)).map((d) => draftRef(store, d)).sort(byStart);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The session source ids an object stands on: its own, its graph node's, a work item's fact records' and statements'. */
|
|
75
|
+
function sourceIdsOf(store: ProjectStore, objectId: string): string[] | null {
|
|
76
|
+
const node = store.nodes.get(objectId);
|
|
77
|
+
const ref = store.reference.get(node?.refKind === 'reference' ? node.refId : objectId);
|
|
78
|
+
const thread = store.threads.get(node?.refKind === 'thread' ? node.refId : objectId);
|
|
79
|
+
if (!node && !ref && !thread) return null;
|
|
80
|
+
const ids = new Set<string>([...(node?.sourceIds ?? []), ...(ref?.sourceIds ?? [])]);
|
|
81
|
+
if (thread) {
|
|
82
|
+
for (const s of [...thread.executionFacts, ...thread.qcFacts]) for (const id of s.sourceIds) ids.add(id);
|
|
83
|
+
for (const f of thread.factRecordIds.flatMap((id) => store.facts.get(id) ?? [])) {
|
|
84
|
+
for (const id of f.aboutSourceIds) ids.add(id);
|
|
85
|
+
for (const s of [...f.statements, ...f.executionFacts]) for (const id of s.sourceIds) ids.add(id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return [...ids];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The sessions among an object's sources, each with its draft — a session not drafted yet is listed with `id: null` so
|
|
93
|
+
* the owner sees it exists and has no draft. Null when the object is not in the assets.
|
|
94
|
+
*/
|
|
95
|
+
export function sessionsOfObject(store: ProjectStore, projectId: string, objectId: string): SessionDraftRef[] | null {
|
|
96
|
+
const ids = sourceIdsOf(store, objectId);
|
|
97
|
+
if (!ids) return null;
|
|
98
|
+
const sessions = new Map<string, { host: string; sessionId: string }>();
|
|
99
|
+
for (const id of ids) {
|
|
100
|
+
const a = store.sources.get(id)?.anchor;
|
|
101
|
+
if (a?.kind === 'session') sessions.set(`${a.host}\x1f${a.sessionId}`, { host: a.host, sessionId: a.sessionId });
|
|
102
|
+
}
|
|
103
|
+
return [...sessions.values()].map(({ host, sessionId }) => {
|
|
104
|
+
const d = store.drafts.get(draftIdOf(projectId, host, sessionId)) ?? store.drafts.find((x) => x.session.host === host && x.session.sessionId === sessionId);
|
|
105
|
+
return d ? draftRef(store, d) : undraftedRef(store, host, sessionId);
|
|
106
|
+
}).sort(byStart);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A draft by its id, a session source's id, `<host>:<sessionId>`, or a session id (unique among the drafts). */
|
|
110
|
+
export function findDraft(store: ProjectStore, projectId: string, key: string): SessionDraft | null {
|
|
111
|
+
const k = key.trim();
|
|
112
|
+
if (!k) return null;
|
|
113
|
+
const direct = store.drafts.get(k);
|
|
114
|
+
if (direct) return direct;
|
|
115
|
+
const a = store.sources.get(k)?.anchor as SessionAnchorOf | undefined;
|
|
116
|
+
if (a?.kind === 'session') return store.drafts.get(draftIdOf(projectId, a.host, a.sessionId)) ?? store.drafts.find((d) => d.session.host === a.host && d.session.sessionId === a.sessionId) ?? null;
|
|
117
|
+
const pair = /^([a-z]+):(.+)$/.exec(k);
|
|
118
|
+
if (pair) {
|
|
119
|
+
const found = store.drafts.find((d) => d.session.host === pair[1] && d.session.sessionId === pair[2]);
|
|
120
|
+
if (found) return found;
|
|
121
|
+
}
|
|
122
|
+
const bySession = store.drafts.filter((d) => d.session.sessionId === k || d.session.sessionId.startsWith(k));
|
|
123
|
+
return bySession.length === 1 ? bySession[0]! : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One draft as the owner reads it: every owner line verbatim with its kind (unclassified ones say so), what a confirmation
|
|
128
|
+
* answers — the agent message, verbatim — and what it confirms, the source that holds each line for its original, and
|
|
129
|
+
* the agents' summaries as claims (§2.4: who and when; never facts).
|
|
130
|
+
*/
|
|
131
|
+
export function draftView(store: ProjectStore, d: SessionDraft): SessionDraftView {
|
|
132
|
+
const sources = sessionSources(store, d.session.host, d.session.sessionId);
|
|
133
|
+
const holder = (ref: string): string | null => {
|
|
134
|
+
const n = /^\[?(\d+)\]?$/.exec(ref.trim())?.[1];
|
|
135
|
+
if (n === undefined) return null;
|
|
136
|
+
const i = Number(n);
|
|
137
|
+
return sources.find((s) => s.anchor.kind === 'session' && s.anchor.messageStart <= i && i <= s.anchor.messageEnd)?.id ?? null;
|
|
138
|
+
};
|
|
139
|
+
const roundLabel = (roundId: string | null): string | null => {
|
|
140
|
+
const r = roundId ? store.clerkRounds.get(roundId) : undefined;
|
|
141
|
+
return r ? `${r.kind} · round ${r.number}` : null;
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
id: d.id,
|
|
145
|
+
session: { host: d.session.host, sessionId: d.session.sessionId, file: d.session.file, startedAt: d.session.startedAt || null, endedAt: d.session.endedAt || null },
|
|
146
|
+
ownerLines: d.ownerLines.map((l) => ({ ref: l.ref, at: l.at || null, text: l.text, kind: l.kind, answers: l.answers, confirms: l.confirms, sourceId: holder(l.ref) })),
|
|
147
|
+
agentSummary: d.agentSummary.map((s) => ({ at: s.at || null, who: s.who, summary: s.summary, basis: 'Claimed' as const })),
|
|
148
|
+
counts: countsOf(d), sourceIds: sources.map((s) => s.id), at: d.at,
|
|
149
|
+
writtenBy: writersOf(d).map((w) => ({ jobId: w.jobId, roundId: w.roundId, roundLabel: roundLabel(w.roundId ?? (w.jobId ? store.jobs.get(w.jobId)?.step?.roundId ?? null : null)), at: w.at })),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function registerDraftRoutes(http: HttpApp, app: App): void {
|
|
154
|
+
http.route('GET', '/api/projects/:id/drafts', ({ params, query }) => {
|
|
155
|
+
const store = app.store(params.id!);
|
|
156
|
+
const objectId = optionalString(query.get('objectId'));
|
|
157
|
+
if (objectId) {
|
|
158
|
+
const sessions = sessionsOfObject(store, params.id!, objectId);
|
|
159
|
+
if (!sessions) throw new HttpError(404, `Unknown object ${objectId}`);
|
|
160
|
+
return { drafts: sessions };
|
|
161
|
+
}
|
|
162
|
+
return { drafts: store.drafts.all().map((d) => draftRef(store, d)).sort(byStart) };
|
|
163
|
+
});
|
|
164
|
+
http.route('GET', '/api/projects/:id/drafts/:key', ({ params }) => {
|
|
165
|
+
const store = app.store(params.id!);
|
|
166
|
+
const d = findDraft(store, params.id!, params.key!);
|
|
167
|
+
if (!d) throw new HttpError(404, `No session draft for ${params.key}`);
|
|
168
|
+
return draftView(store, d);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/folders` — the folders of one directory, for the workbench's folder chooser (`ui/folder-chooser.js`): where a
|
|
3
|
+
* location is typed (Add project, a scope item's path), `Browse…` lets the owner walk to it instead.
|
|
4
|
+
*
|
|
5
|
+
* ?path=<a whole path> the directory to list; none is the home directory. `~` is the home directory too.
|
|
6
|
+
* ?hidden=1 hidden folders as well (off by default: a leading dot; on Windows the hidden attribute too)
|
|
7
|
+
* ?added=<path> repeated: paths the page already holds, so the folders among them can be marked
|
|
8
|
+
*
|
|
9
|
+
* It reads and changes nothing, and it lists folders only, never files. It does tell whoever may call it the folder
|
|
10
|
+
* names of the whole machine, so it stands behind the same check as every route (http.ts `refusal`): the workbench's own
|
|
11
|
+
* pages and programs on this machine, nobody else.
|
|
12
|
+
*
|
|
13
|
+
* What it must not do is hang or grow:
|
|
14
|
+
* - A directory with a great many entries is read in one call, and at most `SHOWN` folders are sent back, in name
|
|
15
|
+
* order, with the number left out. Only those sent are looked into (one `lstat` each, for `.git`), within a budget.
|
|
16
|
+
* - It never walks down. A link (a junction on Windows) is followed once, to see that it leads to a directory, and a
|
|
17
|
+
* path that is entered is put in the file system's own spelling (util/paths.ts `canonicalPath`), so a link that
|
|
18
|
+
* points back up shows the place it leads to and no path grows by going round.
|
|
19
|
+
* - A drive that does not answer (a network drive whose server is gone) is not waited for: the drives are listed by
|
|
20
|
+
* asking Windows for their letters, which touches none of them, and everything that does touch a drive runs under a
|
|
21
|
+
* time limit. While a drive has not answered, nothing more is asked of it, so the few threads Node reads files on
|
|
22
|
+
* are not all left waiting.
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { lstat, readdir, stat } from 'node:fs/promises';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { basename, dirname, join, parse, sep } from 'node:path';
|
|
28
|
+
import { HttpApp, HttpError } from './http.ts';
|
|
29
|
+
import { canonicalPath, normalizePath, pathKey } from '../util/paths.ts';
|
|
30
|
+
|
|
31
|
+
const WIN = process.platform === 'win32';
|
|
32
|
+
|
|
33
|
+
/** How many folders one answer carries; the rest are counted. */
|
|
34
|
+
export const SHOWN = 500;
|
|
35
|
+
/** How long a directory may take to answer before the chooser is told it has not. */
|
|
36
|
+
const ANSWER_MS = 4000;
|
|
37
|
+
/** How long looking for `.git` in the folders shown, and following the links among the entries, may take together. */
|
|
38
|
+
const MARKS_MS = 600;
|
|
39
|
+
|
|
40
|
+
export interface FolderEntry {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
readonly path: string;
|
|
43
|
+
/** It holds a `.git` entry: a directory (a repository) or a file (a worktree, a submodule). */
|
|
44
|
+
readonly repository: boolean;
|
|
45
|
+
readonly hidden: boolean;
|
|
46
|
+
/** A link or a junction that leads to a directory. */
|
|
47
|
+
readonly link: boolean;
|
|
48
|
+
/** Among the paths the page says it already holds. */
|
|
49
|
+
readonly added: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FolderStart { readonly label: string; readonly path: string; readonly kind: 'home' | 'drive' | 'root' }
|
|
53
|
+
|
|
54
|
+
export interface FolderListing {
|
|
55
|
+
/** The directory, in the file system's own spelling; as it was asked for when it could not be read. */
|
|
56
|
+
readonly path: string;
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/** One level up; null at the top (a drive, `/`, a network share). */
|
|
59
|
+
readonly parent: string | null;
|
|
60
|
+
/** The separator of this system's paths: the page builds none itself. */
|
|
61
|
+
readonly sep: string;
|
|
62
|
+
readonly repository: boolean;
|
|
63
|
+
readonly added: boolean;
|
|
64
|
+
readonly folders: FolderEntry[];
|
|
65
|
+
/** Folders left out of `folders` because there are more than `SHOWN`, links that were not followed in time among them. */
|
|
66
|
+
readonly more: number;
|
|
67
|
+
/** Hidden folders here, shown or not. */
|
|
68
|
+
readonly hidden: number;
|
|
69
|
+
/** Why the directory is not listed; the chooser says it in the list's place. */
|
|
70
|
+
readonly error: string | null;
|
|
71
|
+
readonly starts: FolderStart[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface FolderRequest { readonly path?: string | null; readonly hidden?: boolean; readonly added?: readonly string[] }
|
|
75
|
+
/** For tests: a smaller cap, a shorter wait, another home, a read that never answers. */
|
|
76
|
+
export interface FolderOptions {
|
|
77
|
+
readonly shown?: number;
|
|
78
|
+
readonly answerMs?: number;
|
|
79
|
+
readonly marksMs?: number;
|
|
80
|
+
readonly home?: string;
|
|
81
|
+
readonly readEntries?: typeof readEntries;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ───────────────────────── where to start ─────────────────────────
|
|
85
|
+
|
|
86
|
+
const WHOLE_PATH = WIN ? /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/?.])/ : /^\//;
|
|
87
|
+
|
|
88
|
+
/** A path that says where it is in full, or null: `~` is the home directory, `D:` is the drive's top. */
|
|
89
|
+
function wholePath(text: string, home: string): string | null {
|
|
90
|
+
let path = text.trim();
|
|
91
|
+
if (path === '~' || /^~[\\/]/.test(path)) path = path === '~' ? home : join(home, path.slice(2));
|
|
92
|
+
if (WIN && /^[A-Za-z]:$/.test(path)) path += sep;
|
|
93
|
+
return WHOLE_PATH.test(path) ? normalizePath(path) : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let drivesKnown: string[] | null = null; // the last answer
|
|
97
|
+
let drivesAskedAt = 0;
|
|
98
|
+
let drivesAsking: Promise<string[]> | null = null;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The drive letters Windows knows, as `C:\`. Asked of Windows itself (`GetLogicalDrives`, through the PowerShell every
|
|
102
|
+
* Windows has), which names them without opening any: trying each letter in turn would wait on every drive that is
|
|
103
|
+
* not there. That shell takes a moment to start, so the answer is kept: a listing gets the last one at once, and it is
|
|
104
|
+
* asked again in the background when it is older than a few seconds (a drive plugged in shows on the listing after).
|
|
105
|
+
* Only the first listing of all waits for it, and not long; without an answer, the drives this process stands on.
|
|
106
|
+
*/
|
|
107
|
+
function windowsDrives(home: string): Promise<string[]> {
|
|
108
|
+
const standing = [...new Set([parse(home).root, `${process.env.SystemDrive ?? 'C:'}\\`].map((d) => d.toUpperCase()))].filter((d) => /^[A-Z]:\\$/.test(d)).sort();
|
|
109
|
+
if (drivesAsking === null && Date.now() - drivesAskedAt > 15_000) {
|
|
110
|
+
drivesAskedAt = Date.now();
|
|
111
|
+
const shell = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
112
|
+
drivesAsking = new Promise<string[]>((resolvePromise) => {
|
|
113
|
+
execFile(shell, ['-NoProfile', '-NonInteractive', '-Command', '[System.IO.Directory]::GetLogicalDrives()'], { timeout: 5000, windowsHide: true }, (error, stdout) => {
|
|
114
|
+
const found = error ? [] : [...new Set((String(stdout).match(/[A-Za-z]:\\/g) ?? []).map((d) => d.toUpperCase()))].sort();
|
|
115
|
+
if (found.length) drivesKnown = found; else drivesAskedAt = 0; // no answer: asked again with the next listing
|
|
116
|
+
drivesAsking = null;
|
|
117
|
+
resolvePromise(drivesKnown ?? standing);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (drivesKnown) return Promise.resolve(drivesKnown);
|
|
122
|
+
return Promise.race([drivesAsking ?? Promise.resolve(standing), new Promise<string[]>((r) => { setTimeout(() => r(standing), 1200).unref?.(); })]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function quickStarts(home: string): Promise<FolderStart[]> {
|
|
126
|
+
const first: FolderStart = { label: 'Home', path: home, kind: 'home' };
|
|
127
|
+
if (!WIN) return [first, { label: '/', path: '/', kind: 'root' }];
|
|
128
|
+
return [first, ...(await windowsDrives(home)).map((d): FolderStart => ({ label: d.slice(0, 2), path: d, kind: 'drive' }))];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ───────────────────────── reading one directory ─────────────────────────
|
|
132
|
+
|
|
133
|
+
interface Entry { readonly name: string; readonly directory: boolean; readonly link: boolean }
|
|
134
|
+
|
|
135
|
+
/** Every directory and link of `dir`, by name; files are dropped here. One call, whatever the number of entries. */
|
|
136
|
+
async function readEntries(dir: string): Promise<Entry[]> {
|
|
137
|
+
const all = await readdir(dir, { withFileTypes: true });
|
|
138
|
+
return all.filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => ({ name: e.name, directory: e.isDirectory(), link: e.isSymbolicLink() }));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The names in `dir` that carry Windows' hidden attribute, lower-cased. Node does not report the attribute, so the
|
|
143
|
+
* system's own `dir` is asked, once per listing and for a short time only; it is given no path to read wrongly (it runs
|
|
144
|
+
* in the directory), and what it prints is read as UTF-16. No answer means no folder is taken for hidden.
|
|
145
|
+
*/
|
|
146
|
+
function windowsHidden(dir: string): Promise<Set<string>> {
|
|
147
|
+
if (!WIN || dir.startsWith('\\\\')) return Promise.resolve(new Set()); // cmd does not run in a network path
|
|
148
|
+
return new Promise((resolvePromise) => {
|
|
149
|
+
execFile(process.env.ComSpec ?? 'cmd.exe', ['/d', '/u', '/c', 'dir', '/b', '/a:dh'], { cwd: dir, encoding: 'buffer', timeout: 1500, windowsHide: true }, (_error, stdout) => {
|
|
150
|
+
const text = Buffer.isBuffer(stdout) ? stdout.toString('utf16le') : '';
|
|
151
|
+
resolvePromise(new Set(text.split(/\r?\n/).map((l) => l.trim().toLowerCase()).filter(Boolean)));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Run `work` on each item, a few at a time, until `until` (a clock time) has passed; what was not reached stays undefined. */
|
|
157
|
+
async function eachUntil<T, R>(items: readonly T[], until: number, work: (item: T) => Promise<R>): Promise<(R | undefined)[]> {
|
|
158
|
+
const out: (R | undefined)[] = new Array(items.length).fill(undefined);
|
|
159
|
+
let next = 0;
|
|
160
|
+
const lane = async (): Promise<void> => {
|
|
161
|
+
for (let i = next++; i < items.length && Date.now() < until; i = next++) out[i] = await work(items[i]!);
|
|
162
|
+
};
|
|
163
|
+
await Promise.all(Array.from({ length: Math.min(24, items.length) }, lane));
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const hasGit = (dir: string): Promise<boolean> => lstat(join(dir, '.git')).then(() => true, () => false);
|
|
168
|
+
const byName = new Intl.Collator('en', { numeric: true, sensitivity: 'base' });
|
|
169
|
+
const parentOf = (path: string): string | null => { const up = dirname(path); return up === path ? null : up; };
|
|
170
|
+
const nameOf = (path: string): string => basename(path) || path;
|
|
171
|
+
|
|
172
|
+
function whyNot(error: unknown): string {
|
|
173
|
+
const code = (error as NodeJS.ErrnoException | null)?.code ?? '';
|
|
174
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return 'There is no such folder.';
|
|
175
|
+
if (code === 'EACCES' || code === 'EPERM') return 'This folder cannot be read: access to it is denied.';
|
|
176
|
+
if (code === 'ELOOP') return 'This folder cannot be read: it is a link that leads back to itself.';
|
|
177
|
+
return `This folder cannot be read${code ? ` (${code})` : ''}.`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Drives (by their root, as a comparison key) that were asked something and have not answered within the limit. */
|
|
181
|
+
const notAnswering = new Set<string>();
|
|
182
|
+
const SLOW = Symbol('slow');
|
|
183
|
+
|
|
184
|
+
/** `work`, or `SLOW` after `ms`. A drive that was too slow is remembered until its answer does come, however late. */
|
|
185
|
+
function within<T>(root: string, ms: number, work: Promise<T>): Promise<T | typeof SLOW> {
|
|
186
|
+
return new Promise((resolvePromise, reject) => {
|
|
187
|
+
const timer = setTimeout(() => { notAnswering.add(root); resolvePromise(SLOW); }, ms);
|
|
188
|
+
work.then((v) => { clearTimeout(timer); notAnswering.delete(root); resolvePromise(v); }, (e) => { clearTimeout(timer); notAnswering.delete(root); reject(e); });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function listFolders(request: FolderRequest, options: FolderOptions = {}): Promise<FolderListing> {
|
|
193
|
+
const home = normalizePath(options.home ?? homedir());
|
|
194
|
+
const asked = wholePath(request.path?.trim() ? request.path : home, home);
|
|
195
|
+
if (asked === null) throw new HttpError(400, WIN ? 'Give the whole path of a folder, with its drive: D:\\projects' : 'Give the whole path of a folder, from /');
|
|
196
|
+
const starts = await quickStarts(home);
|
|
197
|
+
const addedKeys = new Set((request.added ?? []).slice(0, 200).map((a) => wholePath(a, home)).filter((a): a is string => a !== null).map(pathKey));
|
|
198
|
+
const empty = (path: string, error: string): FolderListing => ({ path, name: nameOf(path), parent: parentOf(path), sep, repository: false, added: addedKeys.has(pathKey(path)), folders: [], more: 0, hidden: 0, error, starts });
|
|
199
|
+
|
|
200
|
+
const root = pathKey(parse(asked).root);
|
|
201
|
+
const rootName = parse(asked).root;
|
|
202
|
+
if (notAnswering.has(root)) return empty(asked, `${rootName} has not answered yet. It may be a network drive that is not connected.`);
|
|
203
|
+
const answerMs = options.answerMs ?? ANSWER_MS;
|
|
204
|
+
const listing = await within(root, answerMs, read(asked, request, options, addedKeys, empty));
|
|
205
|
+
if (listing === SLOW) return empty(asked, `${rootName} did not answer within ${Math.round(answerMs / 1000)} seconds. It may be a network drive that is not connected.`);
|
|
206
|
+
return { ...listing, starts };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function read(asked: string, request: FolderRequest, options: FolderOptions, addedKeys: ReadonlySet<string>, empty: (path: string, error: string) => FolderListing): Promise<FolderListing> {
|
|
210
|
+
// Asked first, and not by a call that would hold the whole process: only a directory that answers is spelled out.
|
|
211
|
+
try {
|
|
212
|
+
if (!(await stat(asked)).isDirectory()) return empty(asked, 'This is a file, not a folder.');
|
|
213
|
+
} catch (e) { return empty(asked, whyNot(e)); }
|
|
214
|
+
const path = canonicalPath(asked);
|
|
215
|
+
let entries: Entry[];
|
|
216
|
+
let hiddenNames: Set<string>;
|
|
217
|
+
try { [entries, hiddenNames] = await Promise.all([(options.readEntries ?? readEntries)(path), windowsHidden(path)]); } catch (e) { return empty(path, whyNot(e)); }
|
|
218
|
+
|
|
219
|
+
const until = Date.now() + (options.marksMs ?? MARKS_MS);
|
|
220
|
+
// A link is listed when it leads to a directory. One that leads nowhere, to a file, or round in a circle is not.
|
|
221
|
+
const links = entries.filter((e) => e.link);
|
|
222
|
+
const led = await eachUntil(links, until, (e) => stat(join(path, e.name)).then((s) => s.isDirectory(), () => false));
|
|
223
|
+
const unfollowed = led.filter((v) => v === undefined).length;
|
|
224
|
+
const leads = new Map(links.map((e, i) => [e.name, led[i]]));
|
|
225
|
+
const all = entries.filter((e) => e.directory || leads.get(e.name) === true)
|
|
226
|
+
.map((e) => ({ name: e.name, link: e.link, hidden: e.name.startsWith('.') || hiddenNames.has(e.name.toLowerCase()) }))
|
|
227
|
+
.sort((a, b) => byName.compare(a.name, b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
228
|
+
const wanted = request.hidden ? all : all.filter((e) => !e.hidden);
|
|
229
|
+
const shown = wanted.slice(0, options.shown ?? SHOWN);
|
|
230
|
+
const marks = await eachUntil(shown, until, (e) => hasGit(join(path, e.name)));
|
|
231
|
+
const folders = shown.map((e, i): FolderEntry => {
|
|
232
|
+
const full = join(path, e.name);
|
|
233
|
+
return { name: e.name, path: full, repository: marks[i] === true, hidden: e.hidden, link: e.link, added: addedKeys.has(pathKey(full)) };
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
path, name: nameOf(path), parent: parentOf(path), sep, repository: await hasGit(path), added: addedKeys.has(pathKey(path)),
|
|
237
|
+
folders, more: wanted.length - shown.length + unfollowed, hidden: all.filter((e) => e.hidden).length, error: null, starts: [],
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function registerFolderRoutes(http: HttpApp): void {
|
|
242
|
+
http.route('GET', '/api/folders', ({ query }) => listFolders({ path: query.get('path'), hidden: query.get('hidden') === '1', added: query.getAll('added') }));
|
|
243
|
+
}
|