toolroll 0.9.15 → 0.9.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/assignment-brief.d.ts +10 -0
  3. package/dist/assignment-brief.js +56 -26
  4. package/dist/assignment.d.ts +8 -7
  5. package/dist/assignment.js +13 -8
  6. package/dist/browser/workspace.css +1 -1
  7. package/dist/browser/workspace.js +22 -22
  8. package/dist/browser-crew.js +1 -0
  9. package/dist/browser-workspace.d.ts +5 -0
  10. package/dist/chat-actions.d.ts +7 -0
  11. package/dist/chat-actions.js +40 -4
  12. package/dist/chat-ask.d.ts +28 -0
  13. package/dist/chat-ask.js +91 -0
  14. package/dist/chat-channel.js +8 -1
  15. package/dist/chat-controls.d.ts +8 -0
  16. package/dist/chat-controls.js +3 -0
  17. package/dist/chat-delivery-state.d.ts +8 -1
  18. package/dist/chat-delivery-state.js +15 -2
  19. package/dist/chat-delivery.d.ts +3 -0
  20. package/dist/chat-delivery.js +49 -17
  21. package/dist/chat-quiet.js +4 -2
  22. package/dist/chat-voice.d.ts +3 -6
  23. package/dist/chat-voice.js +8 -2
  24. package/dist/chat-warmth.d.ts +21 -0
  25. package/dist/chat-warmth.js +36 -0
  26. package/dist/discord-api.d.ts +1 -1
  27. package/dist/discord-api.js +1 -1
  28. package/dist/discord-chat.d.ts +3 -2
  29. package/dist/discord-chat.js +28 -3
  30. package/dist/lead-about.d.ts +35 -0
  31. package/dist/lead-about.js +62 -0
  32. package/dist/lead-capabilities.d.ts +64 -0
  33. package/dist/lead-capabilities.js +199 -0
  34. package/dist/lead-commitments.d.ts +84 -0
  35. package/dist/lead-commitments.js +214 -0
  36. package/dist/lead-context.d.ts +34 -3
  37. package/dist/lead-context.js +155 -19
  38. package/dist/lead-follow.d.ts +1 -1
  39. package/dist/lead-follow.js +6 -2
  40. package/dist/lead-identity.d.ts +23 -0
  41. package/dist/lead-identity.js +33 -0
  42. package/dist/lead-people.d.ts +55 -0
  43. package/dist/lead-people.js +128 -0
  44. package/dist/lead-voice.d.ts +13 -9
  45. package/dist/lead-voice.js +26 -16
  46. package/dist/ledger-view.js +3 -2
  47. package/dist/mate-cli.d.ts +4 -1
  48. package/dist/mate-cli.js +23 -16
  49. package/dist/mate-contract.d.ts +1 -1
  50. package/dist/mate-contract.js +13 -4
  51. package/dist/mate-progress.js +7 -0
  52. package/dist/mate-tools.d.ts +26 -1
  53. package/dist/mate-tools.js +357 -7
  54. package/dist/mate.d.ts +35 -1
  55. package/dist/mate.js +121 -61
  56. package/dist/operate.js +4 -1
  57. package/dist/phase-routing.d.ts +1 -1
  58. package/dist/project-delete.js +3 -1
  59. package/dist/provider-auth.d.ts +4 -0
  60. package/dist/provider-auth.js +1 -1
  61. package/dist/reply-shape.d.ts +96 -0
  62. package/dist/reply-shape.js +452 -0
  63. package/dist/result-acts.d.ts +14 -0
  64. package/dist/result-acts.js +19 -0
  65. package/dist/retention.js +6 -0
  66. package/dist/serve.js +162 -55
  67. package/dist/slack-chat.d.ts +2 -0
  68. package/dist/slack-chat.js +30 -3
  69. package/dist/store.d.ts +60 -0
  70. package/dist/store.js +152 -3
  71. package/dist/surface.js +2 -2
  72. package/dist/task-status.d.ts +5 -15
  73. package/dist/task-status.js +18 -5
  74. package/dist/team-runtime.js +3 -2
  75. package/dist/teams-chat.js +16 -2
  76. package/dist/telegram-mate.d.ts +6 -1
  77. package/dist/telegram-mate.js +43 -11
  78. package/dist/telegram-progress.js +3 -2
  79. package/dist/telegram-team.js +6 -3
  80. package/dist/telegram.js +43 -3
  81. package/dist/test-temp.js +1 -1
  82. package/dist/when-html.d.ts +8 -3
  83. package/dist/when-html.js +23 -3
  84. package/dist/work-index.d.ts +2 -0
  85. package/dist/work-index.js +9 -6
  86. package/dist/work-summary.d.ts +1 -1
  87. package/dist/workspace-ui.d.ts +2 -14
  88. package/dist/workspace-ui.js +4 -22
  89. package/package.json +1 -1
