@a-t-h-i/bot-lobby 0.6.7 → 0.6.8
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/README.md +132 -15
- package/package.json +1 -1
- package/prompts/master.md +15 -0
- package/prompts/panel.md +4 -0
- package/prompts/planner.md +5 -0
- package/prompts/pr-review.md +62 -0
- package/prompts/splitter.md +58 -0
- package/src/ask/dialog.ts +19 -5
- package/src/ask/state.ts +8 -2
- package/src/ask/tool.ts +3 -1
- package/src/ask/view.ts +6 -3
- package/src/classifier/instance.ts +6 -0
- package/src/classifier/knowledge.ts +158 -0
- package/src/classifier/review.ts +146 -0
- package/src/execution/workspace.ts +185 -0
- package/src/knowledge/edit.ts +109 -0
- package/src/knowledge/notes.ts +143 -0
- package/src/knowledge/selector.ts +1 -1
- package/src/knowledge/store.ts +16 -2
- package/src/lobby/ask.ts +42 -21
- package/src/lobby/issues.ts +1 -1
- package/src/lobby/knowledge.ts +179 -0
- package/src/lobby/layout.ts +95 -18
- package/src/lobby/planner.ts +199 -7
- package/src/lobby/pr-review.ts +360 -0
- package/src/lobby/pulls.ts +250 -0
- package/src/lobby/runtime.ts +115 -13
- package/src/lobby/split.ts +230 -0
- package/src/lobby/tabs/git.ts +162 -0
- package/src/lobby/tabs/knowledge.ts +135 -0
- package/src/lobby/tabs/plan.ts +3 -1
- package/src/lobby/tabs/tasks.ts +11 -2
- package/src/lobby/view.ts +432 -32
- package/src/master/master.ts +39 -13
- package/src/pi/commands.ts +12 -7
- package/src/pi/model-support.ts +9 -0
- package/src/pi/plan-checklist.ts +42 -0
- package/src/pi/route.ts +2 -1
- package/src/pi/settings-ui.ts +41 -1
- package/src/pi/start-flags.ts +14 -0
- package/src/pi/start-task.ts +56 -5
- package/src/pi/tools.ts +4 -2
- package/src/schemas/configuration.ts +32 -3
- package/src/schemas/task.ts +15 -0
- package/src/state/backlog.ts +27 -4
- package/src/state/metrics.ts +2 -2
- package/src/state/persistence.ts +5 -5
- package/src/text.ts +38 -0
- package/src/workflow/workflow.ts +11 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The user's comments on knowledge: notes about one entry of an agent's
|
|
3
|
+
* knowledge file ("outdated, we moved to Redis"). They live in one
|
|
4
|
+
* append-only JSON-lines log per project, never inside the knowledge files
|
|
5
|
+
* (agents rewrite those when they compact them), and reach every agent that
|
|
6
|
+
* reads the knowledge: `annotate` puts each note right under its entry, so it
|
|
7
|
+
* travels with the entry into the prompt and the agent weighs it there.
|
|
8
|
+
*
|
|
9
|
+
* A note is tied to its entry by the entry's exact text. Editing the entry
|
|
10
|
+
* from the Knowledge tab moves its notes to the new text; a note whose entry
|
|
11
|
+
* changed some other way is kept, and shown under the file as detached.
|
|
12
|
+
*/
|
|
13
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import type { KnowledgeAgent } from "./paths.ts";
|
|
17
|
+
import { parseEntries } from "./edit.ts";
|
|
18
|
+
|
|
19
|
+
export interface KnowledgeNote {
|
|
20
|
+
id: string;
|
|
21
|
+
agent: KnowledgeAgent;
|
|
22
|
+
/** The knowledge file, e.g. `decisions.md`. */
|
|
23
|
+
file: string;
|
|
24
|
+
/** The exact text of the entry it is about. */
|
|
25
|
+
entry: string;
|
|
26
|
+
text: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
/** The pi session that wrote it, when known. */
|
|
29
|
+
by?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type NoteEvent =
|
|
33
|
+
| { kind: "note"; id: string; agent: KnowledgeAgent; file: string; entry: string; text: string; at: string; by?: string }
|
|
34
|
+
| { kind: "move"; id: string; entry: string; at: string }
|
|
35
|
+
| { kind: "remove"; id: string; at: string };
|
|
36
|
+
|
|
37
|
+
/** Longest note kept; agents read it verbatim. */
|
|
38
|
+
export const MAX_NOTE_CHARS = 2000;
|
|
39
|
+
|
|
40
|
+
export function notesPath(dataRoot: string): string {
|
|
41
|
+
return join(dataRoot, "knowledge-comments.jsonl");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function append(dataRoot: string, event: NoteEvent): void {
|
|
45
|
+
const path = notesPath(dataRoot);
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
47
|
+
appendFileSync(path, `${JSON.stringify(event)}\n`, "utf8");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The notes as they stand, oldest first. A torn line from a crashed writer is skipped. */
|
|
51
|
+
export function readNotes(dataRoot: string | undefined): KnowledgeNote[] {
|
|
52
|
+
if (!dataRoot) return [];
|
|
53
|
+
const path = notesPath(dataRoot);
|
|
54
|
+
if (!existsSync(path)) return [];
|
|
55
|
+
let text: string;
|
|
56
|
+
try {
|
|
57
|
+
text = readFileSync(path, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const notes = new Map<string, KnowledgeNote>();
|
|
62
|
+
for (const line of text.split("\n")) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
let event: NoteEvent;
|
|
65
|
+
try {
|
|
66
|
+
event = JSON.parse(line) as NoteEvent;
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!event || typeof event.id !== "string") continue;
|
|
71
|
+
if (event.kind === "note" && !notes.has(event.id) && typeof event.text === "string" && typeof event.entry === "string") {
|
|
72
|
+
notes.set(event.id, { id: event.id, agent: event.agent, file: event.file, entry: event.entry, text: event.text, createdAt: event.at, ...(event.by ? { by: event.by } : {}) });
|
|
73
|
+
} else if (event.kind === "move" && typeof event.entry === "string") {
|
|
74
|
+
const note = notes.get(event.id);
|
|
75
|
+
if (note) note.entry = event.entry;
|
|
76
|
+
} else if (event.kind === "remove") notes.delete(event.id);
|
|
77
|
+
}
|
|
78
|
+
return [...notes.values()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Leave a note on an entry. */
|
|
82
|
+
export function addNote(dataRoot: string, note: { agent: KnowledgeAgent; file: string; entry: string; text: string; by?: string }, now = new Date()): KnowledgeNote {
|
|
83
|
+
const text = note.text.trim().slice(0, MAX_NOTE_CHARS);
|
|
84
|
+
if (!text) throw new Error("a note needs some words");
|
|
85
|
+
if (!note.entry.trim()) throw new Error("a note needs an entry to be about");
|
|
86
|
+
const id = randomUUID().slice(0, 8);
|
|
87
|
+
const at = now.toISOString();
|
|
88
|
+
append(dataRoot, { kind: "note", id, agent: note.agent, file: note.file, entry: note.entry, text, at, ...(note.by ? { by: note.by } : {}) });
|
|
89
|
+
return { id, agent: note.agent, file: note.file, entry: note.entry, text, createdAt: at, ...(note.by ? { by: note.by } : {}) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function removeNote(dataRoot: string, id: string, now = new Date()): void {
|
|
93
|
+
append(dataRoot, { kind: "remove", id, at: now.toISOString() });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Move every note on `from` to `to`: the entry was edited, its notes stay with it. Returns how many moved. */
|
|
97
|
+
export function moveNotes(dataRoot: string, agent: KnowledgeAgent, file: string, from: string, to: string, now = new Date()): number {
|
|
98
|
+
if (from === to) return 0;
|
|
99
|
+
const moving = readNotes(dataRoot).filter((note) => note.agent === agent && note.file === file && note.entry === from);
|
|
100
|
+
for (const note of moving) append(dataRoot, { kind: "move", id: note.id, entry: to, at: now.toISOString() });
|
|
101
|
+
return moving.length;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Drop every note on `entry`: the entry was deleted. Returns how many went. */
|
|
105
|
+
export function removeNotesOn(dataRoot: string, agent: KnowledgeAgent, file: string, entry: string, now = new Date()): number {
|
|
106
|
+
const going = readNotes(dataRoot).filter((note) => note.agent === agent && note.file === file && note.entry === entry);
|
|
107
|
+
for (const note of going) removeNote(dataRoot, note.id, now);
|
|
108
|
+
return going.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `> Note from the user: …` with every line of a longer note quoted. */
|
|
112
|
+
function noteLines(note: KnowledgeNote, about?: string): string[] {
|
|
113
|
+
const [first = "", ...rest] = note.text.split("\n");
|
|
114
|
+
const lead = about ? `Note from the user about "${about}": ` : "Note from the user: ";
|
|
115
|
+
return [`> ${lead}${first}`, ...rest.map((line) => `> ${line}`)];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A file's text with each note under the entry it is about, as the agents
|
|
120
|
+
* read it. A note whose entry is gone is added at the end, naming the entry it
|
|
121
|
+
* was about, so what the user said is never lost. Notes the text already
|
|
122
|
+
* carries (an agent copied them in when it compacted the file) are not added twice.
|
|
123
|
+
*/
|
|
124
|
+
export function annotate(content: string, notes: readonly KnowledgeNote[]): string {
|
|
125
|
+
if (notes.length === 0) return content;
|
|
126
|
+
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
|
127
|
+
const entries = parseEntries(content);
|
|
128
|
+
const carried = (note: KnowledgeNote) => content.includes(noteLines(note)[0]!);
|
|
129
|
+
const pending = notes.filter((note) => !carried(note));
|
|
130
|
+
const detached: KnowledgeNote[] = [];
|
|
131
|
+
const under = new Map<number, KnowledgeNote[]>();
|
|
132
|
+
for (const note of pending) {
|
|
133
|
+
const entry = entries.find((candidate) => candidate.text === note.entry);
|
|
134
|
+
if (!entry) detached.push(note);
|
|
135
|
+
else under.set(entry.end, [...(under.get(entry.end) ?? []), note]);
|
|
136
|
+
}
|
|
137
|
+
// Bottom-up, so earlier positions stay where they are.
|
|
138
|
+
for (const at of [...under.keys()].sort((a, b) => b - a)) lines.splice(at, 0, ...under.get(at)!.flatMap((note) => noteLines(note)));
|
|
139
|
+
const body = lines.join("\n").replace(/\n+$/, "");
|
|
140
|
+
if (detached.length === 0) return body;
|
|
141
|
+
const about = (note: KnowledgeNote) => note.entry.replace(/\s+/g, " ").trim().slice(0, 60);
|
|
142
|
+
return `${body}\n\n${detached.flatMap((note) => noteLines(note, about(note))).join("\n")}`;
|
|
143
|
+
}
|
|
@@ -36,7 +36,7 @@ export function splitSections(markdown: string): string[] {
|
|
|
36
36
|
.filter((part) => part.length > 0);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function score(sectionText: string, tokens: string[]): number {
|
|
39
|
+
export function score(sectionText: string, tokens: string[]): number {
|
|
40
40
|
const lower = sectionText.toLowerCase();
|
|
41
41
|
return tokens.reduce((total, token) => total + (lower.includes(token) ? 1 : 0), 0);
|
|
42
42
|
}
|
package/src/knowledge/store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { knowledgeDir, STANDARDS_FILE, type KnowledgeAgent } from "./paths.ts";
|
|
4
|
+
import { annotate, readNotes } from "./notes.ts";
|
|
4
5
|
|
|
5
6
|
export function readFileOr(path: string, fallback = ""): string {
|
|
6
7
|
try {
|
|
@@ -105,7 +106,20 @@ export function readKnowledgeSlices(
|
|
|
105
106
|
};
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Read an agent's knowledge by agent key; bot-lobby dirs win per file over the
|
|
111
|
+
* pre-rename ones. The user's notes on entries (Knowledge tab) sit right under
|
|
112
|
+
* the entries they are about, so every agent reads them with the knowledge.
|
|
113
|
+
*/
|
|
109
114
|
export function readAgentKnowledge(roots: readonly string[], agent: KnowledgeAgent) {
|
|
110
|
-
|
|
115
|
+
const slices = readKnowledgeSlices(roots.map((root) => knowledgeDir(root, agent)), STANDARDS_FILE[agent]);
|
|
116
|
+
const notes = readNotes(roots[0]).filter((note) => note.agent === agent);
|
|
117
|
+
if (notes.length === 0) return slices;
|
|
118
|
+
const about = (file: string) => notes.filter((note) => note.file === file);
|
|
119
|
+
return {
|
|
120
|
+
knowledge: annotate(slices.knowledge, about("knowledge.md")),
|
|
121
|
+
standards: annotate(slices.standards, about(STANDARDS_FILE[agent])),
|
|
122
|
+
decisions: annotate(slices.decisions, about("decisions.md")),
|
|
123
|
+
completed: annotate(slices.completed, about("completed-tasks.md")),
|
|
124
|
+
};
|
|
111
125
|
}
|
package/src/lobby/ask.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* answers become the user's turn for the next round.
|
|
7
7
|
*/
|
|
8
8
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type { PanelQuestion } from "./planner.ts";
|
|
9
|
+
import type { PanelQuestion, SettledQuestion } from "./planner.ts";
|
|
10
10
|
import { MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskAnswer, type AskOption, type AskQuestion, type AskResult } from "../ask/types.ts";
|
|
11
11
|
import type { Asker } from "../ask/dialog.ts";
|
|
12
12
|
|
|
@@ -64,6 +64,35 @@ function answerText(answer: AskAnswer | undefined): string | undefined {
|
|
|
64
64
|
return answer.answer?.trim() || undefined;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** A multi-line answer keeps its lines under the `→` that opens it. */
|
|
68
|
+
function indented(text: string, by: string): string {
|
|
69
|
+
return text.replace(/\n/g, `\n${by}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every question of the round with the answer the user gave it, in the order asked. */
|
|
73
|
+
function answered(questions: readonly PanelQuestion[], results: readonly AskResult[]): Array<{ number: number; question: PanelQuestion; answer: AskAnswer | undefined; text: string | undefined }> {
|
|
74
|
+
const rows: Array<{ number: number; question: PanelQuestion; answer: AskAnswer | undefined; text: string | undefined }> = [];
|
|
75
|
+
results.forEach((result, chunk) => {
|
|
76
|
+
const offset = chunk * MAX_QUESTIONS;
|
|
77
|
+
for (let i = 0; i < MAX_QUESTIONS; i++) {
|
|
78
|
+
const question = questions[offset + i];
|
|
79
|
+
if (!question) break;
|
|
80
|
+
const answer = result.answers.find((entry) => entry.questionIndex === i);
|
|
81
|
+
rows.push({ number: offset + i + 1, question, answer, text: answerText(answer) });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What the user's reply settled: each question of the round with its answer,
|
|
89
|
+
* or without one when they left it. The panel is never allowed to ask a
|
|
90
|
+
* settled question again.
|
|
91
|
+
*/
|
|
92
|
+
export function settledQuestions(questions: readonly PanelQuestion[], results: readonly AskResult[]): SettledQuestion[] {
|
|
93
|
+
return answered(questions, results).map(({ question, text }) => ({ from: question.from, question: question.text.replace(/\s+/g, " ").trim(), ...(text ? { answer: text } : {}) }));
|
|
94
|
+
}
|
|
95
|
+
|
|
67
96
|
/**
|
|
68
97
|
* The user's turn for the next round: every question with who asked it and
|
|
69
98
|
* the answer (`→ …`), their notes, and what they left unanswered. Undefined
|
|
@@ -72,27 +101,19 @@ function answerText(answer: AskAnswer | undefined): string | undefined {
|
|
|
72
101
|
export function answerMessage(questions: readonly PanelQuestion[], results: readonly AskResult[]): string | undefined {
|
|
73
102
|
const lines: string[] = [];
|
|
74
103
|
const skipped: string[] = [];
|
|
75
|
-
const notes: string
|
|
76
|
-
let
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (!question) break;
|
|
83
|
-
const answer = result.answers.find((entry) => entry.questionIndex === i);
|
|
84
|
-
const text = answerText(answer);
|
|
85
|
-
const label = `${offset + i + 1}. [${question.from}] ${question.text.replace(/\s+/g, " ").trim()}`;
|
|
86
|
-
if (!text) {
|
|
87
|
-
skipped.push(label);
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
answered += 1;
|
|
91
|
-
lines.push(`${label}\n → ${text}${answer?.kind === "custom" ? " (in my words)" : ""}`);
|
|
92
|
-
if (answer?.notes?.trim()) lines.push(` note: ${answer.notes.trim()}`);
|
|
104
|
+
const notes = results.map((result) => result.globalNote?.trim()).filter((note): note is string => Boolean(note));
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const { number, question, answer, text } of answered(questions, results)) {
|
|
107
|
+
const label = `${number}. [${question.from}] ${question.text.replace(/\s+/g, " ").trim()}`;
|
|
108
|
+
if (!text) {
|
|
109
|
+
skipped.push(label);
|
|
110
|
+
continue;
|
|
93
111
|
}
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
count += 1;
|
|
113
|
+
lines.push(`${label}\n → ${indented(text, " ")}${answer?.kind === "custom" ? " (in my words)" : ""}`);
|
|
114
|
+
if (answer?.notes?.trim()) lines.push(` note: ${indented(answer.notes.trim(), " ")}`);
|
|
115
|
+
}
|
|
116
|
+
if (count === 0 && notes.length === 0) return undefined;
|
|
96
117
|
return [
|
|
97
118
|
"Answers to the panel's questions:",
|
|
98
119
|
...lines,
|
package/src/lobby/issues.ts
CHANGED
|
@@ -63,7 +63,7 @@ export function ghError(result: Partial<ExecResult> & { error?: string }): strin
|
|
|
63
63
|
return first.length > 160 ? `${first.slice(0, 159)}…` : first;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
async function gh(exec: Exec, cwd: string, args: string[]): Promise<string> {
|
|
66
|
+
export async function gh(exec: Exec, cwd: string, args: string[]): Promise<string> {
|
|
67
67
|
let result: ExecResult;
|
|
68
68
|
try {
|
|
69
69
|
result = await exec("gh", args, { cwd, timeout: TIMEOUT_MS });
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Knowledge tab's side of the disk: every agent's knowledge files, one
|
|
3
|
+
* file read as entries with the user's notes on them, and the edits the user
|
|
4
|
+
* can make — replace, add or delete an entry, edit the whole file — plus
|
|
5
|
+
* notes. Every write goes through the compactor's path, which archives the
|
|
6
|
+
* previous version first (`archive/<Agent>/…bak`), so an edit can be undone by
|
|
7
|
+
* hand. An entry is found again by its exact text; when the file changed on
|
|
8
|
+
* disk since the tab drew it, the edit is refused with a line saying so.
|
|
9
|
+
*/
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { compactKnowledgeFile } from "../knowledge/compactor.ts";
|
|
12
|
+
import { findEntry, insertAfter, parseEntries, removeEntry, replaceEntry, type KnowledgeEntry } from "../knowledge/edit.ts";
|
|
13
|
+
import { addNote, moveNotes, readNotes, removeNote, removeNotesOn, type KnowledgeNote } from "../knowledge/notes.ts";
|
|
14
|
+
import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
|
|
15
|
+
import { readFirstExisting } from "../knowledge/store.ts";
|
|
16
|
+
import { dataRoot, readDataRoots } from "../state/project.ts";
|
|
17
|
+
|
|
18
|
+
/** What each knowledge file is, in words. */
|
|
19
|
+
export const FILE_LABELS: Record<string, string> = {
|
|
20
|
+
"knowledge.md": "Knowledge",
|
|
21
|
+
"decisions.md": "Decisions",
|
|
22
|
+
"completed-tasks.md": "Completed tasks",
|
|
23
|
+
"standards.md": "Standards",
|
|
24
|
+
"design-language.md": "Design language",
|
|
25
|
+
"engineering-standards.md": "Engineering standards",
|
|
26
|
+
"testing-standards.md": "Testing standards",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Who the agents are, as the lobby names them. */
|
|
30
|
+
export const AGENT_LABELS: Record<KnowledgeAgent, string> = { master: "Master (oracle)", designer: "Designer", backend: "Backend", qa: "QA" };
|
|
31
|
+
|
|
32
|
+
export interface KnowledgeFileInfo {
|
|
33
|
+
agent: KnowledgeAgent;
|
|
34
|
+
file: string;
|
|
35
|
+
label: string;
|
|
36
|
+
chars: number;
|
|
37
|
+
/** Past the compaction threshold: the Master should compact it. */
|
|
38
|
+
over: boolean;
|
|
39
|
+
notes: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KnowledgeView extends KnowledgeFileInfo {
|
|
43
|
+
content: string;
|
|
44
|
+
entries: KnowledgeEntry[];
|
|
45
|
+
/** Notes on entries this file has. */
|
|
46
|
+
attached: KnowledgeNote[];
|
|
47
|
+
/** Notes whose entry has changed or gone: kept, shown apart. */
|
|
48
|
+
detached: KnowledgeNote[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An entry picked in the tab: its text, and which of several identical ones it is. */
|
|
52
|
+
export interface EntryRef {
|
|
53
|
+
text: string;
|
|
54
|
+
occurrence: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface KnowledgeDeps {
|
|
58
|
+
root: string;
|
|
59
|
+
configDir: string;
|
|
60
|
+
/** Characters past which a file is over its compaction threshold. */
|
|
61
|
+
threshold: () => number;
|
|
62
|
+
/** Backups of a file kept in the archive. */
|
|
63
|
+
backups: () => number;
|
|
64
|
+
sessionId?: () => string | undefined;
|
|
65
|
+
now?: () => Date;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CHANGED = "that entry changed on disk since it was drawn — r reloads the file";
|
|
69
|
+
|
|
70
|
+
export class KnowledgeBook {
|
|
71
|
+
private readonly deps: KnowledgeDeps;
|
|
72
|
+
|
|
73
|
+
constructor(deps: KnowledgeDeps) {
|
|
74
|
+
this.deps = deps;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private get data(): string {
|
|
78
|
+
return dataRoot(this.deps.root, this.deps.configDir);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private read(agent: KnowledgeAgent, file: string): string {
|
|
82
|
+
return readFirstExisting(readDataRoots(this.deps.root, this.deps.configDir).map((root) => join(knowledgeDir(root, agent), file)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every agent's files in the order the tab lists them, with their size and notes. */
|
|
86
|
+
files(): KnowledgeFileInfo[] {
|
|
87
|
+
const notes = readNotes(this.data);
|
|
88
|
+
return (Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]).flatMap((agent) =>
|
|
89
|
+
KNOWLEDGE_FILES[agent].map((file) => {
|
|
90
|
+
const chars = this.read(agent, file).length;
|
|
91
|
+
return { agent, file, label: FILE_LABELS[file] ?? file, chars, over: chars > this.deps.threshold(), notes: notes.filter((note) => note.agent === agent && note.file === file).length };
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One file as entries with their notes. */
|
|
97
|
+
open(agent: KnowledgeAgent, file: string): KnowledgeView {
|
|
98
|
+
const content = this.read(agent, file);
|
|
99
|
+
const entries = parseEntries(content);
|
|
100
|
+
const notes = readNotes(this.data).filter((note) => note.agent === agent && note.file === file);
|
|
101
|
+
const known = new Set(entries.map((entry) => entry.text));
|
|
102
|
+
return {
|
|
103
|
+
agent,
|
|
104
|
+
file,
|
|
105
|
+
label: FILE_LABELS[file] ?? file,
|
|
106
|
+
chars: content.length,
|
|
107
|
+
over: content.length > this.deps.threshold(),
|
|
108
|
+
notes: notes.length,
|
|
109
|
+
content,
|
|
110
|
+
entries,
|
|
111
|
+
attached: notes.filter((note) => known.has(note.entry)),
|
|
112
|
+
detached: notes.filter((note) => !known.has(note.entry)),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Write a file's new content with the previous one archived; the notice says where it went. */
|
|
117
|
+
private write(agent: KnowledgeAgent, file: string, content: string): string {
|
|
118
|
+
compactKnowledgeFile({ dataRoot: this.data, agent, file, content, backupCount: this.deps.backups(), ...(this.deps.now ? { now: this.deps.now() } : {}) });
|
|
119
|
+
return `saved ${agent}/${file} — the version before is in archive/${AGENT_DIR_NAMES[agent]}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Replace one entry with `text`, exactly as written; its notes move with it. */
|
|
123
|
+
edit(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
|
|
124
|
+
const content = this.read(agent, file);
|
|
125
|
+
const entry = findEntry(content, ref.text, ref.occurrence);
|
|
126
|
+
if (!entry) return CHANGED;
|
|
127
|
+
if (!text.trim()) return "nothing to save — d d deletes an entry, esc cancels";
|
|
128
|
+
const next = replaceEntry(content, entry, text);
|
|
129
|
+
if (next.trim() === content.trim()) return "nothing changed";
|
|
130
|
+
const notice = this.write(agent, file, next);
|
|
131
|
+
const moved = moveNotes(this.data, agent, file, entry.text, text.replace(/\s+$/, ""), this.deps.now?.());
|
|
132
|
+
return `${notice}${moved > 0 ? `; ${moved} note${moved === 1 ? "" : "s"} stayed with it` : ""}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Add a bullet after an entry (at the end when there is none). */
|
|
136
|
+
add(agent: KnowledgeAgent, file: string, after: EntryRef | undefined, text: string): string {
|
|
137
|
+
const content = this.read(agent, file);
|
|
138
|
+
const entry = after ? findEntry(content, after.text, after.occurrence) : undefined;
|
|
139
|
+
if (after && !entry) return CHANGED;
|
|
140
|
+
const next = insertAfter(content, entry, text);
|
|
141
|
+
if (next === content) return "nothing to add";
|
|
142
|
+
return this.write(agent, file, next);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Delete an entry, and the notes on it. */
|
|
146
|
+
remove(agent: KnowledgeAgent, file: string, ref: EntryRef): string {
|
|
147
|
+
const content = this.read(agent, file);
|
|
148
|
+
const entry = findEntry(content, ref.text, ref.occurrence);
|
|
149
|
+
if (!entry) return CHANGED;
|
|
150
|
+
const notice = this.write(agent, file, removeEntry(content, entry));
|
|
151
|
+
const gone = removeNotesOn(this.data, agent, file, entry.text, this.deps.now?.());
|
|
152
|
+
return `${notice}${gone > 0 ? `; its ${gone} note${gone === 1 ? "" : "s"} went with it` : ""}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Replace a whole file (edited in pi's editor). */
|
|
156
|
+
replaceFile(agent: KnowledgeAgent, file: string, text: string): string {
|
|
157
|
+
const content = this.read(agent, file);
|
|
158
|
+
if (text.trim() === content.trim()) return "nothing changed";
|
|
159
|
+
return this.write(agent, file, text);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Leave a note on an entry: every agent that reads this knowledge reads it under the entry. */
|
|
163
|
+
comment(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
|
|
164
|
+
const entry = findEntry(this.read(agent, file), ref.text, ref.occurrence);
|
|
165
|
+
if (!entry) return CHANGED;
|
|
166
|
+
try {
|
|
167
|
+
addNote(this.data, { agent, file, entry: entry.text, text, ...(this.deps.sessionId?.() ? { by: this.deps.sessionId!()! } : {}) }, this.deps.now?.());
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return (error as Error).message;
|
|
170
|
+
}
|
|
171
|
+
return "note saved — every agent reads it under this entry";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Take a note back. */
|
|
175
|
+
unnote(id: string): string {
|
|
176
|
+
removeNote(this.data, id, this.deps.now?.());
|
|
177
|
+
return "note removed";
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/lobby/layout.ts
CHANGED
|
@@ -91,6 +91,9 @@ export function wrapHanging(lead: string, text: string, width: number): string[]
|
|
|
91
91
|
return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** The mark before a git branch name, in the lobby's title and a task's details. */
|
|
95
|
+
export const BRANCH_GLYPH = "⎇";
|
|
96
|
+
|
|
94
97
|
/** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
|
|
95
98
|
export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
|
|
96
99
|
if (width <= 0) return "";
|
|
@@ -268,21 +271,84 @@ export function scrollThumb(total: number, rows: number, start: number): { from:
|
|
|
268
271
|
return { from, to: from + size };
|
|
269
272
|
}
|
|
270
273
|
|
|
271
|
-
/**
|
|
272
|
-
export
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
274
|
+
/** Lines a page moves a pane: its rows less one, so a line of context carries over. */
|
|
275
|
+
export function pageStep(rows: number): number {
|
|
276
|
+
return Math.max(1, rows - 1);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** How many pages a pane showing `rows` lines at a time holds out of `total`. */
|
|
280
|
+
export function pageCount(total: number, rows: number): number {
|
|
281
|
+
return total <= rows ? 1 : 1 + Math.ceil((total - rows) / pageStep(rows));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The page a pane showing lines from `start` is on, counted so that the top is
|
|
286
|
+
* page 1 and the bottom the last, and a page's worth of scrolling from either
|
|
287
|
+
* end lands exactly on the next number: a log that opens at its newest line
|
|
288
|
+
* and a detail that opens at its first both step through the same numbers.
|
|
289
|
+
*/
|
|
290
|
+
export function pageOf(total: number, rows: number, start: number): number {
|
|
291
|
+
const end = Math.max(0, total - rows);
|
|
292
|
+
const behind = Math.ceil(Math.max(0, end - Math.max(0, start)) / pageStep(rows));
|
|
293
|
+
return Math.max(1, pageCount(total, rows) - behind);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** One piece of the pager: a button, where the pane is, or what sits between them. */
|
|
297
|
+
export interface PagerPart {
|
|
298
|
+
text: string;
|
|
299
|
+
role: "up" | "down" | "where" | "between";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Fewest columns of border kept either side of the pager, beside its two spaces. */
|
|
303
|
+
const PAGER_MARGIN = 3;
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The pager in a scrolling pane's bottom border, as words a newcomer can read:
|
|
307
|
+
* `▲ prev · page 2/5 · next ▼`, shortening to `▲ prev · 2/5 · next ▼` and
|
|
308
|
+
* `▲ 2/5 ▼` as the pane narrows, and to nothing when even that will not fit
|
|
309
|
+
* in `room` columns. The page number is padded to the width of the page count,
|
|
310
|
+
* so the buttons stay where they are as it changes and a click can be traced
|
|
311
|
+
* from the pane's size alone.
|
|
312
|
+
*/
|
|
313
|
+
export function pager(total: number, rows: number, start: number, room: number): PagerPart[] | undefined {
|
|
314
|
+
const pages = pageCount(total, rows);
|
|
315
|
+
const count = String(pages);
|
|
316
|
+
const of = `${String(pageOf(total, rows, start)).padStart(count.length)}/${pages}`;
|
|
317
|
+
const between = (text: string): PagerPart => ({ text, role: "between" });
|
|
318
|
+
const up = (text: string): PagerPart => ({ text, role: "up" });
|
|
319
|
+
const down = (text: string): PagerPart => ({ text, role: "down" });
|
|
320
|
+
const where = (text: string): PagerPart => ({ text, role: "where" });
|
|
321
|
+
const variants: PagerPart[][] = [
|
|
322
|
+
[up("▲ prev"), between(" · "), where(`page ${of}`), between(" · "), down("next ▼")],
|
|
323
|
+
[up("▲ prev"), between(" · "), where(of), between(" · "), down("next ▼")],
|
|
324
|
+
[up("▲"), between(" "), where(of), between(" "), down("▼")],
|
|
325
|
+
];
|
|
326
|
+
return variants.find((parts) => parts.reduce((sum, part) => sum + textWidth(part.text), 0) <= room);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Columns a box of `width` leaves for the pager's text: two spaces around it and a margin of border on each side. */
|
|
330
|
+
function pagerRoom(width: number): number {
|
|
331
|
+
return width - 2 - 2 - PAGER_MARGIN;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Which button a column of a scrollable pane's bottom border is: -1 (a page
|
|
336
|
+
* up) or 1 (a page down), or undefined. The buttons take their words and the
|
|
337
|
+
* space beside them, so a click a little off still lands.
|
|
338
|
+
*/
|
|
339
|
+
export function pagerButton(width: number, column: number, total: number, rows: number): -1 | 1 | undefined {
|
|
340
|
+
const parts = pager(total, rows, 0, pagerRoom(width));
|
|
341
|
+
if (!parts) return undefined;
|
|
342
|
+
const size = parts.reduce((sum, part) => sum + textWidth(part.text), 0);
|
|
343
|
+
// The text ends one space before the corner: `… next ▼ ╯`.
|
|
344
|
+
const origin = width - 2 - size;
|
|
345
|
+
let at = origin;
|
|
346
|
+
for (const part of parts) {
|
|
347
|
+
const from = part.role === "up" ? origin - 1 : at;
|
|
348
|
+
const to = part.role === "down" ? width - 1 : at + textWidth(part.text);
|
|
349
|
+
if (column >= from && column < to) return part.role === "up" ? -1 : part.role === "down" ? 1 : undefined;
|
|
350
|
+
at += textWidth(part.text);
|
|
351
|
+
}
|
|
286
352
|
return undefined;
|
|
287
353
|
}
|
|
288
354
|
|
|
@@ -309,9 +375,20 @@ export function box(width: number, height: number, content: readonly string[], o
|
|
|
309
375
|
const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
|
|
310
376
|
const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
|
|
311
377
|
const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
|
|
312
|
-
// A pane with more lines than rows carries
|
|
313
|
-
const
|
|
314
|
-
const
|
|
378
|
+
// A pane with more lines than rows carries a pager in its bottom border; `pagerButton` finds its buttons again for clicks.
|
|
379
|
+
const parts = thumb && options.scroll ? pager(options.scroll.total, height - 2, options.scroll.start, pagerRoom(width)) : undefined;
|
|
380
|
+
const atTop = (options.scroll?.start ?? 0) <= 0;
|
|
381
|
+
const atEnd = options.scroll ? options.scroll.start >= options.scroll.total - (height - 2) : true;
|
|
382
|
+
const paintPart = (part: PagerPart): string => {
|
|
383
|
+
if (part.role === "between") return paint(theme, "dim", part.text);
|
|
384
|
+
if (part.role === "where") return paint(theme, focused ? "text" : "muted", part.text);
|
|
385
|
+
// A button that cannot go further is dimmed: the pane is at that end.
|
|
386
|
+
if (part.role === "up" ? atTop : atEnd) return paint(theme, "dim", part.text);
|
|
387
|
+
return bold(theme, paint(theme, focused ? "accent" : "text", part.text));
|
|
388
|
+
};
|
|
389
|
+
const pagerText = parts ? ` ${parts.map(paintPart).join("")} ` : "";
|
|
390
|
+
const pagerWidth = parts ? parts.reduce((sum, part) => sum + textWidth(part.text), 0) + 2 : 0;
|
|
391
|
+
const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - pagerWidth))}${pagerText}${edge("╯")}`;
|
|
315
392
|
return [top, ...rows, bottom];
|
|
316
393
|
}
|
|
317
394
|
|