toolroll 0.9.54 → 0.9.55

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 (199) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-polish.d.ts +5 -4
  19. package/dist/chat-polish.js +9 -10
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +26 -53
  22. package/dist/cli.d.ts +3 -1
  23. package/dist/cli.js +9 -3
  24. package/dist/coding-shipping-ui.d.ts +2 -1
  25. package/dist/coding-shipping-ui.js +6 -6
  26. package/dist/coding-ui.d.ts +3 -1
  27. package/dist/coding-ui.js +30 -29
  28. package/dist/contracts/cli.d.ts +1 -3
  29. package/dist/contracts/console-api.d.ts +1 -1
  30. package/dist/contracts/console-api.js +6 -5
  31. package/dist/contracts/health.d.ts +1 -3
  32. package/dist/control-ui.d.ts +5 -4
  33. package/dist/control-ui.js +9 -34
  34. package/dist/credentials-ui.d.ts +5 -4
  35. package/dist/credentials-ui.js +23 -24
  36. package/dist/demo-chat.d.ts +3 -3
  37. package/dist/demo-chat.js +29 -76
  38. package/dist/discord-settings.d.ts +3 -2
  39. package/dist/discord-settings.js +17 -42
  40. package/dist/envelope.js +10 -0
  41. package/dist/evidence-pack.d.ts +2 -1
  42. package/dist/evidence-pack.js +45 -49
  43. package/dist/export-ui.d.ts +7 -2
  44. package/dist/export-ui.js +4 -10
  45. package/dist/flow-gallery-ui.d.ts +4 -3
  46. package/dist/flow-gallery-ui.js +28 -43
  47. package/dist/flow-starters-ui.d.ts +2 -1
  48. package/dist/flow-starters-ui.js +12 -15
  49. package/dist/flow-triggers.d.ts +1 -0
  50. package/dist/flow-triggers.js +16 -14
  51. package/dist/flows-ui.d.ts +4 -3
  52. package/dist/flows-ui.js +19 -30
  53. package/dist/flows.js +2 -1
  54. package/dist/guides.js +108 -3
  55. package/dist/html.d.ts +76 -0
  56. package/dist/html.js +173 -0
  57. package/dist/integrations-ui.d.ts +2 -1
  58. package/dist/integrations-ui.js +19 -23
  59. package/dist/kits-ui.d.ts +5 -4
  60. package/dist/kits-ui.js +18 -20
  61. package/dist/knowledge-ui.d.ts +6 -5
  62. package/dist/knowledge-ui.js +26 -25
  63. package/dist/lead-context.d.ts +11 -1
  64. package/dist/lead-context.js +6 -8
  65. package/dist/ledger-view.d.ts +2 -1
  66. package/dist/ledger-view.js +27 -29
  67. package/dist/limits-ui.d.ts +2 -1
  68. package/dist/limits-ui.js +7 -7
  69. package/dist/live-bus.d.ts +50 -8
  70. package/dist/live-bus.js +133 -50
  71. package/dist/mcp-oauth.js +35 -36
  72. package/dist/models-ui.d.ts +2 -1
  73. package/dist/models-ui.js +25 -35
  74. package/dist/monitoring-ui.d.ts +4 -3
  75. package/dist/monitoring-ui.js +13 -26
  76. package/dist/openrouter-models.d.ts +2 -1
  77. package/dist/openrouter-models.js +5 -9
  78. package/dist/operate.js +11 -7
  79. package/dist/people-audit-ui.d.ts +2 -1
  80. package/dist/people-audit-ui.js +13 -13
  81. package/dist/policy-ui.d.ts +3 -2
  82. package/dist/policy-ui.js +17 -23
  83. package/dist/project-delete-ui.d.ts +4 -3
  84. package/dist/project-delete-ui.js +15 -27
  85. package/dist/pull-request-ui.d.ts +2 -1
  86. package/dist/pull-request-ui.js +17 -32
  87. package/dist/recipe-ui.d.ts +8 -7
  88. package/dist/recipe-ui.js +39 -32
  89. package/dist/release-adapters.d.ts +60 -0
  90. package/dist/release-adapters.js +435 -0
  91. package/dist/release-cli.d.ts +24 -0
  92. package/dist/release-cli.js +181 -0
  93. package/dist/release-gate.d.ts +80 -0
  94. package/dist/release-gate.js +150 -0
  95. package/dist/release-links.d.ts +29 -0
  96. package/dist/release-links.js +102 -0
  97. package/dist/release.d.ts +331 -0
  98. package/dist/release.js +550 -0
  99. package/dist/reply-shape.d.ts +2 -1
  100. package/dist/reply-shape.js +5 -5
  101. package/dist/repository-context-ui.d.ts +2 -1
  102. package/dist/repository-context-ui.js +2 -7
  103. package/dist/request-budget-ui.d.ts +2 -1
  104. package/dist/request-budget-ui.js +16 -16
  105. package/dist/result-review.d.ts +2 -3
  106. package/dist/result-review.js +3 -9
  107. package/dist/retention-ui.d.ts +7 -1
  108. package/dist/retention-ui.js +7 -11
  109. package/dist/serve.d.ts +11 -2
  110. package/dist/serve.js +69 -58
  111. package/dist/server/chat.d.ts +3 -6
  112. package/dist/server/chat.js +969 -1006
  113. package/dist/server/chrome.d.ts +291 -0
  114. package/dist/server/chrome.js +747 -0
  115. package/dist/server/console.css +2819 -0
  116. package/dist/server/flows.d.ts +2 -6
  117. package/dist/server/flows.js +840 -766
  118. package/dist/server/guards.d.ts +1 -1
  119. package/dist/server/guards.js +5 -2
  120. package/dist/server/handler-context.d.ts +1 -1
  121. package/dist/server/handler-context.js +1 -1
  122. package/dist/server/handler-registry.d.ts +8 -6
  123. package/dist/server/handler-registry.js +14 -2
  124. package/dist/server/http.d.ts +274 -0
  125. package/dist/server/http.js +239 -0
  126. package/dist/server/live.d.ts +34 -0
  127. package/dist/server/live.js +309 -0
  128. package/dist/server/pages.d.ts +2 -6
  129. package/dist/server/pages.js +808 -789
  130. package/dist/server/people-tokens.d.ts +1 -6
  131. package/dist/server/people-tokens.js +580 -572
  132. package/dist/server/remote.d.ts +4 -7
  133. package/dist/server/remote.js +37 -70
  134. package/dist/server/render-chat.d.ts +402 -0
  135. package/dist/server/render-chat.js +927 -0
  136. package/dist/server/render-pages.d.ts +535 -0
  137. package/dist/server/render-pages.js +1277 -0
  138. package/dist/server/render-people.d.ts +32 -0
  139. package/dist/server/render-people.js +121 -0
  140. package/dist/server/render-results.d.ts +744 -0
  141. package/dist/server/render-results.js +2237 -0
  142. package/dist/server/render-settings.d.ts +147 -0
  143. package/dist/server/render-settings.js +577 -0
  144. package/dist/server/render-tasks.d.ts +632 -0
  145. package/dist/server/render-tasks.js +2345 -0
  146. package/dist/server/request-context.d.ts +46 -0
  147. package/dist/server/request-context.js +17 -0
  148. package/dist/server/route-table.d.ts +25 -3
  149. package/dist/server/route-table.js +163 -75
  150. package/dist/server/runtime.d.ts +15 -7
  151. package/dist/server/runtime.js +7 -1
  152. package/dist/server/session.d.ts +138 -0
  153. package/dist/server/session.js +94 -0
  154. package/dist/server/settings.d.ts +1 -5
  155. package/dist/server/settings.js +2181 -2117
  156. package/dist/server/task-views.d.ts +11 -4
  157. package/dist/server/task-views.js +26 -22
  158. package/dist/server/tasks.d.ts +9 -9
  159. package/dist/server/tasks.js +1486 -1418
  160. package/dist/server-telemetry.d.ts +4 -3
  161. package/dist/server-telemetry.js +4 -8
  162. package/dist/skills-ui.d.ts +4 -3
  163. package/dist/skills-ui.js +14 -17
  164. package/dist/skills.d.ts +1 -1
  165. package/dist/skills.js +2 -1
  166. package/dist/slack-settings.d.ts +3 -2
  167. package/dist/slack-settings.js +17 -42
  168. package/dist/spend-ui.d.ts +2 -1
  169. package/dist/spend-ui.js +9 -32
  170. package/dist/sso-ui.d.ts +3 -2
  171. package/dist/sso-ui.js +26 -27
  172. package/dist/storage-ui.d.ts +2 -1
  173. package/dist/storage-ui.js +14 -33
  174. package/dist/surface.js +14 -3
  175. package/dist/task-composer.d.ts +2 -1
  176. package/dist/task-composer.js +7 -7
  177. package/dist/task-status.d.ts +4 -3
  178. package/dist/task-status.js +15 -16
  179. package/dist/team-http.d.ts +5 -9
  180. package/dist/team-http.js +5 -63
  181. package/dist/team-ui.d.ts +2 -1
  182. package/dist/team-ui.js +2 -5
  183. package/dist/teammates-ui.d.ts +5 -4
  184. package/dist/teammates-ui.js +61 -90
  185. package/dist/teams-settings.d.ts +3 -2
  186. package/dist/teams-settings.js +20 -31
  187. package/dist/telegram-settings.d.ts +3 -2
  188. package/dist/telegram-settings.js +14 -25
  189. package/dist/toolroll-update-ui.d.ts +3 -2
  190. package/dist/toolroll-update-ui.js +35 -39
  191. package/dist/tools-ui.d.ts +5 -2
  192. package/dist/tools-ui.js +31 -31
  193. package/dist/when-html.d.ts +11 -3
  194. package/dist/when-html.js +5 -6
  195. package/dist/workspace-ui.d.ts +4 -2
  196. package/dist/workspace-ui.js +10 -10
  197. package/package.json +1 -1
  198. package/dist/server/shared.d.ts +0 -3151
  199. package/dist/server/shared.js +0 -12001