@@ -0,0 +1,33 @@
1
+ export const DEFAULT_LEAD_NAME = "Lead";
2
+ export const DEFAULT_LEAD_PERSONA = "Be genuinely helpful, not performative. Have opinions and say which you'd pick. Check before you claim. Be resourceful before asking. Keep it short.";
3
+ export const LEAD_NAME_MAX = 40;
4
+ export const LEAD_PERSONA_MAX = 600;
5
+ /** This person's lead: their saved name and persona, else the defaults. An older store reads as the defaults. */
6
+ export function leadIdentityOf(store, account) {
7
+ if (account == null || account === "")
8
+ return { name: DEFAULT_LEAD_NAME, persona: DEFAULT_LEAD_PERSONA };
9
+ try {
10
+ const saved = store.leadConfig(account);
11
+ if (saved !== null)
12
+ return { name: saved.name || DEFAULT_LEAD_NAME, persona: saved.persona || DEFAULT_LEAD_PERSONA };
13
+ }
14
+ catch { /* no lead_config yet: the defaults */ }
15
+ return { name: DEFAULT_LEAD_NAME, persona: DEFAULT_LEAD_PERSONA };
16
+ }
17
+ /** The name alone, for the places the lead speaks. */
18
+ export function leadNameOf(store, account) {
19
+ return leadIdentityOf(store, account).name;
20
+ }
21
+ /** The saved form of a name and persona, or the problem with them. An empty name or persona means the default. */
22
+ export function checkLeadIdentity(name, persona) {
23
+ const named = name.trim().replace(/\s+/g, " "), described = persona.trim().replace(/\r\n?/g, "\n");
24
+ if (named.length > LEAD_NAME_MAX)
25
+ return { ok: false, message: `Keep the name to ${LEAD_NAME_MAX} characters.` };
26
+ if (!/^[\p{L}\p{N}][\p{L}\p{N} .'’_-]*$/u.test(named) && named !== "")
27
+ return { ok: false, message: "Use letters, numbers and spaces in the name." };
28
+ if (described.length > LEAD_PERSONA_MAX)
29
+ return { ok: false, message: `Keep the persona to ${LEAD_PERSONA_MAX} characters.` };
30
+ if (/[\u0000-\u0009\u000b-\u001f\u007f]/.test(described))
31
+ return { ok: false, message: "Use plain text in the persona." };
32
+ return { ok: true, identity: { name: named || DEFAULT_LEAD_NAME, persona: described || DEFAULT_LEAD_PERSONA } };
33
+ }
@@ -0,0 +1,55 @@
1
+ import type { Store, TeammateRow } from "./store.js";
2
+ export type PersonEntry = {
3
+ id: string;
4
+ account: string;
5
+ name: string;
6
+ role: string;
7
+ projects: string[];
8
+ profile: string | null;
9
+ };
10
+ export type MateEntry = {
11
+ id: string;
12
+ mate: TeammateRow;
13
+ name: string;
14
+ role: string;
15
+ project: string;
16
+ };
17
+ export type TeamEntry = {
18
+ id: string;
19
+ conversation: string;
20
+ name: string;
21
+ members: string[];
22
+ purpose: string;
23
+ projects: string[];
24
+ };
25
+ export type PeopleIndex = {
26
+ people: PersonEntry[];
27
+ teammates: MateEntry[];
28
+ teams: TeamEntry[];
29
+ };
30
+ /** get_person's id shapes. */
31
+ export declare const PERSON_ID: RegExp;
32
+ /** The team chat this conversation is, or null when it is the person's own chat with their lead. */
33
+ export declare function teamRoomOf(store: Store, thread: number | undefined): string | null;
34
+ /** Everyone the owner's lead works with, in order; in a team chat (`room`), only that room and its members. */
35
+ export declare function peopleIndexOf(store: Store, owner: string, repos: readonly string[], room?: string | null): PeopleIndex;
36
+ /** One line each, as the bundle carries them. Free text goes through `redact`; names and project ids do not. */
37
+ export declare function peopleLines(index: PeopleIndex, redact: (text: string) => string): {
38
+ people: string[];
39
+ teammates: string[];
40
+ teams: string[];
41
+ };
42
+ /** One entry in full by its id (p3fa91c2e, t5, c07b1d9a4) or by name; the candidates when a name fits several, null when
43
+ * none. In a team chat (`room`), only that room and its members. */
44
+ export declare function personEntry(store: Store, owner: string, repos: readonly string[], ask: {
45
+ id?: string;
46
+ name?: string;
47
+ }, room?: string | null): {
48
+ found: Record<string, unknown>;
49
+ } | {
50
+ several: {
51
+ id: string;
52
+ name: string;
53
+ kind: string;
54
+ }[];
55
+ } | null;
@@ -0,0 +1,128 @@
1
+ /** Who the lead works with: the people on this installation who share a project with its owner, the AI teammates in
2
+ * those projects, and the team chats the owner is in. The bundle carries one line each (lead-context.ts); get_person
3
+ * reads one in full, with their open work. Ids stay the same from turn to turn however the list changes: p and c plus a
4
+ * short digest of the account or team chat, t plus the teammate's number. In a team chat the lead speaks for the room,
5
+ * so only that room and its members are listed. Only the owner's own projects are listed, by their r1.. ids; first
6
+ * names are shown on purpose, everything else is scrubbed by the caller. */
7
+ import { createHash } from "node:crypto";
8
+ import { firstNameOf } from "./lead-context.js";
9
+ import { labelOf, nameOf, zonesOf } from "./teammate-admin.js";
10
+ import { parseSoul } from "./teammates.js";
11
+ import { deskOf } from "./teammate-desk.js";
12
+ const OPEN_TASK = new Set(["queued", "running", "failed"]);
13
+ /** get_person's id shapes. */
14
+ export const PERSON_ID = /^(p[0-9a-f]{8}|t[0-9]{1,9}|c[0-9a-f]{8})$/;
15
+ const stableId = (prefix, key) => `${prefix}${createHash("sha256").update(key).digest("hex").slice(0, 8)}`;
16
+ /** The team chat this conversation is, or null when it is the person's own chat with their lead. */
17
+ export function teamRoomOf(store, thread) {
18
+ if (thread === undefined)
19
+ return null;
20
+ try {
21
+ const row = store.handle.prepare("SELECT id FROM team_conversation WHERE thread = ?").get(thread);
22
+ return row === undefined ? null : String(row["id"]);
23
+ }
24
+ catch {
25
+ return null;
26
+ } // an older store has no team chats
27
+ }
28
+ const line = (text, max) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; };
29
+ /** Everyone the owner's lead works with, in order; in a team chat (`room`), only that room and its members. */
30
+ export function peopleIndexOf(store, owner, repos, room = null) {
31
+ const label = (repo) => { const index = repos.indexOf(repo); return index === -1 ? null : `r${index + 1}`; };
32
+ const named = (list) => list.map(label).filter((one) => one !== null);
33
+ const inRoom = room === null ? null : new Set(store.handle.prepare("SELECT account FROM team_participant WHERE conversation = ? AND active = 1").all(room).map(one => String(one["account"])));
34
+ const people = store.accountFacts()
35
+ .filter(one => one.name !== owner && one.revokedAt === null && (inRoom === null || inRoom.has(one.name)))
36
+ .map(one => ({ one, shared: one.projects === null ? [...repos] : repos.filter(repo => one.projects.includes(repo)) }))
37
+ .filter(({ shared }) => shared.length > 0)
38
+ .map(({ one, shared }) => ({ id: stableId("p", one.name), account: one.name, name: firstNameOf(one.name) || one.name,
39
+ role: one.role === "approver" ? "approves work" : "watches work",
40
+ // No person has a written profile yet; the field is there for when one does.
41
+ projects: named(shared), profile: null }));
42
+ // AI teammates are not members of a team chat.
43
+ const teammates = room !== null ? [] : store.teammates(repos).map(mate => {
44
+ const soul = parseSoul(mate.soul);
45
+ return { id: `t${mate.id}`, mate, name: nameOf(mate), role: soul.ok ? soul.soul.role : labelOf(mate), project: label(mate.repo) ?? "" };
46
+ });
47
+ let teams = [];
48
+ try {
49
+ const rows = store.handle.prepare(`SELECT c.id, c.title, c.projects_json, l.name AS lead, l.instructions FROM team_conversation c JOIN team_lead l ON l.id = c.lead
50
+ WHERE EXISTS (SELECT 1 FROM team_participant p WHERE p.conversation = c.id AND p.account = ? AND p.active = 1)
51
+ OR (c.visibility = 'team' AND EXISTS (SELECT 1 FROM team_lead_member m WHERE m.lead = c.lead AND m.account = ? AND m.active = 1))
52
+ ORDER BY c.created_at, c.id`).all(owner, owner);
53
+ teams = rows.filter(row => room === null || String(row["id"]) === room).map(row => {
54
+ const members = store.handle.prepare("SELECT account FROM team_participant WHERE conversation = ? AND active = 1 ORDER BY account").all(row["id"])
55
+ .map(one => String(one["account"])).map(account => account === owner ? "you" : firstNameOf(account) || account);
56
+ let projects = [];
57
+ try {
58
+ const listed = JSON.parse(String(row["projects_json"]));
59
+ if (Array.isArray(listed))
60
+ projects = named(listed.map(String));
61
+ }
62
+ catch { /* none named */ }
63
+ const purpose = String(row["instructions"] ?? "").split("\n").map(one => one.trim()).find(one => one !== "") ?? "";
64
+ return { id: stableId("c", String(row["id"])), conversation: String(row["id"]), name: String(row["title"]), members, purpose: purpose || String(row["lead"]), projects };
65
+ });
66
+ }
67
+ catch { /* an older store has no team chats */ }
68
+ return { people, teammates, teams };
69
+ }
70
+ /** One line each, as the bundle carries them. Free text goes through `redact`; names and project ids do not. */
71
+ export function peopleLines(index, redact) {
72
+ return {
73
+ people: index.people.map(one => `${one.id} ${one.name}: ${one.role}; ${one.projects.join(", ")}${one.profile === null ? "" : `; ${line(redact(one.profile), 120)}`}`),
74
+ teammates: index.teammates.map(one => `${one.id} ${one.name}: ${line(redact(one.role), 60)}${one.project === "" ? "" : ` (${one.project})`}`),
75
+ teams: index.teams.map(one => `${one.id} ${line(redact(one.name), 60)}: ${one.members.join(", ")}; ${line(redact(one.purpose), 120)}`),
76
+ };
77
+ }
78
+ /** The work still open for one person: tasks last filed, approved or handed to them in the owner's projects, and flow cards they own. */
79
+ function openWorkOf(store, account, repos) {
80
+ const repoId = (repo) => `r${repos.indexOf(repo) + 1}`;
81
+ const tasks = repos.length === 0 ? [] : store.handle.prepare(`SELECT r.external_id AS task, r.repo FROM task_ref r JOIN task_act a ON a.id = (SELECT MAX(id) FROM task_act WHERE task_ref = r.id AND act IN ('filed', 'approved', 'asked'))
82
+ WHERE r.repo IN (${repos.map(() => "?").join(", ")}) AND ((a.act IN ('filed', 'approved') AND a.account = ?) OR (a.act = 'asked' AND a.person = ?)) ORDER BY r.id DESC LIMIT 100`).all(...repos, account, account)
83
+ .flatMap(row => { const task = store.getTask(String(row["task"])); return task !== null && OPEN_TASK.has(task.state) ? [{ task: task.id, title: task.title, state: task.state, repo: repoId(String(row["repo"])) }] : []; })
84
+ .slice(0, 10);
85
+ const cards = repos.flatMap(repo => store.activeFlowCards(repo).filter(card => card.owner === account).map(card => ({ card: card.id, flow: card.flow, title: card.title, zone: card.stage, repo: repoId(repo) }))).slice(0, 10);
86
+ return { tasks, cards };
87
+ }
88
+ /** One entry in full by its id (p3fa91c2e, t5, c07b1d9a4) or by name; the candidates when a name fits several, null when
89
+ * none. In a team chat (`room`), only that room and its members. */
90
+ export function personEntry(store, owner, repos, ask, room = null) {
91
+ const index = peopleIndexOf(store, owner, repos, room);
92
+ const wanted = ask.name?.trim().toLowerCase() ?? "";
93
+ const all = [
94
+ ...index.people.map(one => ({ id: one.id, name: one.name, kind: "person", also: one.account })),
95
+ ...index.teammates.map(one => ({ id: one.id, name: one.name, kind: "AI teammate", also: one.mate.handle })),
96
+ ...index.teams.map(one => ({ id: one.id, name: one.name, kind: "team chat", also: "" })),
97
+ ];
98
+ const hits = ask.id !== undefined ? all.filter(one => one.id === ask.id)
99
+ : all.filter(one => one.name.toLowerCase() === wanted || one.also.toLowerCase() === wanted);
100
+ const loose = hits.length > 0 || wanted.length < 2 ? hits : all.filter(one => one.name.toLowerCase().includes(wanted));
101
+ if (loose.length === 0)
102
+ return null;
103
+ if (loose.length > 1)
104
+ return { several: loose.map(({ id, name, kind }) => ({ id, name, kind })) };
105
+ const id = loose[0].id;
106
+ const person = index.people.find(one => one.id === id);
107
+ if (person !== undefined) {
108
+ const work = openWorkOf(store, person.account, repos);
109
+ return { found: { id, kind: "person", name: person.name, role: person.role, projects: person.projects, profile: person.profile, openTasks: work.tasks, openCards: work.cards } };
110
+ }
111
+ const mate = index.teammates.find(one => one.id === id);
112
+ if (mate !== undefined) {
113
+ const flows = new Set([...zonesOf(store, mate.mate).map(one => `${one.flow}:${one.zone}`)]);
114
+ const desk = deskOf(store, mate.mate)?.id ?? null;
115
+ const cards = store.activeFlowCards(mate.mate.repo).filter(card => flows.has(`${card.flow}:${card.stage}`) || card.flow === desk)
116
+ .slice(0, 10).map(card => ({ card: card.id, flow: card.flow, title: card.title, zone: card.stage }));
117
+ return { found: { id, kind: "AI teammate", name: mate.name, role: mate.role, project: mate.project, working: mate.mate.state === "active", soul: mate.mate.soul,
118
+ worksOn: zonesOf(store, mate.mate).map(one => ({ flow: one.flowName, zone: one.title, how: one.kind })),
119
+ openTasks: cards, questions: store.openTeammateQuestions([mate.mate.id]).map(one => ({ question: one.id, asks: one.question })),
120
+ more: "get_teammates with this teammate's number reads its memory, routines, tools and recent log." } };
121
+ }
122
+ const team = index.teams.find(one => one.id === id);
123
+ const purpose = String(store.handle.prepare("SELECT l.instructions FROM team_conversation c JOIN team_lead l ON l.id = c.lead WHERE c.id = ?").get(team.conversation)?.["instructions"] ?? "");
124
+ const tasks = store.handle.prepare("SELECT r.external_id AS task, r.repo FROM team_task_owner o JOIN task_ref r ON r.id = o.task_ref WHERE o.conversation = ? ORDER BY r.id DESC LIMIT 100").all(team.conversation)
125
+ .flatMap(row => { const task = store.getTask(String(row["task"])), repo = String(row["repo"]); return task !== null && OPEN_TASK.has(task.state) && repos.includes(repo) ? [{ task: task.id, title: task.title, state: task.state, repo: `r${repos.indexOf(repo) + 1}` }] : []; })
126
+ .slice(0, 10);
127
+ return { found: { id, kind: "team chat", name: team.name, members: team.members, purpose: line(purpose || team.purpose, 600), projects: team.projects, openTasks: tasks } };
128
+ }
@@ -1,14 +1,12 @@
1
1
  /** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
2
- * console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
3
- * in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
2
+ * console. `lead say` posts one short message from the lead, by the name its person gave it (Settings → Lead); several within two minutes are one message, edited
3
+ * in place as it grows. `assignment claim` marks a task the lead's: it reads "<name> is on it" and leaves Needs
4
4
  * you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
5
5
  *
6
6
  * Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
7
7
  * ordinary notification addressed to the owner, so every chat delivers it with the usual receipts. */
8
8
  import { type Actor } from "./actor.js";
9
9
  import { type Notification, type Store } from "./store.js";
10
- /** Who the person's chat says the lead's messages are from. */
11
- export declare const LEAD_NAME = "Your lead";
12
10
  export declare const LEAD_SAY_KIND = "lead-say";
13
11
  /** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
14
12
  export declare const LEAD_ON_IT_KIND = "lead-on-it";
@@ -34,7 +32,8 @@ export type LeadActivity = {
34
32
  owner: string;
35
33
  doing: string;
36
34
  at: string;
37
- taskId: string | null;
35
+ taskId: string | null; /** What the owner calls their lead. */
36
+ name: string;
38
37
  };
