toolroll 0.9.16 → 0.9.18

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 (70) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/assignment-brief.js +11 -4
  3. package/dist/browser/workspace.js +14 -14
  4. package/dist/browser-workspace.d.ts +2 -0
  5. package/dist/chat-actions.d.ts +7 -0
  6. package/dist/chat-actions.js +40 -4
  7. package/dist/chat-ask.js +29 -15
  8. package/dist/chat-channel.js +3 -1
  9. package/dist/chat-decide.d.ts +203 -0
  10. package/dist/chat-decide.js +554 -0
  11. package/dist/chat-delivery-state.d.ts +7 -0
  12. package/dist/chat-delivery.d.ts +3 -0
  13. package/dist/chat-delivery.js +63 -17
  14. package/dist/chat-quiet.d.ts +4 -1
  15. package/dist/chat-quiet.js +6 -2
  16. package/dist/chat-warmth.d.ts +21 -0
  17. package/dist/chat-warmth.js +36 -0
  18. package/dist/desktop-update.d.ts +1 -1
  19. package/dist/discord-api.d.ts +3 -1
  20. package/dist/discord-api.js +4 -2
  21. package/dist/discord-chat.d.ts +3 -2
  22. package/dist/discord-chat.js +35 -6
  23. package/dist/lead-about.d.ts +35 -0
  24. package/dist/lead-about.js +62 -0
  25. package/dist/lead-capabilities.d.ts +64 -0
  26. package/dist/lead-capabilities.js +199 -0
  27. package/dist/lead-commitments.js +13 -7
  28. package/dist/lead-context.d.ts +5 -3
  29. package/dist/lead-context.js +29 -7
  30. package/dist/lead-follow.d.ts +1 -1
  31. package/dist/lead-follow.js +3 -2
  32. package/dist/lead-identity.js +1 -1
  33. package/dist/lead-people.d.ts +55 -0
  34. package/dist/lead-people.js +128 -0
  35. package/dist/lead-voice.d.ts +3 -2
  36. package/dist/lead-voice.js +4 -3
  37. package/dist/mate-cli.d.ts +3 -0
  38. package/dist/mate-cli.js +15 -14
  39. package/dist/mate-contract.d.ts +1 -1
  40. package/dist/mate-contract.js +6 -4
  41. package/dist/mate-progress.js +2 -0
  42. package/dist/mate-tools.d.ts +8 -0
  43. package/dist/mate-tools.js +112 -6
  44. package/dist/mate.d.ts +26 -1
  45. package/dist/mate.js +89 -25
  46. package/dist/modes.d.ts +6 -0
  47. package/dist/modes.js +6 -0
  48. package/dist/operate.js +20 -8
  49. package/dist/phase-routing.d.ts +1 -1
  50. package/dist/project-delete.js +3 -0
  51. package/dist/provider-auth.d.ts +4 -0
  52. package/dist/provider-auth.js +1 -1
  53. package/dist/reply-shape.d.ts +96 -0
  54. package/dist/reply-shape.js +452 -0
  55. package/dist/result-shots.d.ts +51 -0
  56. package/dist/result-shots.js +119 -0
  57. package/dist/serve.js +76 -49
  58. package/dist/slack-chat.d.ts +2 -0
  59. package/dist/slack-chat.js +38 -7
  60. package/dist/store.d.ts +36 -3
  61. package/dist/store.js +163 -16
  62. package/dist/team-runtime.js +1 -1
  63. package/dist/teams-chat.js +17 -1
  64. package/dist/telegram-mate.d.ts +12 -0
  65. package/dist/telegram-mate.js +27 -11
  66. package/dist/telegram-team.js +6 -3
  67. package/dist/telegram.d.ts +5 -3
  68. package/dist/telegram.js +227 -21
  69. package/dist/test-temp.js +1 -1
  70. package/package.json +1 -1