@@ -6,11 +6,12 @@ import { MEMORY_CHARS, searchMemories } from "./teammate-memory.js";
6
6
  import { deskOf, localZone, routinesOf } from "./teammate-desk.js";
7
7
  import { weekOf, weekWords } from "./teammate-week.js";
8
8
  import { describeSchedule, parseSchedule } from "./flow-schedule.js";
9
+ import { html, joinHtml, postForm } from "./html.js";
9
10
  /** v94: the Tools section's rule rows; a limit's fields show only while "Up to a limit" is chosen. */
10
11
  export const TEAMMATE_CSS = `.teammate-tools .tool-grant{border-top:1px solid var(--so-line);padding-top:12px;margin-top:12px}.teammate-tools .tool-grant:first-of-type{border-top:0;margin-top:0;padding-top:0}.teammate-tools h3{font-size:1rem;margin:0 0 4px}.teammate-tools .tool-rule{display:grid;gap:6px;padding:10px 0;border-bottom:1px solid var(--so-line)}.teammate-tools .tool-rule:last-of-type{border-bottom:0}.teammate-tools .tool-rule .meta{display:block}.teammate-tools select,.teammate-tools input{max-width:100%;box-sizing:border-box}.teammate-tools .tool-limit{display:none;flex-wrap:wrap;align-items:center;gap:6px}.teammate-tools .tool-rule:has(option[value="limit"]:checked) .tool-limit{display:flex}.teammate-tools .tool-rule>select{width:auto;min-width:16em}.teammate-tools .tool-limit select{width:auto}.teammate-tools .tool-limit input{width:7em}.teammate-tools .tool-buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}.teammate-tools button{min-height:44px}.teammate-activity li{overflow-wrap:anywhere}.teammate-memory .memory-tell,.teammate-memory .memory-search{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin:0 0 12px}.teammate-memory .memory-tell textarea{flex:1 1 280px}.teammate-memory .memory-search input{flex:1 1 220px}.teammate-memory button{min-height:44px}.teammate-memories{list-style:none;padding:0;margin:0}.teammate-memories li{padding:10px 0;border-top:1px solid var(--so-line);overflow-wrap:anywhere}.teammate-memories li:first-child{border-top:0}.teammate-memories .meta{display:block;font-size:.85rem}.teammate-memories details.memory-edit{border:0;padding:0;margin:2px 0 0;background:transparent;box-shadow:none}.memory-edit summary{cursor:pointer;min-height:32px;display:inline-flex;align-items:center;font-size:.85rem;font-weight:400;color:var(--so-muted)}.tool-undo{border:0;padding:0;margin:8px 0;background:transparent;box-shadow:none}.tool-undo summary{cursor:pointer;min-height:36px;display:inline-flex;align-items:center;font-size:.9rem}.tool-undo-row{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:6px 0}.tool-undo-row select{width:auto}.teammate-overrides,.teammate-undo{padding-left:18px}.teammate-undo li{margin:6px 0}.teammate-undo form{display:inline}.teammate-week h3{font-size:1rem;margin:14px 0 4px}.teammate-week button{min-height:44px}.teammate-routines{list-style:none;padding:0;margin:8px 0}.teammate-routines li{display:grid;gap:4px;padding:10px 0;border-top:1px solid var(--so-line)}.teammate-routines li:first-child{border-top:0}.routine-buttons{display:flex;flex-wrap:wrap;gap:8px}.routine-buttons button,.routine-add button{min-height:44px}.routine-add{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;margin:8px 0}.routine-add label{display:grid;gap:4px;flex:1 1 200px}.routine-add label:first-of-type{flex:0 1 180px}.teammate-actions,.question-options{display:flex;flex-wrap:wrap;gap:8px;margin:8px 0}.teammate-actions form,.question-options form{margin:0}.memory-edit form{display:grid;gap:8px;margin-top:6px}.memory-buttons{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:600px){.teammate-tools select,.teammate-tools input{font-size:16px}}`;
11
12
  const USES = [["free", "Do it"], ["limit", "Do it, up to a limit"], ["ask", "Ask first"], ["never", "Never"]];
12
13
  /** v94: which project tools it may use, and its rule for each action. */
13
- function toolsSection(store, mate, csrf, canManage) {
14
+ function toolsSection(store, mate, canManage) {
14
15
  const name = nameOf(mate);
15
16
  const tools = projectToolsOf(store, mate.repo);
16
17
  const grants = store.teammateGrants(mate.id);
@@ -22,145 +23,115 @@ function toolsSection(store, mate, csrf, canManage) {
22
23
  const use = rule.use === "free" && rule.limit !== undefined ? "limit" : rule.use;
23
24
  const numbers = numberFields(action);
24
25
  const words = rule.use === "never" ? "Never" : rule.use === "ask" ? "Ask first" : rule.limit === undefined ? "Do it" : `Do it up to ${rule.limit.field} ${rule.limit.over}, ask first above`;
26
+ const about = action.about !== "" && html `<span class="meta">${action.about}</span>`;
25
27
  if (!canManage)
26
- return `<li class="tool-rule"><span><strong>${e(action.name)}</strong> · ${e(words)}</span>${action.about === "" ? "" : `<span class="meta">${e(action.about)}</span>`}</li>`;
28
+ return html `<li class="tool-rule"><span><strong>${action.name}</strong> · ${words}</span>${about}</li>`;
27
29
  const field = `${grant.tool}-${action.name}`.replace(/[^A-Za-z0-9_-]/g, "-");
28
- return `<div class="tool-rule" data-tool-action="${e(action.name)}"><label for="use-${e(field)}"><strong>${e(action.name)}</strong>${action.about === "" ? "" : `<span class="meta">${e(action.about)}</span>`}</label>` +
29
- `<select id="use-${e(field)}" name="use.${e(action.name)}">${USES.filter(([value]) => value !== "limit" || numbers.length > 0).map(([value, label]) => `<option value="${value}"${value === use ? " selected" : ""}>${label}</option>`).join("")}</select>` +
30
- (numbers.length === 0 ? "" : `<span class="tool-limit">Ask first when <select name="field.${e(action.name)}" aria-label="The number the limit is on">${numbers.map(one => `<option value="${e(one)}"${rule.limit?.field === one ? " selected" : ""}>${e(one)}</option>`).join("")}</select> is over <input type="number" name="over.${e(action.name)}" min="0" step="any" value="${rule.limit === undefined ? "" : rule.limit.over}" aria-label="The limit"></span>`) +
31
- `</div>`;
32
- }).join("");
33
- const head = `<h3>${e(grant.tool)}</h3>${gone ? `<p class="problem">This project doesn't have ${e(grant.tool)} any more, so ${e(name)} can't use it.</p>` : ""}`;
30
+ const limit = numbers.length > 0 && html `<span class="tool-limit">Ask first when <select name="field.${action.name}" aria-label="The number the limit is on">${numbers.map(one => html `<option value="${one}"${rule.limit?.field === one && html ` selected`}>${one}</option>`)}</select> is over <input type="number" name="over.${action.name}" min="0" step="any" value="${rule.limit === undefined ? "" : rule.limit.over}" aria-label="The limit"></span>`;
31
+ return html `<div class="tool-rule" data-tool-action="${action.name}"><label for="use-${field}"><strong>${action.name}</strong>${about}</label><select id="use-${field}" name="use.${action.name}">${USES.filter(([value]) => value !== "limit" || numbers.length > 0).map(([value, label]) => html `<option value="${value}"${value === use && html ` selected`}>${label}</option>`)}</select>${limit}</div>`;
32
+ });
33
+ const head = html `<h3>${grant.tool}</h3>${gone && html `<p class="problem">This project doesn't have ${grant.tool} any more, so ${name} can't use it.</p>`}`;
34
34
  // v97: which action undoes which, for the Undo button on its receipts. Optional, so it's folded away.