39
38
  type Failure = {
40
39
  ok: false;
@@ -51,8 +50,13 @@ export declare function leadSay(store: Store, actor: Actor, text: string, taskId
51
50
  joined: boolean;
52
51
  notification: number | null;
53
52
  } | Failure;
54
- /** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
55
- export declare function leadSayText(row: Pick<Notification, "subject" | "body">): string;
53
+ /** A lead row's subject in its owner's current name for their lead, read when it is shown, so a rename reaches
54
+ * earlier cards too; any other row's own subject. `reader`: who the chat belongs to, for a row with no recipient
55
+ * (only its owner ever receives a lead row). */
56
+ export declare function leadSubjectOf(store: Store, row: Pick<Notification, "kind" | "subject" | "recipient">, reader?: string | null): string;
57
+ /** The words a chat shows for a lead-say row: the lead's name, then each line, plain, with no task id. `reader`: whose
58
+ * chat it is, for a row with no recipient (see leadSubjectOf). */
59
+ export declare function leadSayText(store: Store, row: Pick<Notification, "kind" | "subject" | "body" | "recipient">, reader?: string | null): string;
56
60
  /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
57
61
  export declare function leadSayEarlier(store: Store, row: Pick<Notification, "id" | "kind" | "dedupeKey" | "recipient">): number[];
58
62
  /** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
@@ -72,10 +76,10 @@ export declare function enqueueLeadLapses(store: Store, now: Date): number;
72
76
  /** The lead's act on a task that a claim counts (a retry, a state change, a steer): one ledger row as the lead. */
73
77
  export declare function noteLeadWork(store: Store, actor: Actor, taskId: string, what: string, now: Date): void;
74
78
  /** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
75
- * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
79
+ * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never theirs. */
76
80
  export declare function leadActivity(store: Store, owner: string | null | undefined): LeadActivity | null;
77
81
  /** "just now", "3 min ago", "2 h ago", "yesterday", "4 days ago". */
78
82
  export declare function agoWords(at: string, now: Date): string;
79
- /** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
83
+ /** The one line `toolroll status` and the console say: "Lead: fixing 0.9.12's release check · 3 min ago". */
80
84
  export declare function leadActivityLine(activity: LeadActivity, now: Date): string;
81
85
  export {};
@@ -1,6 +1,6 @@
1
1
  /** The lead's voice: what the person's lead (an agent under their lead token) is doing, in their chat and on the
2
- * console. `lead say` posts one short message from "Your lead"; several within two minutes are one message, edited
3
- * in place as it grows. `assignment claim` marks a task the lead's: it reads "Your lead is on it" and leaves Needs
2
+ * console. `lead say` posts one short message from the lead, by the name its person gave it (Settings → Lead); several within two minutes are one message, edited
3
+ * in place as it grows. `assignment claim` marks a task the lead's: it reads "<name> is on it" and leaves Needs
4
4
  * you until the lead completes it, hands it to a person, or two hours pass with no lead act on it.
5
5
  *
6
6
  * Every fact is an append-only ledger row under "lead for <owner>"; no table of its own. The chat message is an
@@ -9,10 +9,9 @@ import { actorLabel, withActor } from "./actor.js";
9
9
  import { chatControlHref } from "./chat-controls.js";
10
10
  import { BATCH_MS, chatText, chatTitle } from "./chat-voice.js";
11
11
  import { BUILT_IN } from "./store.js";
12
- import { LEAD_LAPSED } from "./task-status.js";
12
+ import { leadNameOf } from "./lead-identity.js";
13
+ import { leadLapsed } from "./task-status.js";
13
14
  import { phoneText as plain } from "./telegram-status.js";
14
- /** Who the person's chat says the lead's messages are from. */
15
- export const LEAD_NAME = "Your lead";
16
15
  export const LEAD_SAY_KIND = "lead-say";
17
16
  /** Repaints the owner's task card when the lead takes a task on: it never makes a new message in quiet chat. */
18
17
  export const LEAD_ON_IT_KIND = "lead-on-it";
@@ -97,15 +96,26 @@ export function leadSay(store, actor, text, taskId, now) {
97
96
  if (joined)
98
97
  store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(stamp, Number(open["id"]));
99
98
  const key = `lead-say:${joined ? opened : stamp}:${ledger}`;
100
- store.enqueueNotification({ dedupeKey: key, kind: LEAD_SAY_KIND, subject: LEAD_NAME, body: lines.join("\n"),
99
+ store.enqueueNotification({ dedupeKey: key, kind: LEAD_SAY_KIND, subject: leadNameOf(store, actor.account), body: lines.join("\n"),
101
100
  recipient: actor.account, source: { installation: true }, ...(taskId === null ? {} : { link: chatControlHref("task", taskId) }) }, now);
102
101
  const row = store.handle.prepare("SELECT id FROM notification WHERE dedupe_key = ?").get(key);
103
102
  return { ok: true, joined, notification: row === undefined ? null : Number(row["id"]) };
104
103
  });
105
104
  }
106
- /** The words a chat shows for a lead-say row: "Your lead", then each line, plain, with no task id. */
107
- export function leadSayText(row) {
108
- return chatText([plain(row.subject, 60), "", ...row.body.split("\n").map(line => plain(line, LEAD_SAY_MAX)).filter(line => line !== "")].join("\n"));
105
+ /** A lead row's subject in its owner's current name for their lead, read when it is shown, so a rename reaches
106
+ * earlier cards too; any other row's own subject. `reader`: who the chat belongs to, for a row with no recipient
107
+ * (only its owner ever receives a lead row). */
108
+ export function leadSubjectOf(store, row, reader) {
109
+ const owner = row.recipient ?? reader ?? null;
110
+ if (owner === null || (row.kind !== LEAD_SAY_KIND && row.kind !== LEAD_ON_IT_KIND && row.kind !== LEAD_LAPSED_KIND))
111
+ return row.subject;
112
+ const name = leadNameOf(store, owner);
113
+ return row.kind === LEAD_SAY_KIND ? name : row.kind === LEAD_ON_IT_KIND ? `${name} is on it` : leadLapsed(name);
114
+ }
115
+ /** The words a chat shows for a lead-say row: the lead's name, then each line, plain, with no task id. `reader`: whose
116
+ * chat it is, for a row with no recipient (see leadSubjectOf). */
117
+ export function leadSayText(store, row, reader) {
118
+ return chatText([plain(leadSubjectOf(store, row, reader), 60), "", ...row.body.split("\n").map(line => plain(line, LEAD_SAY_MAX)).filter(line => line !== "")].join("\n"));
109
119
  }
110
120
  /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
111
121
  export function leadSayEarlier(store, row) {
@@ -129,9 +139,9 @@ export function leadClaim(store, actor, taskId, now) {
129
139
  store.transact(() => {
130
140
  const ledger = store.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: task.repo, taskId: root, runId: null,
131
141
  action: LEAD_CLAIMED, outcome: "recorded", source: "work" });
132
- // The owner's card for this task repaints to "Your lead is on it": quiet chat edits its card, and every-update
142
+ // The owner's card for this task repaints to "<name> is on it": quiet chat edits its card, and every-update
133
143
  // chat edits the attempt's own card (the Failed alert), or follows it up when that message is gone.
134
- store.enqueueNotification({ dedupeKey: `lead-on-it:${ledger}`, kind: LEAD_ON_IT_KIND, subject: "Your lead is on it", body: "",
144
+ store.enqueueNotification({ dedupeKey: `lead-on-it:${ledger}`, kind: LEAD_ON_IT_KIND, subject: `${leadNameOf(store, actor.account)} is on it`, body: "",
135
145
  link: chatControlHref("task", taskId), source: cardSource(store, root) ?? { taskRef: task.refId } }, now);
136
146
  });
137
147
  return { ok: true, root, claim: leadClaimOf(store, taskId, now, actor.account) };
@@ -210,7 +220,7 @@ export function enqueueLeadLapses(store, now) {
210
220
  continue;
211
221
  // The owner's lead let it go: only its owner's card hears of it (store.pingAllowed).
212
222
  if (withActor({ account: owner, lead: true }, () => store.enqueueNotification({ dedupeKey: `lead-lapsed:${root}:${owner}:${claim.lastActedAt}`,
213
- kind: LEAD_LAPSED_KIND, subject: LEAD_LAPSED, body: "", link: chatControlHref("task", root), source }, now)))
223
+ kind: LEAD_LAPSED_KIND, subject: leadLapsed(leadNameOf(store, owner)), body: "", link: chatControlHref("task", root), source }, now)))
214
224
  count++;
215
225
  }