@@ -0,0 +1,199 @@
1
+ import { resolveRouteCandidates } from "./agentconfig.js";
2
+ import { authPauseOf, messageCommand, providerName } from "./provider-auth.js";
3
+ import { readAuthMode, SUBSCRIPTION_CAPABLE } from "./keys.js";
4
+ import { DEFAULT_LIVENESS_MS } from "./runner.js";
5
+ import { projectToolsOf, secretsSetFor, toolStanding } from "./project-tools.js";
6
+ import { conversationSkills } from "./project-skills.js";
7
+ import { CHECK_LEVEL_WORDS, liveQuickCommand, projectCheckLevel } from "./check-levels.js";
8
+ import { STATE_WORDS } from "./integrations.js";
9
+ const ROLE_WORDS = { plan: "plan", build: "build", repair: "fix failed checks", review: "review" };
10
+ const MAX_LISTED = 12;
11
+ /** The exact provider and model pairs a project's route would use, with the roles each would play. */
12
+ function routePairs(store, repo) {
13
+ const pairs = new Map();
14
+ const resolved = resolveRouteCandidates(store, repo);
15
+ if (!resolved.ok)
16
+ return { pairs, problem: resolved.problem };
17
+ const add = (phase, candidate) => {
18
+ if (candidate === null)
19
+ return;
20
+ const key = `${candidate.provider} ${candidate.model}`;
21
+ const pair = pairs.get(key) ?? { provider: candidate.provider, model: candidate.model, roles: [] };
22
+ if (!pair.roles.includes(ROLE_WORDS[phase]))
23
+ pair.roles.push(ROLE_WORDS[phase]);
24
+ pairs.set(key, pair);
25
+ };
26
+ for (const phase of ["plan", "build", "repair", "review"]) {
27
+ const leg = resolved.candidates[phase];
28
+ add(phase, leg.routine ?? (phase === "repair" ? resolved.candidates.build.routine : null));
29
+ add(phase, leg.strong);
30
+ }
31
+ return { pairs, problem: null };
32
+ }
33
+ /** A provider's plan or quota running out, from the last readings (provider_limit and the runners' quota rows). */
34
+ function outOfBudget(store, provider, now) {
35
+ const stamp = now.toISOString();
36
+ const window = store.providerLimits().find(one => one.provider === provider && one.reached && (one.resetsAt === null || one.resetsAt > stamp));
37
+ if (window !== undefined)
38
+ return { until: window.resetsAt };
39
+ const quota = store.handle.prepare("SELECT reset_at FROM quota WHERE provider = ? AND state = 'exhausted' AND (reset_at IS NULL OR reset_at > ?) ORDER BY reset_at DESC LIMIT 1").get(provider, stamp);
40
+ return quota === undefined ? null : { until: quota["reset_at"] == null ? null : String(quota["reset_at"]) };
41
+ }
42
+ function agentsOf(store, repo, now, authMode) {
43
+ const { pairs, problem } = routePairs(store, repo);
44
+ if (problem !== null) {
45
+ return { agents: [], agentsProblem: { name: "Agents", state: "Not set up", ok: false, lets: "plan and build this project's tasks", next: `Choose an exact model for each role: ${problem}`, link: "providers" } };
46
+ }
47
+ const readiness = store.readinessLookupFor(repo, null, now);
48
+ const agents = [...pairs.values()].map(pair => {
49
+ const name = `${providerName(pair.provider)} · ${pair.model}`;
50
+ const lets = `${pair.roles.join(", ")} this project's tasks`;
51
+ const base = { provider: pair.provider, model: pair.model, roles: pair.roles, name, lets };
52
+ // Signed out only on a real sign-in failure: a sign-in pause from a run, the worker's login check saying not logged
53
+ // in, or the key a key-only provider needs missing. A provider on an API key gets the key step, never the sign-in.
54
+ const pause = authPauseOf(store, pair.provider);
55
+ const mode = pause?.authMode ?? (SUBSCRIPTION_CAPABLE[pair.provider] ? authMode(pair.provider) : "api-key");
56
+ const key = mode === "api-key";
57
+ const outWords = key ? "Needs a working API key" : "Signed out";
58
+ const outNext = (detail) => key ? `Save a working API key: run \`${messageCommand({ provider: pair.provider, authMode: "api-key" })}\` on this computer${detail}.` : `Sign in: run \`${messageCommand({ provider: pair.provider, authMode: "subscription" })}\` on this computer${detail}.`;
59
+ if (pause !== null)
60
+ return { ...base, state: outWords, ok: false, next: outNext("; its tasks start again on their own"), link: "providers" };
61
+ const seen = readiness(pair.provider);
62
+ const authFailed = seen?.state === "unavailable" && (seen.probe === "key" || (seen.probe === "identity" && !key));
63
+ if (authFailed)
64
+ return { ...base, state: outWords, ok: false, next: outNext(""), link: "providers" };
65
+ if (seen?.state === "unavailable" && seen.probe !== "identity") {
66
+ return { ...base, state: `Not available: ${seen.reason}`, ok: false, next: `Fix it on the worker: ${seen.reason}.`, link: "providers" };
67
+ }
68
+ const spent = outOfBudget(store, pair.provider, now);
69
+ if (spent !== null)
70
+ return { ...base, state: "Out of plan budget", ok: false, next: spent.until === null ? "Wait for the plan to reset, or choose another agent." : `Wait until ${spent.until}, when the plan resets, or choose another agent.`, link: "providers" };
71
+ if (seen === null)
72
+ return { ...base, state: "Not checked yet", ok: false, next: "No worker has reported this agent yet: start a worker for this project.", link: "workers" };
73
+ // On an API key, only a key check speaks for it: a CLI login check (passed or not) says nothing about the key.
74
+ if (seen.state !== "ready" || (key && seen.probe !== "key")) {
75
+ return { ...base, state: "Not checked yet", ok: false, next: key ? "Its API key has not been checked without spending; the first task shows whether it works." : "A worker could not check it without spending; the first task shows whether it works.", link: "providers" };
76
+ }
77
+ return { ...base, state: key ? "API key set" : "Signed in", ok: true, next: null, link: null };
78
+ });
79
+ return { agents, agentsProblem: null };
80
+ }
81
+ function toolsOf(store, repo) {
82
+ return projectToolsOf(store, repo).slice(0, MAX_LISTED).map(tool => {
83
+ let set = [];
84
+ try {
85
+ set = secretsSetFor(repo, tool.spec);
86
+ }
87
+ catch {
88
+ set = [];
89
+ }
90
+ const standing = toolStanding(tool, set);
91
+ const state = standing.words;
92
+ const lets = `builds in this project can use ${tool.name}${tool.spec.about ? ` (${tool.spec.about})` : ""}`;
93
+ if (state.startsWith("Needs"))
94
+ return { name: tool.name, state, ok: false, lets, next: `Add ${state.slice("Needs ".length)} on the Tools page; never in chat.`, link: "tools" };
95
+ if (state === "Last test failed")
96
+ return { name: tool.name, state, ok: false, lets, next: `Fix it and test it again on the Tools page${tool.lastTest?.problem ? `: ${tool.lastTest.problem}` : ""}.`, link: "tools" };
97
+ if (state === "Not tested yet")
98
+ return { name: tool.name, state, ok: true, lets, next: "Test it on the Tools page to be sure it works.", link: "tools" };
99
+ return { name: tool.name, state: "Working", ok: true, lets, next: null, link: null };
100
+ });
101
+ }
102
+ /** The newest test of one skill version in a project: passed, failed or still running; null when never tested. */
103
+ function lastSkillTest(store, repo, sha) {
104
+ const row = store.handle.prepare("SELECT skill_test.task_ref AS ref FROM skill_test JOIN task_ref ON task_ref.id = skill_test.task_ref WHERE task_ref.repo = ? AND skill_test.package = ? ORDER BY skill_test.task_ref DESC LIMIT 1").get(repo, sha);
105
+ if (row === undefined)
106
+ return null;
107
+ const run = store.runsFor(Number(row["ref"]))[0];
108
+ if (run === undefined || run.outcome === null)
109
+ return "running";
110
+ return run.outcome === "built" || run.outcome === "no-change" ? "passed" : "failed";
111
+ }
112
+ function skillsOf(store, repo, actor) {
113
+ let skills;
114
+ try {
115
+ skills = conversationSkills(store, repo, actor).skills;
116
+ }
117
+ catch {
118
+ return [];
119
+ }
120
+ return skills.filter(one => one.enabled).slice(0, MAX_LISTED).map(skill => {
121
+ const lets = `builds in this project are given the ${skill.name} skill${skill.description ? ` (${skill.description.slice(0, 120)})` : ""}`;
122
+ const test = lastSkillTest(store, repo, skill.sha);
123
+ if (test === "failed")
124
+ return { name: skill.name, state: "Last test failed", ok: false, lets, next: "Read it with get_skills, then fix and test it again on the Skills page.", link: "skills" };
125
+ const needs = skill.requirements.trim();
126
+ if (needs !== "" && test !== "passed")
127
+ return { name: skill.name, state: `Needs ${needs.slice(0, 120)}`, ok: false, lets, next: "Make sure what it needs is set up, then test it on the Skills page.", link: "skills" };
128
+ return { name: skill.name, state: test === "running" ? "Working · test running" : "Working", ok: true, lets, next: null, link: null };
129
+ });
130
+ }
131
+ function checksOf(store, repo) {
132
+ const level = projectCheckLevel(store, repo).level;
133
+ const releaseCheck = store.liveVerifyCommand(repo) !== null, quickCheck = liveQuickCommand(store, repo) !== null;
134
+ const base = { name: "Checks", level: CHECK_LEVEL_WORDS[level], quickCheck, releaseCheck, lets: "say whether a result's checks passed before you call it done" };
135
+ if (level === "off")
136
+ return { ...base, state: "Off", ok: false, next: "Builds are not checked. Turn checks on for the project if results need them.", link: "projects" };
137
+ if (!releaseCheck)
138
+ return { ...base, state: `${CHECK_LEVEL_WORDS[level]} · no release check`, ok: false, next: "No release check is set up, so no result can show a passing check. Set one for the project.", link: "projects" };
139
+ return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set`, ok: true, next: null, link: null };
140
+ }
141
+ function workersOf(store, repos, now) {
142
+ const live = store.handle.prepare(`SELECT ref.external_id AS task, ref.repo AS repo FROM claim JOIN task_ref AS ref ON ref.id = claim.task_ref
143
+ WHERE claim.runner = ? AND claim.released_at IS NULL AND claim.expires_at > ?
144
+ AND claim.lease_generation = (SELECT MAX(newest.lease_generation) FROM claim AS newest WHERE newest.task_ref = claim.task_ref)`);
145
+ const workers = store.listRunners()
146
+ .filter(one => one.retiredAt === null && one.repos.some(repo => repos.includes(repo)))
147
+ .slice(0, MAX_LISTED)
148
+ .map(runner => {
149
+ const online = now.getTime() - Date.parse(runner.heartbeatAt) <= DEFAULT_LIVENESS_MS;
150
+ const held = live.all(runner.name, now.toISOString());
151
+ const running = held.filter(row => repos.includes(String(row["repo"]))).map(row => String(row["task"]));
152
+ const busy = store.liveClaimCount(runner.name, now);
153
+ const lets = `run tasks for ${runner.repos.filter(repo => repos.includes(repo)).length} of your projects, ${runner.capacity} at a time`;
154
+ if (!online)
155
+ return { name: runner.name, state: `Offline since ${runner.heartbeatAt}`, ok: false, online, capacity: runner.capacity, running, lets,
156
+ next: `Start that computer, or reinstall its background worker there: \`toolroll daemon install --runner ${runner.name}\`.`, link: "workers" };
157
+ const full = busy >= runner.capacity;
158
+ return { name: runner.name, state: full ? `Online · full (${busy} of ${runner.capacity})` : `Online · ${busy} of ${runner.capacity} busy`, ok: true, online, capacity: runner.capacity, running, lets,
159
+ next: full ? "New work waits for a free slot; raise its capacity on the Workers page if it should run more at once." : null, link: full ? "workers" : null };
160
+ });
161
+ if (workers.length === 0) {
162
+ return [{ name: "No worker", state: "None for your projects", ok: false, online: false, capacity: 0, running: [], lets: "run any task",
163
+ next: "Add a worker for the project: run `toolroll daemon install` on the computer that should do the work.", link: "workers" }];
164
+ }
165
+ return workers;
166
+ }
167
+ const INTEGRATION_LINK = { slack: "slack", discord: "discord" };
168
+ /** The integrations exactly as get_integrations reads them, with what each lets the lead do and the next step. */
169
+ export function integrationCapabilities(list) {
170
+ return list.map(one => {
171
+ const link = one.group === "agents" ? "providers" : one.group === "tools" ? "tools" : INTEGRATION_LINK[one.key.replace(/^.*:/, "").toLowerCase()] ?? INTEGRATION_LINK[one.name.toLowerCase()] ?? "integrations";
172
+ const lets = one.usedBy.length > 0 ? `use ${one.name} for ${one.usedBy.join(", ")}` : `use ${one.name}`;
173
+ const next = one.action.kind === "setup" ? `Not set up: set up ${one.name} in Settings` : one.action.kind === "fix" ? `Fix: ${one.action.words}` : null;
174
+ return { name: one.name, kind: one.group, state: STATE_WORDS[one.state], ok: one.state === "connected", account: one.account, usedBy: one.usedBy, lastError: one.lastError, lets, next, link: next === null ? null : link };
175
+ });
176
+ }
177
+ /** Every capability, for one project or every admitted one. Read-only and spends nothing. */
178
+ export function capabilitiesOf(store, input) {
179
+ const authMode = input.authMode ?? (provider => readAuthMode(provider));
180
+ return {
181
+ projects: input.repos.map(repo => ({ repo, ...agentsOf(store, repo, input.now, authMode), tools: toolsOf(store, repo), skills: skillsOf(store, repo, input.actor), checks: checksOf(store, repo) })),
182
+ workers: workersOf(store, input.admitted, input.now),
183
+ integrations: integrationCapabilities(input.integrations),
184
+ };
185
+ }
186
+ /** Whether a call depends on an agent, a worker or an integration, and so needs get_capabilities read first this turn:
187
+ * a task always does (a worker runs it with an agent); a flow change that sets up or starts work does; a promise to
188
+ * report on a task, attempt or check does (crew work must run), and a timed one does when it is reported on a chat app. */
189
+ const FLOW_WORK = new Set(["create", "edit", "kit", "starter", "add_trigger", "resume_trigger", "add_card"]);
190
+ export function needsCapabilities(name, args, channel) {
191
+ if (name === "propose_task")
192
+ return true;
193
+ if (name === "propose_flow")
194
+ return FLOW_WORK.has(String(args["operation"]));
195
+ if (name === "commit_to")
196
+ return args["when"] === "time" ? channel !== undefined && channel !== "chat" : true;
197
+ return false;
198
+ }
199
+ export const CAPABILITIES_UNREAD = "This depends on an agent, a worker or an integration. Read get_capabilities first, then propose or promise in a later step; if something it needs can't work, say so plainly with its next step and show_control link instead.";
@@ -87,9 +87,13 @@ export function getCommitment(store, id) {
87
87
  const row = store.handle.prepare('SELECT * FROM lead_commitment WHERE id = ?').get(id);
88
88
  return row === undefined ? null : readCommitment(row);
89
89
  }