35
35
  const undoing = grant.actions.filter(one => !one.readOnly).map(action => {
36
36
  const chosen = grant.rules[action.name]?.undo ?? "";
37
- return `<label class="tool-undo-row">${e(action.name)} is undone by<select name="undo.${e(action.name)}"><option value="">nothing</option>${grant.actions.filter(other => other.name !== action.name).map(other => `<option value="${e(other.name)}"${other.name === chosen ? " selected" : ""}>${e(other.name)}</option>`).join("")}</select></label>`;
38
- }).join("");
39
- const undoBox = undoing === "" ? "" : `<details class="tool-undo"${grant.actions.some(one => grant.rules[one.name]?.undo !== undefined) ? " open" : ""}><summary>Undo</summary><p class="meta">When an action has an opposite, a person can press Undo on its receipts: the opposite is called with the same input.</p>${undoing}</details>`;
37
+ return html `<label class="tool-undo-row">${action.name} is undone by<select name="undo.${action.name}"><option value="">nothing</option>${grant.actions.filter(other => other.name !== action.name).map(other => html `<option value="${other.name}"${other.name === chosen && html ` selected`}>${other.name}</option>`)}</select></label>`;
38
+ });
39
+ const undoBox = undoing.length > 0 && html `<details class="tool-undo"${grant.actions.some(one => grant.rules[one.name]?.undo !== undefined) && html ` open`}><summary>Undo</summary><p class="meta">When an action has an opposite, a person can press Undo on its receipts: the opposite is called with the same input.</p>${undoing}</details>`;
40
40
  return canManage
41
- ? `<div class="tool-grant" data-tool-grant="${e(grant.tool)}">${head}<form method="post" action="/teammates/${mate.id}/tools">${hidden(csrf)}<input type="hidden" name="tool" value="${e(grant.tool)}">${rows}` +
42
- undoBox + `<div class="tool-buttons"><button name="op" value="rules">Save rules</button><button name="op" value="revoke" class="secondary">Stop using ${e(grant.tool)}</button></div></form></div>`
43
- : `<div class="tool-grant" data-tool-grant="${e(grant.tool)}">${head}<ul>${rows}</ul></div>`;
44
- }).join("");
41
+ ? html `<div class="tool-grant" data-tool-grant="${grant.tool}">${head}${postForm(`/teammates/${mate.id}/tools`, html `${rows}${undoBox}<div class="tool-buttons"><button name="op" value="rules">Save rules</button><button name="op" value="revoke" class="secondary">Stop using ${grant.tool}</button></div>`, { hidden: { tool: grant.tool } })}</div>`
42
+ : html `<div class="tool-grant" data-tool-grant="${grant.tool}">${head}<ul>${rows}</ul></div>`;
43
+ });
45
44
  const open = tools.filter(one => !grants.some(grant => grant.tool === one.name));
46
- const add = !canManage ? "" : tools.length === 0 ? `<p class="meta">This project has no tools yet. <a href="${e(toolsPage)}">Add one</a>, then let ${e(name)} use it here.</p>`
47
- : open.length === 0 ? "" : `<form method="post" action="/teammates/${mate.id}/tools" class="tool-add">${hidden(csrf)}<input type="hidden" name="op" value="grant"><label>Let ${e(name)} use<select name="tool">${open.map(one => `<option value="${e(one.name)}">${e(one.name)}${one.spec.about === "" ? "" : ` — ${e(one.spec.about.slice(0, 80))}`}</option>`).join("")}</select></label><button>Add tool</button></form>`;
48
- if (grants.length === 0 && add === "")
45
+ const add = !canManage ? null : tools.length === 0 ? html `<p class="meta">This project has no tools yet. <a href="${toolsPage}">Add one</a>, then let ${name} use it here.</p>`
46
+ : open.length === 0 ? null : postForm(`/teammates/${mate.id}/tools`, html `<label>Let ${name} use<select name="tool">${open.map(one => html `<option value="${one.name}">${one.name}${one.spec.about === "" ? "" : ` — ${one.spec.about.slice(0, 80)}`}</option>`)}</select></label><button>Add tool</button>`, { attrs: { class: "tool-add" }, hidden: { op: "grant" } });
47
+ if (grants.length === 0 && add === null)
49
48
  return "";
50
- return `<section class="card teammate-tools" id="tools"><h2>Tools</h2>${grants.length === 0 ? `<p class="meta">${e(name)} doesn't use any tools yet. Reading starts as “Do it”; everything else asks you first.</p>` : ""}${granted}${add}</section>`;
49
+ return html `<section class="card teammate-tools" id="tools"><h2>Tools</h2>${grants.length === 0 && html `<p class="meta">${name} doesn't use any tools yet. Reading starts as “Do it”; everything else asks you first.</p>`}${granted}${add}</section>`;
51
50
  }