216
226
  }
@@ -233,7 +243,7 @@ const ACT_WORDS = {
233
243
  "task handed to a person": "handed on", [LEAD_CLAIMED]: "working on",
234
244
  };
235
245
  /** What `owner`'s own lead is doing now and when it last acted, both from its one newest act: its words when that
236
- * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never "Your lead". */
246
+ * was a say, else the act in words. Nobody (null) has no lead line: another person's lead is never theirs. */
237
247
  export function leadActivity(store, owner) {
238
248
  if (owner == null || owner === "")
239
249
  return null;
@@ -250,7 +260,7 @@ export function leadActivity(store, owner) {
250
260
  } })();
251
261
  const doing = last["action"] === LEAD_SAID && last["detail"] != null ? chatText(String(last["detail"]), task === null ? [] : [{ id: task, title: title.trim() }])
252
262
  : `${ACT_WORDS[String(last["action"])] ?? String(last["action"]).replace(/^lead /, "")}${title}`;
253
- return { owner, doing: doing.replace(/[.\s]+$/u, ""), at: String(last["at"]), taskId: task };
263
+ return { owner, doing: doing.replace(/[.\s]+$/u, ""), at: String(last["at"]), taskId: task, name: leadNameOf(store, owner) };
254
264
  }
255
265
  catch {
256
266
  return null;
@@ -269,7 +279,7 @@ export function agoWords(at, now) {
269
279
  const days = Math.floor(hours / 24);
270
280
  return days === 1 ? "yesterday" : `${days} days ago`;
271
281
  }
272
- /** The one line `toolroll status` and the console say: "Your lead: fixing 0.9.12's release check · 3 min ago". */
282
+ /** The one line `toolroll status` and the console say: "Lead: fixing 0.9.12's release check · 3 min ago". */
273
283
  export function leadActivityLine(activity, now) {
274
- return `${LEAD_NAME}: ${activity.doing} · ${agoWords(activity.at, now)}`;
284
+ return `${activity.name}: ${activity.doing} · ${agoWords(activity.at, now)}`;
275
285
  }
@@ -1,4 +1,5 @@
1
1
  import { projectName } from "./project.js";
2
+ import { exactWhenHtml } from "./when-html.js";
2
3
  const html = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
3
4
  /** The ledger's kinds of event, as the filter names them. */
4
5
  const EVENT_KINDS = [["work", "Work"], ["request", "Requests"], ["access", "Access"], ["sign-in", "Sign-ins"], ["policy", "Policy changes"]];
@@ -47,11 +48,11 @@ export function ledgerBody(rows, projects, params, chain) {
47
48
  <label>Outcome<input type="text" name="outcome" value="${html(params.get("outcome") ?? "")}" placeholder="Any outcome"></label>
48
49
  <label>Task<input type="text" name="task" value="${html(params.get("task") ?? "")}" placeholder="Any task"></label>
49
50
  <button type="submit">Filter</button><a href="/ledger">Clear</a></form></details>
50
- <div class="ledger-list" aria-label="Action history"><div class="ledger-event ledger-labels" aria-hidden="true"><span>Time (UTC)</span><span>Actor</span><span>Action</span><span>Project / work</span><span>Outcome / event</span></div>${shown.length === 0 ? `<p class="ledger-empty">No actions match these filters yet.</p>` : shown.map(row => {
51
+ <div class="ledger-list" aria-label="Action history"><div class="ledger-event ledger-labels" aria-hidden="true"><span>Time</span><span>Actor</span><span>Action</span><span>Project / work</span><span>Outcome / event</span></div>${shown.length === 0 ? `<p class="ledger-empty">No actions match these filters yet.</p>` : shown.map(row => {
51
52
  const subject = row.runId !== null ? `<a href="/r/${row.runId}">Run #${row.runId}</a>` : row.taskId !== null ? `<a href="/t/${encodeURIComponent(row.taskId)}">${html(row.taskId)}</a>` : "";
52
53
  const byActor = new URLSearchParams(fresh);
53
54
  byActor.set("actor", row.actor);
54
- return `<article class="ledger-event" data-ledger-id="${row.id}"><div class="ledger-time"><time datetime="${html(row.at)}" title="${html(row.at)}">${html(row.at.slice(0, 19).replace("T", " "))}</time></div><div class="ledger-actor"><a class="actor" href="/ledger?${html(byActor.toString())}" title="Filter by this actor">${html(row.actor)}</a></div><div class="ledger-action">${html(row.action)}${row.detail === null ? "" : `<small class="ledger-detail">${html(row.detail)}</small>`}</div><div class="ledger-place">${row.repo === null ? "Instance" : html(projectName(row.repo))} ${subject}</div><div class="ledger-result">${html(row.outcome)}<small>${html(row.source)}</small></div></article>`;
55
+ return `<article class="ledger-event" data-ledger-id="${row.id}"><div class="ledger-time">${exactWhenHtml(row.at)}</div><div class="ledger-actor"><a class="actor" href="/ledger?${html(byActor.toString())}" title="Filter by this actor">${html(row.actor)}</a></div><div class="ledger-action">${html(row.action)}${row.detail === null ? "" : `<small class="ledger-detail">${html(row.detail)}</small>`}</div><div class="ledger-place">${row.repo === null ? "Instance" : html(projectName(row.repo))} ${subject}</div><div class="ledger-result">${html(row.outcome)}<small>${html(row.source)}</small></div></article>`;
55
56
  }).join("")}</div>
56
57
  <p class="row"><a href="/ledger?${html(fresh.toString())}">Newest actions</a>${more ? `<a rel="next" href="/ledger?${html(next.toString())}">Older actions</a>` : ""}</p>
57
58
  <p class="meta">Accepted requests record acceptance; worker events record results. History starts from this upgrade. Review evidence is on each task or run.</p>`;
@@ -1,4 +1,5 @@
1
- import type { MateProposal, Store } from "./store.js";
1
+ import { type MateProposal, type Store } from "./store.js";
2
+ import { type ShapeOptions } from "./reply-shape.js";
2
3
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
3
4
  export type MateCliSeams = {
4
5
  fetcher?: typeof fetch;
@@ -27,6 +28,8 @@ export type MateCliInput = {
27
28
  seams?: MateCliSeams;
28
29
  /** Where evidence lives — a scout's report reads from here. */
29
30
  evidenceRoot?: string;
31
+ /** The console's address(es): a reply's links there are named as its pages ("the task"), not by host. */
32
+ appOrigin?: ShapeOptions["appOrigin"];
30
33
  };
31
34
  export type MateCliResult = {
32
35
  code: number;
package/dist/mate-cli.js CHANGED
@@ -10,9 +10,11 @@ import { configureLeadFollow, leadFollowStatus } from './lead-follow.js';
10
10
  import { createHash } from "node:crypto";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { dirname, join } from "node:path";
13
+ import { MATE_ASK_OTHER } from "./store.js";
13
14
  import { CHAT_KEY_ENV, credentialKeyOf, isDirectChatProvider, priceForConfig, subscriptionCredentialKey } from "./converse.js";
14
15
  import { verifyApproverByPassword } from "./principal.js";
15
16
  import { runMateTurn } from "./mate.js";
17
+ import { voiceReply } from "./reply-shape.js";
16
18
  import { confirmMateProposal, dismissMateProposal } from "./mate-doors.js";
17
19
  import { projectName } from "./project.js";
18
20
  import { CHAT_CONTROLS, chatControlHref, isChatControl } from "./chat-controls.js";
@@ -152,7 +154,7 @@ export async function runMateCli(input) {
152
154
  return refuse("no-key", `no ${directProvider} key — export ${CHAT_KEY_ENV[directProvider]}, or paste one on the console's chat page`);
153
155
  const repos = [...input.repos];
154
156
  if (repos.length === 0)
155
- return refuse("empty-ceiling", "the mate needs projects to see — name them with --repo, or enroll some in the console", MATE_CLI_EXIT.usage);
157
+ return refuse("empty-ceiling", "The lead has no projects to look at. Name some with --repo, or add projects in the console.", MATE_CLI_EXIT.usage);
156
158
  const verified = verifyApproverByPassword(store, input.credentials.name, input.credentials.token, repos);
157
159
  if (!verified.ok)
158
160
  return refuse("unauthenticated", "that is not an approver, or the password does not match");
@@ -163,7 +165,7 @@ export async function runMateCli(input) {
163
165
  const sessions = store.endMateSessionsFor(who.name, who.name, now);
164
166
  const threads = store.closeMateThreadsFor(who.name, now);
165
167
  emit({ ok: true, ended: { sessions, threads, turns } });
166
- say(sessions === 0 ? "no mate session was live; the thread is gone" : "the session is over and the thread is forgotten");
168
+ say(sessions === 0 ? "No conversation was open, so there was nothing to end." : "The conversation has ended and its history is forgotten.");
167
169
  return { code: MATE_CLI_EXIT.ok };
168
170
  }
169
171
  store.sweepStaleMateTurns(now);
@@ -172,12 +174,12 @@ export async function runMateCli(input) {
172
174
  if (session !== null && session.credentialKey !== credentialKey) {
173
175
  // A session minted under another provider key cannot be spent by this
174
176
  // one (slice-2 review, finding 9): say so, never loop on `not-yours`.
175
- return refuse("key-mismatch", "the live mate session was minted under a different provider key — use that key, or end the session with --end and mint a new one");
177
+ return refuse("key-mismatch", "The open conversation was started with a different provider key. Use that key, or end it with --end and start a new one.");
176
178
  }
177
179
  if (session !== null && session.ceilingDigest !== who.ceilingDigest) {
178
180
  store.endMateSession(session.id, who.name, now);
179
181
  store.closeMateThreadsFor(who.name, now);
180
- say("the projects named differ from the live session's — that session ended; minting a new one");
182
+ say("The projects you named differ from the open conversation's, so it ended. Starting a new one.");
181
183
  session = null;
182
184
  }
183
185
  if (session === null) {
@@ -189,15 +191,15 @@ export async function runMateCli(input) {
189
191
  const id = store.mintMateSession({ approver: who.name, approverGeneration: who.generation, credentialKey, ceilingMicrousd, ceilingDigest: who.ceilingDigest, termsDigest }, now);
190
192
  session = store.getMateSession(id);
191
193
  if (session === null)
192
- return refuse("failed", "the session could not be minted", MATE_CLI_EXIT.failed);
194
+ return refuse("failed", "A new conversation couldn't be started. Try again.", MATE_CLI_EXIT.failed);
193
195
  if (!direct && input.ceilingUsd !== undefined)
194
196
  say("the supplied --ceiling-usd value is ignored for membership usage");
195
- say(`mate conversation started: ${direct ? `up to ${money(ceilingMicrousd)}` : "subscription usage (no dollar ceiling)"} over ${repos.map((one, index) => `r${index + 1} ${projectName(one)}`).join(", ")} — live until you end it`);
197
+ say(`mate conversation started: ${direct ? `up to ${money(ceilingMicrousd)}` : "subscription usage (no dollar limit)"} over ${repos.map(one => projectName(one)).join(", ")} — live until you end it`);
196
198
  if (direct)
197
- say(`(the weekly chat ceiling, ${money(config.weeklyCeilingMicrousd)}, still binds above it)`);
199
+ say(`(the weekly chat limit, ${money(config.weeklyCeilingMicrousd)}, still applies)`);
198
200
  }
199
201
  else {
200
- say(`mate conversation live: ${direct ? `${money(session.spentMicrousd)} of ${money(session.ceilingMicrousd)} spent` : "subscription usage (no dollar ceiling)"} — live until you end it`);
202
+ say(`mate conversation live: ${direct ? `${money(session.spentMicrousd)} of ${money(session.ceilingMicrousd)} spent` : "subscription usage (no dollar limit)"} — live until you end it`);
201
203
  }
202
204
  const thread = store.openMateThread(who.name, who.ceilingDigest, now).thread;
203
205
  if (input.follow !== undefined) {
@@ -239,21 +241,26 @@ export async function runMateCli(input) {
239
241
  const turn = async (message) => {
240
242
  const live = store.activeMateSession(who.name);
241
243
  if (live === null) {
242
- const outcome = { ok: false, refused: "session-ended", message: "this mate session has ended — run chat again to mint one" };
244
+ const outcome = { ok: false, refused: "session-ended", message: "This conversation has ended. Run chat again to start a new one." };
243
245
  return outcome;
244
246
  }
245
- return runMateTurn({ store, who, session: live, thread, config, key, message, ...(seams.fetcher === undefined ? {} : { fetcher: seams.fetcher }), ...(seams.subscriptionRunner === undefined ? {} : { subscriptionRunner: seams.subscriptionRunner }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), clock });
247
+ return runMateTurn({ store, who, session: live, thread, config, key, message, channel: "terminal", ...(seams.fetcher === undefined ? {} : { fetcher: seams.fetcher }), ...(seams.subscriptionRunner === undefined ? {} : { subscriptionRunner: seams.subscriptionRunner }), ...(input.evidenceRoot === undefined ? {} : { evidenceRoot: input.evidenceRoot }), clock });
246
248
  };
247
- const report = (outcome) => {
249
+ const report = (outcome, message) => {
248
250
  if (outcome.ok && outcome.replayed) {
249
251
  emit(outcome);
250
252
  say(`Message already received as turn #${outcome.turn}.`);
251
253
  return;
252
254
  }
253
255
  if (outcome.ok) {
254
- emit({ ok: true, turn: outcome.turn, reply: outcome.reply, activity: outcome.activity, steps: outcome.steps, settledMicrousd: outcome.settledMicrousd, proposals: pendingProposals().map(one => ({ id: one.id, ordinal: ordinalOf(one), kind: one.kind, payload: one.payload })) });
256
+ const ask = store.mateAsk(outcome.turn);
257
+ emit({ ok: true, turn: outcome.turn, reply: outcome.reply, activity: outcome.activity, steps: outcome.steps, settledMicrousd: outcome.settledMicrousd, proposals: pendingProposals().map(one => ({ id: one.id, ordinal: ordinalOf(one), kind: one.kind, payload: one.payload })),
258
+ ...(ask === null ? {} : { ask: { question: ask.question, options: [...ask.options, MATE_ASK_OTHER] } }) });
255
259
  say(` ${outcome.activity}`);
256
- say(outcome.reply);
260
+ say(voiceReply(outcome.reply, "terminal", { appOrigin: input.appOrigin ?? null, ...(message === undefined ? {} : { asked: message }) }));
261
+ // The lead's question: the terminal has no buttons, so the options are listed to type back.
262
+ if (ask !== null)
263
+ say(`${ask.question}\n${ask.options.map(one => ` · ${one}`).join("\n")}\n · ${MATE_ASK_OTHER} (type your answer)`);
257
264
  printProposals();
258
265
  return;
259
266
  }
@@ -262,7 +269,7 @@ export async function runMateCli(input) {
262
269
  };
263
270
  if (input.say !== undefined) {
264
271
  const outcome = await turn(input.say);
265
- report(outcome);
272
+ report(outcome, input.say);
266
273
  return { code: outcome.ok ? MATE_CLI_EXIT.ok : MATE_CLI_EXIT.refused };
267
274
  }
268
275
  // The REPL. Text is a turn; a few words are acts on the cards.
@@ -331,7 +338,7 @@ export async function runMateCli(input) {
331
338
  store.failLiveMateTurnsFor(who.name, "ended", now);
332
339
  store.endMateSessionsFor(who.name, who.name, now);
333
340
  store.closeMateThreadsFor(who.name, now);
334
- say("the session is over and the thread is forgotten");
341
+ say("The conversation has ended and its history is forgotten.");
335
342
  emit({ ok: true, ended: true });
336
343
  return { code: MATE_CLI_EXIT.ok };
337
344
  }
@@ -339,7 +346,7 @@ export async function runMateCli(input) {
339
346
  say("say something, or: proposals · confirm N · dismiss N · open N · end · quit");
340
347
  continue;
341
348
  }
342
- report(await turn(line));
349
+ report(await turn(line), line);
343
350
  }
344
351
  return { code: MATE_CLI_EXIT.ok };
345
352
  }
@@ -6,5 +6,5 @@
6
6
  * recommendations never reach the model — the tools already hide them —
7
7
  * so the contract need not forbid repeating what it cannot see.
8
8
  */
9
- export declare const MATE_CONTRACT_VERSION = 43;
9
+ export declare const MATE_CONTRACT_VERSION = 47;
10
10
  export declare const MATE_CONTRACT: string;