90
- /** A promise only counts once the reply that made it was answered: one from a reply still being written, or one
91
- * that never finished, was never heard. */
92
- const HEARD = "(c.turn IS NULL OR EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = c.turn AND t.state = 'answered'))";
90
+ /** The reply that made a promise was shown: its turn is over, and it was answered or its text reached the conversation
91
+ * before it failed. A turn still running has shown nothing yet, whatever it has written so far. */
92
+ const SHOWN = (turn) => `EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = ${turn} AND (t.state = 'answered'
93
+ OR t.state = 'failed' AND EXISTS (SELECT 1 FROM mate_message m WHERE m.turn = t.id AND m.role = 'assistant')))`;
94
+ /** A promise only counts once the reply that made it was shown: one from a reply still being written, or one that
95
+ * failed without reaching the owner, was never heard. */
96
+ const HEARD = `(c.turn IS NULL OR ${SHOWN('c.turn')})`;
93
97
  /** The owner's open promises that they heard, oldest first. */
94
98
  export function openCommitments(store, owner, limit = 20) {
95
99
  return store.handle.prepare(`SELECT c.* FROM lead_commitment c WHERE c.owner = ? AND c.state = 'open' AND ${HEARD} ORDER BY c.id LIMIT ?`).all(owner, limit)
@@ -153,10 +157,12 @@ export function checkLeadCommitments(store, now, root) {
153
157
  const at = now.toISOString();
154
158
  let reported = 0;
155
159
  store.handle.prepare("UPDATE lead_commitment SET state = 'expired', closed_at = ?, outcome = 'Expired after 7 days without the condition being met.' WHERE state = 'open' AND expires_at <= ?").run(at, at);
156
- // A promise made in a reply that was not delivered (it failed, or its turn is gone) was never heard: drop it now,
157
- // whenever it was next due, so nothing waits on it.
160
+ // A promise made in a reply that was not delivered (it failed before any text was shown, or its turn is gone) was
161
+ // never heard: drop it now, whenever it was next due, so nothing waits on it. An interrupted reply whose text was
162
+ // shown keeps its promises.
158
163
  store.handle.prepare(`UPDATE lead_commitment SET state = 'cancelled', closed_at = ?, closed_by = 'lead', outcome = 'The reply that made this promise was not delivered.'
159
- WHERE state = 'open' AND turn IS NOT NULL AND NOT EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = lead_commitment.turn AND t.state IN ('queued', 'running', 'answered'))`).run(at);
164
+ WHERE state = 'open' AND turn IS NOT NULL AND NOT EXISTS (SELECT 1 FROM mate_turn t WHERE t.id = lead_commitment.turn AND t.state IN ('queued', 'running'))
165
+ AND NOT ${SHOWN('lead_commitment.turn')}`).run(at);
160
166
  const due = store.handle.prepare("SELECT * FROM lead_commitment WHERE state = 'open' AND check_at <= ? ORDER BY check_at, id LIMIT ?").all(at, PASS_LIMIT);
161
167
  for (const row of due) {
162
168
  const one = readCommitment(row);
@@ -168,7 +174,7 @@ export function checkLeadCommitments(store, now, root) {
168
174
  }
169
175
  // A reply still being written may yet be delivered; one that was not is dropped above.
170
176
  const turn = one.turn === null ? null : store.getMateTurn(one.turn);
171
- if (turn !== null && turn.state !== 'answered')
177
+ if (turn !== null && (turn.state === 'queued' || turn.state === 'running'))
172
178
  continue;
173
179
  const account = store.accountOf(one.owner);
174
180
  if (account === null || account.revokedAt !== null || one.repo !== null && !store.accountCanAccess(one.owner, one.repo)) {
@@ -2,9 +2,11 @@
2
2
  * repository scan, mutation, or hidden approval happens while catching up.
3
3
  *
4
4
  * The bundle is ordered by importance and built fresh each turn: who the lead
5
- * is (name, persona), who it is talking to (first name, time zone, today), the
6
- * channel, what needs them now, projects by name with their active decisions,
7
- * then the rest. Over 8 KB, the least important goes first. */
5
+ * is (name, persona), who it is talking to (first name, time zone, today), what
6
+ * it knows about them (their confirmed lines), the people, AI teammates and team
7
+ * chats it works with (one line each), the channel, what needs them now,
8
+ * projects by name with their active decisions, then the rest. Over 8 KB, the
9
+ * least important goes first: the rest, then people, then decisions. */
8
10
  import type { Store } from './store.js';
9
11
  import { type AssignmentCatchUp } from './assignment-brief.js';
10
12
  export declare const LEAD_CONTEXT_MAX_BYTES = 8000;
@@ -2,7 +2,9 @@ import { activeDecisionsOf, assignmentCatchUp } from './assignment-brief.js';
2
2
  import { publicChatText } from './chat-display.js';
3
3
  import { leadIdentityOf } from './lead-identity.js';
4
4
  import { conditionWords, openCommitments } from './lead-commitments.js';
5
- const REMEMBERED = new Set(['decision_record', 'knowledge_instructions']);
5
+ import { aboutYouOf } from './lead-about.js';
6
+ import { peopleIndexOf, peopleLines, teamRoomOf } from './lead-people.js';
7
+ const REMEMBERED = new Set(['decision_record', 'knowledge_instructions', 'lead_about_you']);
6
8
  /** The lead's own follow-through for one conversation: its open promises, and corrections the operator confirmed
7
9
  * since its last reply (with the cards still open in this conversation, which a correction may affect). */
8
10
  function followThrough(store, owner, thread) {
@@ -16,7 +18,9 @@ function followThrough(store, owner, thread) {
16
18
  // A changed instruction is added at the end, so that is the part to show.
17
19
  const instructions = payload?.request?.instructions;
18
20
  const change = payload?.operation === 'knowledge_instructions' && typeof instructions === 'string'
19
- ? `Project instructions now end: ${instructions.slice(-240)}` : Array.isArray(payload?.terms) ? payload.terms[0] : null;
21
+ ? `Project instructions now end: ${instructions.slice(-240)}`
22
+ : payload?.operation === 'lead_about_you' && typeof payload?.request?.line === 'string' ? `About you: ${payload.request.line}`
23
+ : Array.isArray(payload?.terms) ? payload.terms[0] : null;
20
24
  if (REMEMBERED.has(payload?.operation) && typeof change === 'string')
21
25
  corrections.push({ proposal: Number(row['id']), change: change.slice(0, 300) });
22
26
  }
@@ -94,26 +98,40 @@ export function leadContext(store, repos, now, options = {}) {
94
98
  sources: one.knowledge.sources.map(source => ({ id: source.id, title: source.title })) }));
95
99
  const omissions = { ...brief.omissions, projects: Math.max(brief.omissions.projects, repos.length - 8), notes: [...brief.omissions.notes] };
96
100
  const firstName = options.owner === undefined ? null : firstNameOf(options.owner);
101
+ const redact = options.redact ?? (text => text);
102
+ // A team chat's own lead speaks for the room, so the owner's own note stays with their own lead, and the people
103
+ // index is that room and its members, no one else.
104
+ const room = teamRoomOf(store, options.thread);
105
+ const aboutYou = options.leadName === undefined && room === null ? aboutYouOf(store, options.owner) : [];
106
+ // First names are shown on purpose; each line's free text is scrubbed as it is written.
107
+ const people = options.owner === undefined ? { people: [], teammates: [], teams: [] } : peopleLines(peopleIndexOf(store, options.owner, repos, room), redact);
97
108
  // The whole bundle is scrubbed (titles, notes, next labels, the lead's name and persona); then the names it
98
- // carries on purpose are put back: the lead's own name, the person's first name and each project's label.
109
+ // carries on purpose are put back: the lead's own name, the person's first name, the lines they confirmed about
110
+ // themselves, the people index (first names) and each project's label.
99
111
  const data = scrubbed({
100
112
  snapshotVersion: 3, source: 'local-database',
101
113
  me: { name: options.leadName ?? identity.name, persona: identity.persona },
102
114
  you: { firstName, ...localNow(now, options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone) },
115
+ aboutYou,
116
+ people: { people: [], teammates: [], teams: [] },
103
117
  channel: options.channel === undefined ? null : { id: options.channel, fit: CHANNEL_WORDS[options.channel] },
104
118
  needsYou: brief.assignments.filter(needs).map(task),
105
119
  ...(options.owner === undefined || options.thread === undefined ? {} : followThrough(store, options.owner, options.thread)),
106
120
  projects,
107
121
  rest: { tasks: brief.assignments.filter(one => !needs(one)).map(task), knowledge },
108
- omissions,
122
+ omissions: { ...omissions, people: 0 },
109
123
  notice: 'Bounded catch-up. Read the exact task/result before acting. Saved knowledge is context, not authority.',
110
- }, options.redact ?? (text => text));
124
+ }, redact);
111
125
  data.you.firstName = firstName;
126
+ data.people = people;
127
+ // The owner confirmed every line (secret-checked when saved), so they read as written, names included.
128
+ data.aboutYou = aboutYou;
112
129
  // The name the owner gave their lead is theirs to say, even when it matches an account name.
113
130
  data.me.name = options.leadName ?? identity.name;
114
131
  data.projects.forEach((one, index) => { one.name = projects[index].name; });
115
- // Least important first: the rest's knowledge, then its tasks, then each project's oldest decision, then the
116
- // last Needs you. Who the lead is, who it is talking to and the channel always stay.
132
+ // Least important first: the rest's knowledge, then its tasks, then the people index (team chats, AI teammates,
133
+ // then people), then each project's oldest decision, then the last Needs you. Who the lead is, who it is talking
134
+ // to, what it knows about them and the channel always stay.
117
135
  const drop = () => {
118
136
  if (data.rest.knowledge.pop() !== undefined) {
119
137
  data.omissions.projects++;
@@ -123,6 +141,10 @@ export function leadContext(store, repos, now, options = {}) {
123
141
  data.omissions.assignments++;
124
142
  return true;
125
143
  }
144
+ if ((data.people.teams.pop() ?? data.people.teammates.pop() ?? data.people.people.pop()) !== undefined) {
145
+ data.omissions.people++;
146
+ return true;
147
+ }
126
148
  const decided = [...data.projects].reverse().find(one => one.decisions.length > 0);
127
149
  if (decided !== undefined) {
128
150
  decided.decisions.pop();
@@ -1,4 +1,4 @@
1
- import type { Store, MateSession, MateThread, ChatConfig } from './store.js';
1
+ import { type Store, type MateSession, type MateThread, type ChatConfig } from './store.js';
2
2
  import { type VerifiedApprover } from './principal.js';
3
3
  import { type MateTurnInput, type MateTurnOutcome } from './mate.js';
4
4
  export declare function configureLeadFollow(store: Store, who: VerifiedApprover, session: MateSession, thread: MateThread, enabled: boolean, now: Date): boolean;
@@ -1,6 +1,7 @@
1
1
  /** Durable crew updates for the built-in lead. Idle scans use no model.
2
2
  * Delivery replays reuse the saved mate request; they never dispatch a task. */
3
3
  import { createHash } from 'node:crypto';
4
+ import { LEAD_FOLLOW_MESSAGE } from './store.js';
4
5
  import { isVerifiedApprover, reproveApprover, verifyApproverStanding } from './principal.js';
5
6
  import { assignmentOf } from './assignment.js';
6
7
  import { runMateTurn } from './mate.js';
@@ -141,8 +142,8 @@ export async function runLeadFollowPass(input) {
141
142
  if (!provider)
142
143
  continue;
143
144
  const result = await (input.runTurn ?? runMateTurn)({ store, ...access, ...provider, evidenceRoot: input.evidenceRoot, clock,
144
- requestId: batch.request, message: 'Automatic crew update: inspect the saved results or decisions and tell me what needs attention. Do not rerun work.',
145
- context: batch.context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'Lead authorization ended.' },
145
+ requestId: batch.request, message: LEAD_FOLLOW_MESSAGE,
146
+ context: batch.context, revalidate: async () => !updateAdmissionPaused(store.raw()) && authorized(store, grant, input.repos()) !== null ? { ok: true } : { ok: false, reason: 'access-changed' },
146
147
  ...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.subscriptionRunner ? { subscriptionRunner: input.subscriptionRunner } : {}) });
147
148
  if (!result.ok && 'refused' in result) {
148
149
  const status = `Crew updates are waiting: ${result.message}`;
@@ -9,7 +9,7 @@ export function leadIdentityOf(store, account) {
9
9
  try {
10
10
  const saved = store.leadConfig(account);
11
11
  if (saved !== null)
12
- return { name: saved.name, persona: saved.persona };
12
+ return { name: saved.name || DEFAULT_LEAD_NAME, persona: saved.persona || DEFAULT_LEAD_PERSONA };
13
13
  }
14
14
  catch { /* no lead_config yet: the defaults */ }
15
15
  return { name: DEFAULT_LEAD_NAME, persona: DEFAULT_LEAD_PERSONA };
@@ -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
+ }
@@ -54,8 +54,9 @@ export declare function leadSay(store: Store, actor: Actor, text: string, taskId
54
54
  * earlier cards too; any other row's own subject. `reader`: who the chat belongs to, for a row with no recipient
55
55
  * (only its owner ever receives a lead row). */
56
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. */
58
- export declare function leadSayText(store: Store, row: Pick<Notification, "kind" | "subject" | "body" | "recipient">): 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;
59
60
  /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
60
61
  export declare function leadSayEarlier(store: Store, row: Pick<Notification, "id" | "kind" | "dedupeKey" | "recipient">): number[];
61
62
  /** `assignment claim` under the lead token: the task is the lead's until it completes it, hands it on, or goes
@@ -112,9 +112,10 @@ export function leadSubjectOf(store, row, reader) {
112
112
  const name = leadNameOf(store, owner);
113
113
  return row.kind === LEAD_SAY_KIND ? name : row.kind === LEAD_ON_IT_KIND ? `${name} is on it` : leadLapsed(name);
114
114
  }
115
- /** The words a chat shows for a lead-say row: the lead's name, then each line, plain, with no task id. */
116
- export function leadSayText(store, row) {
117
- return chatText([plain(leadSubjectOf(store, row), 60), "", ...row.body.split("\n").map(line => plain(line, LEAD_SAY_MAX)).filter(line => line !== "")].join("\n"));
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"));
118
119
  }
119
120
  /** The earlier rows of a lead-say message (newest first): a chat that showed one edits it rather than sending again. */
120
121
  export function leadSayEarlier(store, row) {
@@ -1,4 +1,5 @@
1
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;