52
- const e = (text) => text.replace(/[&<>"']/g, one => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]);
53
51
  const when = (at) => new Date(at).toLocaleString("en", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
54
- const hidden = (csrf) => `<input type="hidden" name="csrf" value="${e(csrf)}">`;
55
52
  const startOfDay = () => { const day = new Date(); day.setHours(0, 0, 0, 0); return day.toISOString(); };
53
+ /** Text whose line breaks show as breaks. */
54
+ const lines = (text) => joinHtml(text.split("\n"), html `<br>`);
56
55
  /** A blank soul file: the sections a teammate reads, to fill in. */
57
56
  export const BLANK_SOUL = `---\nname: \nrole: \n---\n\n## Who you are\n\n\n## How you write\n\n\n## What you know\n\n\n## Decide on your own\n- \n\n## Ask first\n- \n\n## Never\n- \n`;
58
- export function teammatesListHtml(store, mates, projects, projectName, csrf, canCreate, notice) {
57
+ export function teammatesListHtml(store, mates, projects, projectName, canCreate, notice) {
59
58
  const rows = mates.map(mate => {
60
59
  const today = store.teammateTurnsSince(mate.id, startOfDay());
61
60
  const open = store.openTeammateQuestions([mate.id]).length;
62
- return `<article class="card" data-teammate="${e(mate.handle)}"><div class="flow-row"><h2><a href="/teammates/${mate.id}">${e(labelOf(mate))}</a></h2><span class="flow-counts">${e(projectName(mate.repo))} · ${mate.state === "active" ? "Working" : "Paused"} · ${today} today${open > 0 ? ` · ${open} question${open === 1 ? "" : "s"} for you` : ""}</span></div></article>`;
63
- }).join("");
61
+ return html `<article class="card" data-teammate="${mate.handle}"><div class="flow-row"><h2><a href="/teammates/${mate.id}">${labelOf(mate)}</a></h2><span class="flow-counts">${projectName(mate.repo)} · ${mate.state === "active" ? "Working" : "Paused"} · ${today} today${open > 0 ? ` · ${open} question${open === 1 ? "" : "s"} for you` : ""}</span></div></article>`;
62
+ });
64
63
  const create = canCreate && projects.length > 0
65
- ? `<details class="card"${mates.length === 0 ? " open" : ""}><summary>New teammate</summary><form method="post" action="/teammates/new" data-new-teammate>${hidden(csrf)}` +
66
- `<label>Start from<select name="template">${TEAMMATE_TEMPLATES.map(one => `<option value="${e(one.id)}">${e(one.label)}: ${e(one.about)}</option>`).join("")}<option value="blank">Blank: write its soul file yourself</option></select></label>` +
67
- `<label>Name<input name="name" maxlength="40" placeholder="keep the template's name"></label>` +
68
- (projects.length === 1 ? `<input type="hidden" name="repo" value="${e(projects[0])}">` : `<label>Project<select name="repo">${projects.map(repo => `<option value="${e(repo)}">${e(projectName(repo))}</option>`).join("")}</select></label>`) +
69
- `<button>Add teammate</button></form></details>`
64
+ ? html `<details class="card"${mates.length === 0 && html ` open`}><summary>New teammate</summary>${postForm("/teammates/new", html `<label>Start from<select name="template">${TEAMMATE_TEMPLATES.map(one => html `<option value="${one.id}">${one.label}: ${one.about}</option>`)}<option value="blank">Blank: write its soul file yourself</option></select></label><label>Name<input name="name" maxlength="40" placeholder="keep the template's name"></label>${projects.length === 1 ? html `<input type="hidden" name="repo" value="${projects[0]}">` : html `<label>Project<select name="repo">${projects.map(repo => html `<option value="${repo}">${projectName(repo)}</option>`)}</select></label>`}<button>Add teammate</button>`, { attrs: { "data-new-teammate": true } })}</details>`
70
65
  : "";
71
- const intro = `<p class="meta">AI teammates work your flows' cards within rules you write: they decide, reply and move cards on, and ask you when their rules say to. The quickest start is a <a href="/kits">starter kit</a>: a teammate and its flow, set up in one click.</p>`;
72
- return `<section class="teammates">${notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : ""}${notice.said ? `<p class="said" role="status">${e(notice.said)}</p>` : ""}${intro}${rows || '<p class="meta">No teammates yet.</p>'}${create}</section>`;
66
+ const intro = html `<p class="meta">AI teammates work your flows' cards within rules you write: they decide, reply and move cards on, and ask you when their rules say to. The quickest start is a <a href="/kits">starter kit</a>: a teammate and its flow, set up in one click.</p>`;
67
+ return html `<section class="teammates">${notice.problem && html `<p class="problem" role="alert">${notice.problem}</p>`}${notice.said && html `<p class="said" role="status">${notice.said}</p>`}${intro}${rows.length > 0 ? rows : html `<p class="meta">No teammates yet.</p>`}${create}</section>`;
73
68
  }
74
69
  /** v97: its week: what it did and cost, what people overrode, and calls that can be undone. */
75
- function weekSection(store, mate, csrf, canManage) {
70
+ function weekSection(store, mate, canManage) {
76
71
  const week = weekOf(store, mate, new Date());
77
- const overrides = week.overrides.length === 0 ? "" : `<h3>What you overrode</h3><ul class="teammate-overrides">${week.overrides.map(one => `<li>${e(one.said)}${one.href === null ? "" : ` · <a href="${e(one.href)}">open</a>`} <span class="meta">${when(one.at)}</span></li>`).join("")}</ul>`;
78
- const undo = week.undoable.length === 0 ? "" : `<h3>Undo</h3><ul class="teammate-undo">${week.undoable.map(one => `<li data-undo="${one.call}"><span>${e(one.words)}</span> <span class="meta">${when(one.at)}${one.href === null ? "" : ` · <a href="${e(one.href)}">open</a>`}</span>` +
79
- (canManage ? `<form method="post" action="/teammates/${mate.id}/week">${hidden(csrf)}<input type="hidden" name="id" value="${one.call}"><button name="op" value="undo" class="secondary">Undo with ${e(one.undo)}</button></form>` : "") + `</li>`).join("")}</ul>`;
80
- const send = canManage ? `<form method="post" action="/teammates/${mate.id}/week">${hidden(csrf)}<button name="op" value="send" class="secondary">Send the week's report</button></form>` : "";
81
- return `<details class="card teammate-week" id="week" open><summary>This week</summary><p class="teammate-week-said">${e(weekWords(mate, week)).replace(/\n/g, "<br>")}</p>${overrides}${undo}${send}</details>`;
72
+ const overrides = week.overrides.length > 0 && html `<h3>What you overrode</h3><ul class="teammate-overrides">${week.overrides.map(one => html `<li>${one.said}${one.href !== null && html ` · <a href="${one.href}">open</a>`} <span class="meta">${when(one.at)}</span></li>`)}</ul>`;
73
+ const undo = week.undoable.length > 0 && html `<h3>Undo</h3><ul class="teammate-undo">${week.undoable.map(one => html `<li data-undo="${one.call}"><span>${one.words}</span> <span class="meta">${when(one.at)}${one.href !== null && html ` · <a href="${one.href}">open</a>`}</span>${canManage && postForm(`/teammates/${mate.id}/week`, html `<button name="op" value="undo" class="secondary">Undo with ${one.undo}</button>`, { hidden: { id: one.call } })}</li>`)}</ul>`;
74
+ const send = canManage && postForm(`/teammates/${mate.id}/week`, html `<button name="op" value="send" class="secondary">Send the week's report</button>`);
75
+ return html `<details class="card teammate-week" id="week" open><summary>This week</summary><p class="teammate-week-said">${lines(weekWords(mate, week))}</p>${overrides}${undo}${send}</details>`;
82
76
  }
83
77
  /** v96: its desk — where messages to it by name and its routines land — and its routines. */
84
- function deskSection(store, mate, csrf, canManage) {
78
+ function deskSection(store, mate, canManage) {
85
79
  const name = nameOf(mate);
86
80
  const desk = deskOf(store, mate);
87
81
  const routines = routinesOf(store, mate);
88
- const intro = `<p class="meta">Message ${e(name)} by name in your chat app, like “@${e(mate.handle)} where's order 1042?”. It lands on ${desk === null ? "its desk" : `<a href="/flows/${desk.id}">${e(name)}'s desk</a>`}, and the answer comes back to you.</p>`;
89
- const list = routines.length === 0 ? "" : `<ul class="teammate-routines">${routines.map(one => `<li data-routine="${one.id}"><span><strong>${e(scheduleWords(one.schedule))}</strong> · ${e(one.text)}</span>` +
90
- `<span class="meta">${one.state === "paused" ? "Paused" : one.nextAt === null ? "" : `Next ${when(one.nextAt)}`}${one.lastAt === null ? "" : ` · last ${when(one.lastAt)}${one.lastOutcome === null ? "" : `: ${e(one.lastOutcome)}`}`}</span>` +
91
- (canManage ? `<form method="post" action="/teammates/${mate.id}/routines" class="routine-buttons">${hidden(csrf)}<input type="hidden" name="id" value="${one.id}"><button name="op" value="run" class="secondary">Run now</button><button name="op" value="remove" class="secondary">Remove</button></form>` : "") + `</li>`).join("")}</ul>`;
92
- const add = canManage ? `<form method="post" action="/teammates/${mate.id}/routines" class="routine-add">${hidden(csrf)}<input type="hidden" name="op" value="add">` +
93
- `<label>When<input type="text" name="schedule" maxlength="80" placeholder="weekdays 09:00" required></label><label>What to do<input type="text" name="text" maxlength="200" placeholder="Look up yesterday's refunds and tell me the total." required></label><button>Add routine</button></form>` : "";
94
- return `<section class="card teammate-desk" id="desk"><h2>Desk and routines</h2>${intro}${list}${add}${canManage ? `<p class="meta">A routine puts a card on its desk on that schedule (“weekdays 09:00”, “daily 17:00”, “monday 09:00”, “every 2 hours”; times are this computer's, ${e(localZone())}, unless you name one); the answer goes to ${e(mate.manager)}.</p>` : ""}</section>`;
82
+ const intro = html `<p class="meta">Message ${name} by name in your chat app, like “@${mate.handle} where's order 1042?”. It lands on ${desk === null ? "its desk" : html `<a href="/flows/${desk.id}">${name}'s desk</a>`}, and the answer comes back to you.</p>`;
83
+ const list = routines.length > 0 && html `<ul class="teammate-routines">${routines.map(one => html `<li data-routine="${one.id}"><span><strong>${scheduleWords(one.schedule)}</strong> · ${one.text}</span><span class="meta">${one.state === "paused" ? "Paused" : one.nextAt === null ? "" : `Next ${when(one.nextAt)}`}${one.lastAt !== null && ` · last ${when(one.lastAt)}${one.lastOutcome === null ? "" : `: ${one.lastOutcome}`}`}</span>${canManage && postForm(`/teammates/${mate.id}/routines`, html `<button name="op" value="run" class="secondary">Run now</button><button name="op" value="remove" class="secondary">Remove</button>`, { attrs: { class: "routine-buttons" }, hidden: { id: one.id } })}</li>`)}</ul>`;
84
+ const add = canManage && postForm(`/teammates/${mate.id}/routines`, html `<label>When<input type="text" name="schedule" maxlength="80" placeholder="weekdays 09:00" required></label><label>What to do<input type="text" name="text" maxlength="200" placeholder="Look up yesterday's refunds and tell me the total." required></label><button>Add routine</button>`, { attrs: { class: "routine-add" }, hidden: { op: "add" } });
85
+ return html `<section class="card teammate-desk" id="desk"><h2>Desk and routines</h2>${intro}${list}${add}${canManage && html `<p class="meta">A routine puts a card on its desk on that schedule (“weekdays 09:00”, “daily 17:00”, “monday 09:00”, “every 2 hours”; times are this computer's, ${localZone()}, unless you name one); the answer goes to ${mate.manager}.</p>`}</section>`;
95
86
  }
96
87
  /** A schedule's stored form ("weekdays:09:00@Europe/London") in words. */
97
88
  const scheduleWords = (text) => { const parsed = parseSchedule(text); return parsed === null ? text : describeSchedule(parsed); };
98
89
  /** v95: what it remembers — what its people told it, and what it kept from cards — to search, edit and forget. */
99
- function memorySection(store, mate, csrf, canManage, query) {
90
+ function memorySection(store, mate, canManage, query) {
100
91
  const name = nameOf(mate);
101
92
  const all = store.teammateMemories(mate.id, { limit: 500 });
102
93
  const shown = query === null || query.trim() === "" ? all.slice(0, 30) : searchMemories(store, mate, query);
103
- const tell = canManage ? `<form method="post" action="/teammates/${mate.id}/note" class="memory-tell">${hidden(csrf)}<label class="sr-only" for="teammate-note">Tell ${e(name)} something</label><textarea id="teammate-note" name="note" rows="2" maxlength="${MEMORY_CHARS}" placeholder="For example: this week, offer free shipping instead of a refund when you can."></textarea><button>Tell ${e(name)}</button></form>` : "";
104
- const search = all.length > 8 || (query ?? "") !== "" ? `<form method="get" action="/teammates/${mate.id}#memory" class="memory-search" role="search"><label class="sr-only" for="memory-q">Search what ${e(name)} remembers</label><input id="memory-q" type="search" name="q" value="${e(query ?? "")}" placeholder="Search what ${e(name)} remembers"><button class="secondary">Search</button></form>` : "";
94
+ const tell = canManage && postForm(`/teammates/${mate.id}/note`, html `<label class="sr-only" for="teammate-note">Tell ${name} something</label><textarea id="teammate-note" name="note" rows="2" maxlength="${MEMORY_CHARS}" placeholder="For example: this week, offer free shipping instead of a refund when you can."></textarea><button>Tell ${name}</button>`, { attrs: { class: "memory-tell" } });
95
+ const search = (all.length > 8 || (query ?? "") !== "") && html `<form method="get" action="/teammates/${mate.id}#memory" class="memory-search" role="search"><label class="sr-only" for="memory-q">Search what ${name} remembers</label><input id="memory-q" type="search" name="q" value="${query ?? ""}" placeholder="Search what ${name} remembers"><button class="secondary">Search</button></form>`;
105
96
  const items = shown.map(one => {
106
97
  const card = one.card === null ? null : store.getFlowCard(one.card);
107
- const whence = one.source === "person" ? `${e(one.createdBy)} told it` : card === null ? "it kept this" : `it kept this from <a href="/flows/${card.flow}?card=${card.id}">${e(card.title.slice(0, 60))}</a>`;
108
- const edit = canManage ? `<details class="memory-edit"><summary>Edit</summary><form method="post" action="/teammates/${mate.id}/memory">${hidden(csrf)}<input type="hidden" name="id" value="${one.id}"><label class="sr-only" for="memory-${one.id}">Memory</label><textarea id="memory-${one.id}" name="text" rows="2" maxlength="${MEMORY_CHARS}">${e(one.text)}</textarea><div class="memory-buttons"><button name="op" value="edit">Save</button><button name="op" value="forget" class="secondary">Forget</button></div></form></details>` : "";
109
- return `<li data-memory="${one.id}" data-source="${one.source}"><span>${e(one.text)}</span> <span class="meta">${whence} · ${when(one.updatedAt)}</span>${edit}</li>`;
110
- }).join("");
111
- const empty = (query ?? "") !== "" ? `<p class="meta">Nothing ${e(name)} remembers matches that.</p>` : `<p class="meta">Nothing yet. What you tell ${e(name)} is kept here, and it keeps short facts from the cards it works.</p>`;
112
- return `<section class="card teammate-memory" id="memory"><h2>Memory</h2>${tell}${search}${shown.length === 0 ? empty : `<ul class="teammate-memories">${items}</ul>`}${query === null && all.length > shown.length ? `<p class="meta">${all.length - shown.length} older ${all.length - shown.length === 1 ? "memory" : "memories"}: search to find them.</p>` : ""}</section>`;
98
+ const whence = one.source === "person" ? `${one.createdBy} told it` : card === null ? "it kept this" : html `it kept this from <a href="/flows/${card.flow}?card=${card.id}">${card.title.slice(0, 60)}</a>`;
99
+ const edit = canManage && html `<details class="memory-edit"><summary>Edit</summary>${postForm(`/teammates/${mate.id}/memory`, html `<label class="sr-only" for="memory-${one.id}">Memory</label><textarea id="memory-${one.id}" name="text" rows="2" maxlength="${MEMORY_CHARS}">${one.text}</textarea><div class="memory-buttons"><button name="op" value="edit">Save</button><button name="op" value="forget" class="secondary">Forget</button></div>`, { hidden: { id: one.id } })}</details>`;
100
+ return html `<li data-memory="${one.id}" data-source="${one.source}"><span>${one.text}</span> <span class="meta">${whence} · ${when(one.updatedAt)}</span>${edit}</li>`;
101
+ });
102
+ const empty = (query ?? "") !== "" ? html `<p class="meta">Nothing ${name} remembers matches that.</p>` : html `<p class="meta">Nothing yet. What you tell ${name} is kept here, and it keeps short facts from the cards it works.</p>`;
103
+ return html `<section class="card teammate-memory" id="memory"><h2>Memory</h2>${tell}${search}${shown.length === 0 ? empty : html `<ul class="teammate-memories">${items}</ul>`}${query === null && all.length > shown.length && html `<p class="meta">${all.length - shown.length} older ${all.length - shown.length === 1 ? "memory" : "memories"}: search to find them.</p>`}</section>`;
113
104
  }
114
- export function teammatePageHtml(store, mate, viewer, projectName, csrf, canManage, approvers, notice) {
105
+ export function teammatePageHtml(store, mate, viewer, projectName, canManage, approvers, notice) {
115
106
  const name = nameOf(mate);
116
107
  const questions = store.openTeammateQuestions([mate.id]);
117
108
  const asked = questions.map(question => {
118
109
  const card = store.getFlowCard(question.card);
119
110
  const flow = card === null ? null : store.getFlow(card.flow);
120
111
  const mine = question.askedOf === viewer;
121
- const cardLink = card === null || flow === null ? "a card" : `<a href="/flows/${flow.id}?card=${card.id}">${e(card.title)}</a>`;
122
- return `<article class="card teammate-question" data-question="${question.id}"${question.suggestion === null ? "" : " data-suggestion"}><p class="meta">${question.suggestion === null ? `About ${cardLink}` : "Suggests a rule change"} · ${when(question.createdAt)}${mine ? "" : ` · for ${e(question.askedOf)}`}</p><p><strong>${e(question.question)}</strong></p>` +
123
- (mine ? `<div class="question-options">${question.options.map(one => `<form method="post" action="/teammates/questions/${question.id}/answer">${hidden(csrf)}<input type="hidden" name="choice" value="${e(one.id)}"><button>${e(one.label)}</button></form>`).join("")}</div>` +
124
- `<form method="post" action="/teammates/questions/${question.id}/answer" class="question-reply">${hidden(csrf)}<label>Or answer in your words<textarea name="text" rows="2" maxlength="2000"></textarea></label><button>Answer</button></form>` : "") +
125
- `</article>`;
126
- }).join("");
112
+ const cardLink = card === null || flow === null ? "a card" : html `<a href="/flows/${flow.id}?card=${card.id}">${card.title}</a>`;
113
+ const answer = mine && html `<div class="question-options">${question.options.map(one => postForm(`/teammates/questions/${question.id}/answer`, html `<button>${one.label}</button>`, { hidden: { choice: one.id } }))}</div>${postForm(`/teammates/questions/${question.id}/answer`, html `<label>Or answer in your words<textarea name="text" rows="2" maxlength="2000"></textarea></label><button>Answer</button>`, { attrs: { class: "question-reply" } })}`;
114
+ return html `<article class="card teammate-question" data-question="${question.id}"${question.suggestion !== null && html ` data-suggestion`}><p class="meta">${question.suggestion === null ? html `About ${cardLink}` : "Suggests a rule change"} · ${when(question.createdAt)}${!mine && ` · for ${question.askedOf}`}</p><p><strong>${question.question}</strong></p>${answer}</article>`;
115
+ });
127
116
  const zones = zonesOf(store, mate);
128
117
  const where = zones.length === 0
129
- ? `<p class="meta">Not on any zone yet. In a flow, choose ${e(name)} under “Who decides” on a “Person decides” zone, or add a “Teammate handles it” zone.</p>`
130
- : `<ul>${zones.map(one => `<li><a href="/flows/${one.flow}">${e(one.flowName)}</a> · ${one.kind === "decides" ? "decides" : "handles"} ${e(one.title)}</li>`).join("")}</ul>`;
118
+ ? html `<p class="meta">Not on any zone yet. In a flow, choose ${name} under “Who decides” on a “Person decides” zone, or add a “Teammate handles it” zone.</p>`
119
+ : html `<ul>${zones.map(one => html `<li><a href="/flows/${one.flow}">${one.flowName}</a> · ${one.kind === "decides" ? "decides" : "handles"} ${one.title}</li>`)}</ul>`;
131
120
  // What it did and why, with every tool call it made or asked to make (v94), newest first.
132
121
  const events = [
133
122
  ...store.teammateEvents(mate.id, 40).filter(one => one.kind !== "note").map(one => ({ kind: one.kind, said: one.said, card: one.card, at: one.at })),
134
123
  ...store.teammateCallsOf(mate.id, 40).map(one => ({ kind: "call", said: `Used ${callWords(one.tool, one.action, one.input, 200)} · ${receiptWords(store, one)}`, card: one.card, at: one.createdAt })),
135
124
  ].sort((a, b) => b.at.localeCompare(a.at)).slice(0, 40);
136
- const activity = events.length === 0 ? `<p class="meta">Nothing yet.</p>` : `<ul class="teammate-activity">${events.map(one => {
125
+ const activity = events.length === 0 ? html `<p class="meta">Nothing yet.</p>` : html `<ul class="teammate-activity">${events.map(one => {
137
126
  const card = one.card === null ? null : store.getFlowCard(one.card);
138
- return `<li data-event="${one.kind}">${e(one.said)}${card === null ? "" : ` · <a href="/flows/${card.flow}?card=${card.id}">open</a>`} <span class="meta">${when(one.at)}</span></li>`;
139
- }).join("")}</ul>`;
127
+ return html `<li data-event="${one.kind}">${one.said}${card !== null && html ` · <a href="/flows/${card.flow}?card=${card.id}">open</a>`} <span class="meta">${when(one.at)}</span></li>`;
128
+ })}</ul>`;
140
129
  const versions = store.teammateVersions(mate.id);
141
130
  const latest = versions[0];
142
- const manage = canManage ? `<div class="teammate-actions">` +
143
- `<form method="post" action="/teammates/${mate.id}/state">${hidden(csrf)}<input type="hidden" name="state" value="${mate.state === "active" ? "paused" : "active"}"><button${mate.state === "active" ? ' class="secondary"' : ""}>${mate.state === "active" ? `Pause ${e(name)}` : `Resume ${e(name)}`}</button></form>` +
144
- `<form method="post" action="/teammates/${mate.id}/summary">${hidden(csrf)}<button class="secondary">Send today's summary</button></form></div>` : "";
145
- return [
146
- notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : "",
147
- notice.said ? `<p class="said" role="status">${e(notice.said)}</p>` : "",
148
- `<p class="meta">${e(projectName(mate.repo))} · ${mate.state === "active" ? "Working" : "Paused: its decisions go to people"} · reports to ${e(mate.manager)}</p>`,
149
- manage,
150
- questions.length === 0 ? "" : `<section><h2>Questions</h2>${asked}</section>`,
151
- `<section class="card"><h2>Today</h2><p class="teammate-summary">${e(summaryOf(store, mate, startOfDay()).said).replace(/\n/g, "<br>")}</p></section>`,
152
- weekSection(store, mate, csrf, canManage),
153
- `<section class="card"><h2>Works on</h2>${where}</section>`,
154
- toolsSection(store, mate, csrf, canManage),
155
- deskSection(store, mate, csrf, canManage),
156
- memorySection(store, mate, csrf, canManage, notice.query ?? null),
157
- `<details class="card"${canManage ? " open" : ""}><summary>Soul file</summary><p class="meta">Who ${e(name)} is and its rules, read every turn. Version ${mate.version}${latest === undefined ? "" : ` · saved by ${e(latest.savedBy)}, ${when(latest.savedAt)}`} · <a href="/teammates/${mate.id}/soul.md" download>Download</a></p>` +
158
- (canManage ? `<form method="post" action="/teammates/${mate.id}/soul" data-soul-form>${hidden(csrf)}<label class="sr-only" for="teammate-soul">Soul file</label><textarea id="teammate-soul" name="soul" rows="24" class="mono" maxlength="12000" spellcheck="false">${e(notice.soulDraft ?? mate.soul)}</textarea><button>Save</button></form>` : `<pre class="mono">${e(mate.soul)}</pre>`) + `</details>`,
159
- `<details class="card"><summary>What ${e(name)} did</summary>${activity}</details>`,
160
- canManage ? `<details class="card"><summary>Settings</summary><form method="post" action="/teammates/${mate.id}/settings">${hidden(csrf)}` +
161
- `<label>Model<select name="model">${TEAMMATE_MODELS.map(one => `<option value="${one}"${(mate.model ?? "default") === one ? " selected" : ""}>${one === "default" ? "The lead chat's model" : one[0].toUpperCase() + one.slice(1)}</option>`).join("")}</select></label>` +
162
- `<label>Turns a day, at most<input type="number" name="dailyTurns" min="1" max="2000" value="${mate.dailyTurns}"></label>` +
163
- `<label>Reports to<select name="manager">${[...new Set([mate.manager, ...approvers])].map(one => `<option value="${e(one)}"${one === mate.manager ? " selected" : ""}>${e(one)}</option>`).join("")}</select></label><button>Save settings</button></form>` +
164
- `<form method="post" action="/teammates/${mate.id}/state" class="danger-zone">${hidden(csrf)}<input type="hidden" name="state" value="removed"><button class="danger">Remove ${e(name)} from the team</button></form></details>` : "",
165
- ].join("");
131
+ const manage = canManage && html `<div class="teammate-actions">${postForm(`/teammates/${mate.id}/state`, html `<button${mate.state === "active" && html ` class="secondary"`}>${mate.state === "active" ? `Pause ${name}` : `Resume ${name}`}</button>`, { hidden: { state: mate.state === "active" ? "paused" : "active" } })}${postForm(`/teammates/${mate.id}/summary`, html `<button class="secondary">Send today's summary</button>`)}</div>`;
132
+ const soul = html `<details class="card"${canManage && html ` open`}><summary>Soul file</summary><p class="meta">Who ${name} is and its rules, read every turn. Version ${mate.version}${latest !== undefined && ` · saved by ${latest.savedBy}, ${when(latest.savedAt)}`} · <a href="/teammates/${mate.id}/soul.md" download>Download</a></p>${canManage
133
+ ? postForm(`/teammates/${mate.id}/soul`, html `<label class="sr-only" for="teammate-soul">Soul file</label><textarea id="teammate-soul" name="soul" rows="24" class="mono" maxlength="12000" spellcheck="false">${notice.soulDraft ?? mate.soul}</textarea><button>Save</button>`, { attrs: { "data-soul-form": true } })
134
+ : html `<pre class="mono">${mate.soul}</pre>`}</details>`;
135
+ const settings = canManage && html `<details class="card"><summary>Settings</summary>${postForm(`/teammates/${mate.id}/settings`, html `<label>Model<select name="model">${TEAMMATE_MODELS.map(one => html `<option value="${one}"${(mate.model ?? "default") === one && html ` selected`}>${one === "default" ? "The lead chat's model" : one[0].toUpperCase() + one.slice(1)}</option>`)}</select></label><label>Turns a day, at most<input type="number" name="dailyTurns" min="1" max="2000" value="${mate.dailyTurns}"></label><label>Reports to<select name="manager">${[...new Set([mate.manager, ...approvers])].map(one => html `<option value="${one}"${one === mate.manager && html ` selected`}>${one}</option>`)}</select></label><button>Save settings</button>`)}${postForm(`/teammates/${mate.id}/state`, html `<button class="danger">Remove ${name} from the team</button>`, { attrs: { class: "danger-zone" }, hidden: { state: "removed" } })}</details>`;
136
+ return html `${notice.problem && html `<p class="problem" role="alert">${notice.problem}</p>`}${notice.said && html `<p class="said" role="status">${notice.said}</p>`}<p class="meta">${projectName(mate.repo)} · ${mate.state === "active" ? "Working" : "Paused: its decisions go to people"} · reports to ${mate.manager}</p>${manage}${questions.length > 0 && html `<section><h2>Questions</h2>${asked}</section>`}<section class="card"><h2>Today</h2><p class="teammate-summary">${lines(summaryOf(store, mate, startOfDay()).said)}</p></section>${weekSection(store, mate, canManage)}<section class="card"><h2>Works on</h2>${where}</section>${toolsSection(store, mate, canManage)}${deskSection(store, mate, canManage)}${memorySection(store, mate, canManage, notice.query ?? null)}${soul}<details class="card"><summary>What ${name} did</summary>${activity}</details>${settings}`;
166
137
  }
@@ -3,10 +3,11 @@
3
3
  * registration must point at is shown here; it has to be reachable from the
4
4
  * internet, which a tailnet-only address is not. */
5
5
  import type { Store } from "./store.js";
6
- export declare function teamsSettingsHtml(store: Store, dir: string, csrf: string, options?: {
6
+ import { type Html } from "./html.js";
7
+ export declare function teamsSettingsHtml(store: Store, dir: string, options?: {
7
8
  code?: string;
8
9
  problem?: string;
9
10
  now?: Date;
10
11
  who?: string;
11
12
  publicUrl?: string | null;
12
- }): string;
13
+ }): Html;
@@ -1,9 +1,9 @@
1
+ import { html, joinHtml, postForm } from "./html.js";
1
2
  import { ChatState } from "./chat-delivery-state.js";
2
3
  import { loadTeamsCredentials } from "./teams-api.js";
3
4
  import { loadPrimary } from "./webhooks.js";
4
5
  import { TEAMS_MESSAGES_PATH } from "./teams.js";
5
- const escape = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
6
- export function teamsSettingsHtml(store, dir, csrf, options = {}) {
6
+ export function teamsSettingsHtml(store, dir, options = {}) {
7
7
  const credentials = loadTeamsCredentials(dir), state = new ChatState(store, "teams");
8
8
  const bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [];
9
9
  const binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null;
@@ -11,47 +11,36 @@ export function teamsSettingsHtml(store, dir, csrf, options = {}) {
11
11
  const runtime = credentials ? state.runtime(credentials.installation) : null;
12
12
  const now = options.now ?? new Date();
13
13
  const live = runtime && runtime.leaseUntil !== null && runtime.leaseUntil > now.toISOString() && runtime.connected;
14
- const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
15
- const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
16
- const post = (action, content) => `<form method="post" action="/settings/teams/${action}" class="card">${hidden}${content}</form>`;
14
+ const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
15
+ const post = (action, content) => postForm(`/settings/teams/${action}`, content, { attrs: { class: "card" } });
17
16
  const endpoint = options.publicUrl ? `${options.publicUrl.replace(/\/$/, "")}${TEAMS_MESSAGES_PATH}` : null;
18
- let content = "";
17
+ const parts = [];
19
18
  if (!credentials) {
20
- content =
21
- "<p>Manage projects and review results in a Teams chat, and let a channel follow a team conversation.</p>" +
22
- '<details open><summary>Register the bot</summary><ol><li>In <a href="https://portal.azure.com" target="_blank" rel="noopener noreferrer">Azure</a>, create an <em>Azure Bot</em> with a new single-tenant app registration; note the app (client) id and the directory (tenant) id, and create a client secret.</li>' +
23
- `<li>Set its messaging endpoint to <code>${endpoint === null ? "https://your-public-address" + TEAMS_MESSAGES_PATH : escape(endpoint)}</code>. Microsoft must reach it from the internet: a tailnet-only address needs Tailscale Funnel (or another public tunnel) on that path.</li>` +
24
- "<li>Enable the Microsoft Teams channel on the bot, then add the app to Teams (personal scope; team scope for channels).</li></ol></details>" +
25
- post("connect", '<label>App (client) id<input style="min-height:44px" type="text" name="app-id" autocomplete="off" required></label>' +
26
- '<label>Directory (tenant) id<input style="min-height:44px" type="text" name="tenant" autocomplete="off" required></label>' +
27
- '<label>Client secret<input style="min-height:44px" type="password" name="secret" autocomplete="off" required></label>' +
28
- password + '<p class="meta">The secret stays on this installation. Next, pair your own Teams account.</p><button type="submit">Connect Teams</button>');
19
+ parts.push(html `<p>Manage projects and review results in a Teams chat, and let a channel follow a team conversation.</p>`, html `<details open><summary>Register the bot</summary><ol><li>In <a href="https://portal.azure.com" target="_blank" rel="noopener noreferrer">Azure</a>, create an <em>Azure Bot</em> with a new single-tenant app registration; note the app (client) id and the directory (tenant) id, and create a client secret.</li>`, html `<li>Set its messaging endpoint to <code>${endpoint === null ? "https://your-public-address" + TEAMS_MESSAGES_PATH : endpoint}</code>. Microsoft must reach it from the internet: a tailnet-only address needs Tailscale Funnel (or another public tunnel) on that path.</li>`, html `<li>Enable the Microsoft Teams channel on the bot, then add the app to Teams (personal scope; team scope for channels).</li></ol></details>`, post("connect", joinHtml([
20
+ html `<label>App (client) id<input style="min-height:44px" type="text" name="app-id" autocomplete="off" required></label>`,
21
+ html `<label>Directory (tenant) id<input style="min-height:44px" type="text" name="tenant" autocomplete="off" required></label>`,
22
+ html `<label>Client secret<input style="min-height:44px" type="password" name="secret" autocomplete="off" required></label>`,
23
+ html `${password}<p class="meta">The secret stays on this installation. Next, pair your own Teams account.</p><button type="submit">Connect Teams</button>`
24
+ ])));
29
25
  }
30
26
  else {
31
- content = `<p><strong>Microsoft Teams</strong> · ${live ? "Signed in" : "Waiting for sign-in"}</p>`;
27
+ parts.push(html `<p><strong>Microsoft Teams</strong> · ${live ? "Signed in" : "Waiting for sign-in"}</p>`);
32
28
  if (endpoint !== null)
33
- content += `<p class="meta">Messaging endpoint: <code>${escape(endpoint)}</code> — must be reachable from the internet.</p>`;
29
+ parts.push(html `<p class="meta">Messaging endpoint: <code>${endpoint}</code> — must be reachable from the internet.</p>`);
34
30
  if (runtime?.problem)
35
- content += `<p role="status">${escape(String(runtime.problem))}</p>`;
31
+ parts.push(html `<p role="status">${String(runtime.problem)}</p>`);
36
32
  if (!live && !runtime?.problem)
37
- content += "<p>Keep the Toolroll worker running to answer Teams messages.</p>";
33
+ parts.push(html `<p>Keep the Toolroll worker running to answer Teams messages.</p>`);
38
34
  if (options.code) {
39
- content += "<h2>Pair your account</h2><p>Send this to the Toolroll bot in a personal Teams chat. It expires in 10 minutes.</p>" +
40
- `<label>Pairing message<input type="text" readonly value="pair ${escape(options.code)}" autocomplete="off" style="width:100%;max-width:100%;font-size:14px;min-height:44px"></label>` +
41
- '<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/teams">Check connection</a>';
35
+ parts.push(html `<h2>Pair your account</h2><p>Send this to the Toolroll bot in a personal Teams chat. It expires in 10 minutes.</p>`, html `<label>Pairing message<input type="text" readonly value="pair ${options.code}" autocomplete="off" style="width:100%;max-width:100%;font-size:14px;min-height:44px"></label>`, html `<a class="button-link" style="min-height:44px;white-space:nowrap" href="/settings/teams">Check connection</a>`);
42
36
  }
43
37
  else if (binding && state.live(binding)) {
44
- content += `<p>Your Teams account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}</p>` +
45
- "<p>Try “What needs my attention?” or send <code>status</code>. In a channel, mention the bot with <code>team</code> to follow a conversation.</p>" +
46
- '<p><a href="/chat">Open saved chat</a></p>' +
47
- (loadPrimary(process.env, dir) === "teams" ? "<p>Task updates are sent here.</p>" : post("alerts", '<button type="submit">Send task updates here</button>')) +
48
- post("unpair", password + '<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>');
38
+ parts.push(html `<p>Your Teams account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}</p>`, html `<p>Try “What needs my attention?” or send <code>status</code>. In a channel, mention the bot with <code>team</code> to follow a conversation.</p>`, html `<p><a href="/chat">Open saved chat</a></p>`, loadPrimary(process.env, dir) === "teams" ? html `<p>Task updates are sent here.</p>` : post("alerts", html `<button type="submit">Send task updates here</button>`), post("unpair", html `${password}<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>`));
49
39
  }
50
40
  else {
51
- content += post("pair", "<h2>Pair your account</h2><p>Your paired Teams account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll." +
52
- (others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : "") + "</p>" + password + '<button type="submit">Create pairing code</button>');
41
+ parts.push(post("pair", html `<h2>Pair your account</h2><p>Your paired Teams account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : ""}</p>${password}<button type="submit">Create pairing code</button>`));
53
42
  }
54
- content += "<details><summary>Connection settings</summary>" + post("disconnect", password + '<button type="submit">Disconnect Teams</button>') + "</details>";
43
+ parts.push(html `<details><summary>Connection settings</summary>${post("disconnect", html `${password}<button type="submit">Disconnect Teams</button>`)}</details>`);
55
44
  }
56
- return `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Teams</h1>${options.problem ? `<p class="problem" role="alert">${escape(options.problem)}</p>` : ""}${content}</section>`;
45
+ return html `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Teams</h1>${options.problem ? html `<p class="problem" role="alert">${options.problem}</p>` : ""}${parts}</section>`;
57
46
  }
@@ -3,10 +3,11 @@
3
3
  * the settings page; this card only mints or revokes the signed-in person's
4
4
  * pairing, never anyone else's. */
5
5
  import type { Store } from "./store.js";
6
+ import { type Html } from "./html.js";
6
7
  /** How long a written reply may wait to be sent before it shows here with a Retry. */
7
8
  export declare const UNSENT_REPLY_MS: number;
8
- export declare function telegramSettingsHtml(store: Store, botId: string | null, who: string, csrf: string, options?: {
9
+ export declare function telegramSettingsHtml(store: Store, botId: string | null, who: string, options?: {
9
10
  code?: string;
10
11
  problem?: string;
11
12
  now?: Date;
12
- }): string;
13
+ }): Html;
@@ -1,47 +1,36 @@
1
+ import { html, joinHtml, postForm } from "./html.js";
1
2
  /** How long a written reply may wait to be sent before it shows here with a Retry. */
2
3
  export const UNSENT_REPLY_MS = 2 * 60_000;
3
- const escape = (text) => text
4
- .replaceAll("&", "&amp;")
5
- .replaceAll("<", "&lt;")
6
- .replaceAll(">", "&gt;")
7
- .replaceAll('"', "&quot;")
8
- .replaceAll("'", "&#39;");
9
- export function telegramSettingsHtml(store, botId, who, csrf, options = {}) {
10
- const hidden = `<input type="hidden" name="csrf" value="${escape(csrf)}">`;
11
- const password = '<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>';
12
- const post = (action, content) => `<form method="post" action="/settings/telegram/${action}" class="card">${hidden}${content}</form>`;
13
- let content = "";
4
+ export function telegramSettingsHtml(store, botId, who, options = {}) {
5
+ const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
6
+ const post = (action, content) => postForm(`/settings/telegram/${action}`, content, { attrs: { class: "card" } });
7
+ let content;
14
8
  if (botId === null) {
15
- content = '<p>No Telegram bot is connected yet. An installation approver saves the bot token on the <a href="/settings">settings page</a> first.</p>';
9
+ content = html `<p>No Telegram bot is connected yet. An installation approver saves the bot token on the <a href="/settings">settings page</a> first.</p>`;
16
10
  }
17
11
  else {
18
12
  const bindings = store.liveTelegramBindings(botId);
19
13
  const mine = bindings.filter(binding => binding.approver === who);
20
14
  const others = bindings.length - mine.length;
21
- content = `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`;
15
+ const parts = [html `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`];
22
16
  // A reply the assistant wrote that has not reached this person's phone for two minutes.
23
17
  const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
24
18
  if (unsent.length > 0) {
25
19
  const oldest = unsent[0];
26
- content +=
27
- `<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? "A reply hasn't reached your phone" : `${unsent.length} replies haven't reached your phone`}</strong></p>` +
28
- `<p class="meta">Trying since ${escape(oldest.since.slice(11, 16))} UTC${oldest.error === null ? "" : `: ${escape(oldest.error)}`}</p>` +
29
- `<form method="post" action="/settings/telegram/retry">${hidden}<button type="submit">Retry</button></form></div>`;
20
+ parts.push(html `<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? html `A reply hasn't reached your phone` : html `${unsent.length} replies haven't reached your phone`}</strong></p><p class="meta">Trying since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : `: ${oldest.error}`}</p>${postForm("/settings/telegram/retry", html `<button type="submit">Retry</button>`)}</div>`);
30
21
  }
31
22
  if (options.code !== undefined) {
32
- content +=
33
- "<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p>" +
34
- `<pre class="pairing-code">/pair ${escape(options.code)}</pre>` +
35
- '<p class="meta">The code works once. That chat then answers as you, so treat it like your sign-in.</p>';
23
+ parts.push(html `<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p><pre class="pairing-code">/pair ${options.code}</pre><p class="meta">The code works once. That chat then answers as you, so treat it like your sign-in.</p>`);
36
24
  }
37
25
  else if (mine.length === 0) {
38
- content += post("pair", password + '<button type="submit">Pair my phone</button>');
26
+ parts.push(post("pair", html `${password}<button type="submit">Pair my phone</button>`));
39
27
  }
40
28
  else {
41
- content += post("unpair", password + '<p class="meta">This phone will lose access. Its existing Telegram buttons will stop working.</p><button type="submit" class="danger">Unpair my phone</button>');
29
+ parts.push(post("unpair", html `${password}<p class="meta">This phone will lose access. Its existing Telegram buttons will stop working.</p><button type="submit" class="danger">Unpair my phone</button>`));
42
30
  }
31
+ content = joinHtml(parts);
43
32
  }
44
33
  if (options.problem !== undefined)
45
- content = `<p role="status">${escape(options.problem)}</p>` + content;
46
- return `<h1>Telegram</h1>${content}<p class="meta">Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code>/team</code> there and chooses a conversation. Everyone in the group can read its replies.</p>`;
34
+ content = html `<p role="status">${options.problem}</p>${content}`;
35
+ return html `<h1>Telegram</h1>${content}<p class="meta">Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code>/team</code> there and chooses a conversation. Everyone in the group can read its replies.</p>`;
47
36
  }
@@ -4,6 +4,7 @@
4
4
  * and a one-time "What's new" card afterwards. The work itself is the
5
5
  * `toolroll update` command (toolroll-update.ts), started as its own job.
6
6
  */
7
+ import { type Html } from "./html.js";
7
8
  import type { InstallMethod } from "./install-method.js";
8
9
  import { type RuntimeUpdateJournal } from "./toolroll-update.js";
9
10
  export declare const UPDATES_CSS: string;
@@ -29,11 +30,11 @@ export type UpdatesView = {
29
30
  csrf: string;
30
31
  };
31
32
  /** The steps, each done, current, failed or waiting. Also the live region's fragment. */
32
- export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean): string;
33
+ export declare function updateStepsHtml(j: RuntimeUpdateJournal, running: boolean): Html;
33
34
  export declare function updatesHtml(view: UpdatesView, notice: {
34
35
  said?: string | null;
35
36
  problem?: string | null;
36
- }): string;
37
+ }): Html;
37
38
  export declare function newerThan(a: string, b: string): boolean;
38
39
  /** Polls the steps every two seconds, keeps trying while the console
39
40
  * restarts, and reloads once the update has finished. */