@intentic/sandbox-contract 1.159.0 → 1.161.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/agent-catalog.d.ts +1 -0
  2. package/dist/agent-catalog.d.ts.map +1 -1
  3. package/dist/agent-catalog.js +1 -0
  4. package/dist/agent-catalog.js.map +1 -1
  5. package/dist/contracts/agent.contract.d.ts +50 -4
  6. package/dist/contracts/agent.contract.d.ts.map +1 -1
  7. package/dist/contracts/agent.contract.js +2 -1
  8. package/dist/contracts/agent.contract.js.map +1 -1
  9. package/dist/contracts/agents.contract.d.ts +195 -0
  10. package/dist/contracts/agents.contract.d.ts.map +1 -1
  11. package/dist/contracts/agents.contract.js +7 -2
  12. package/dist/contracts/agents.contract.js.map +1 -1
  13. package/dist/contracts/claude.contract.d.ts +26 -0
  14. package/dist/contracts/claude.contract.d.ts.map +1 -1
  15. package/dist/contracts/claude.contract.js +2 -1
  16. package/dist/contracts/claude.contract.js.map +1 -1
  17. package/dist/contracts/extensions.contract.d.ts +1 -0
  18. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  19. package/dist/contracts/git.contract.d.ts +13 -0
  20. package/dist/contracts/git.contract.d.ts.map +1 -1
  21. package/dist/contracts/git.contract.js +2 -1
  22. package/dist/contracts/git.contract.js.map +1 -1
  23. package/dist/contracts/grok.contract.d.ts +2 -0
  24. package/dist/contracts/grok.contract.d.ts.map +1 -1
  25. package/dist/contracts/kimi.contract.d.ts +26 -0
  26. package/dist/contracts/kimi.contract.d.ts.map +1 -1
  27. package/dist/contracts/kimi.contract.js +2 -1
  28. package/dist/contracts/kimi.contract.js.map +1 -1
  29. package/dist/contracts/memory.contract.d.ts +34 -0
  30. package/dist/contracts/memory.contract.d.ts.map +1 -0
  31. package/dist/contracts/memory.contract.js +9 -0
  32. package/dist/contracts/memory.contract.js.map +1 -0
  33. package/dist/contracts/panels.contract.d.ts +1 -0
  34. package/dist/contracts/panels.contract.d.ts.map +1 -1
  35. package/dist/contracts/push.contract.d.ts +1 -1
  36. package/dist/contracts/push.contract.js +2 -2
  37. package/dist/contracts/push.contract.js.map +1 -1
  38. package/dist/contracts/sessions.contract.d.ts +2 -0
  39. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  40. package/dist/contracts/settings.contract.d.ts +72 -15
  41. package/dist/contracts/settings.contract.d.ts.map +1 -1
  42. package/dist/contracts/settings.contract.js +3 -2
  43. package/dist/contracts/settings.contract.js.map +1 -1
  44. package/dist/contracts/system.contract.d.ts +15 -2
  45. package/dist/contracts/system.contract.d.ts.map +1 -1
  46. package/dist/contracts/system.contract.js +2 -1
  47. package/dist/contracts/system.contract.js.map +1 -1
  48. package/dist/contracts/translator.contract.d.ts +13 -3
  49. package/dist/contracts/translator.contract.d.ts.map +1 -1
  50. package/dist/contracts/translator.contract.js +1 -1
  51. package/dist/contracts/translator.contract.js.map +1 -1
  52. package/dist/contracts/usage.contract.js +2 -2
  53. package/dist/contracts/usage.contract.js.map +1 -1
  54. package/dist/contracts/workspace.contract.d.ts +38 -5
  55. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  56. package/dist/contracts/workspace.contract.js +2 -1
  57. package/dist/contracts/workspace.contract.js.map +1 -1
  58. package/dist/events.d.ts +124 -2
  59. package/dist/events.d.ts.map +1 -1
  60. package/dist/events.js +21 -3
  61. package/dist/events.js.map +1 -1
  62. package/dist/index.d.ts +491 -30
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +5 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/model-order.d.ts +1 -0
  67. package/dist/model-order.d.ts.map +1 -1
  68. package/dist/model-order.js +1 -0
  69. package/dist/model-order.js.map +1 -1
  70. package/dist/path-refs.d.ts +1 -0
  71. package/dist/path-refs.d.ts.map +1 -1
  72. package/dist/path-refs.js +3 -0
  73. package/dist/path-refs.js.map +1 -1
  74. package/dist/quick-model.d.ts +13 -0
  75. package/dist/quick-model.d.ts.map +1 -0
  76. package/dist/quick-model.js +33 -0
  77. package/dist/quick-model.js.map +1 -0
  78. package/dist/schemas.d.ts +374 -14
  79. package/dist/schemas.d.ts.map +1 -1
  80. package/dist/schemas.js +145 -15
  81. package/dist/schemas.js.map +1 -1
  82. package/dist/title.d.ts +6 -0
  83. package/dist/title.d.ts.map +1 -0
  84. package/dist/title.js +149 -0
  85. package/dist/title.js.map +1 -0
  86. package/package.json +2 -2
  87. package/src/agent-catalog.ts +6 -0
  88. package/src/contracts/agent.contract.ts +14 -1
  89. package/src/contracts/agents.contract.ts +25 -1
  90. package/src/contracts/claude.contract.ts +4 -1
  91. package/src/contracts/git.contract.ts +6 -0
  92. package/src/contracts/kimi.contract.ts +13 -2
  93. package/src/contracts/memory.contract.ts +13 -0
  94. package/src/contracts/push.contract.ts +2 -2
  95. package/src/contracts/settings.contract.ts +16 -4
  96. package/src/contracts/system.contract.ts +4 -0
  97. package/src/contracts/translator.contract.ts +3 -2
  98. package/src/contracts/usage.contract.ts +2 -2
  99. package/src/contracts/workspace.contract.ts +6 -0
  100. package/src/events.ts +85 -6
  101. package/src/index.ts +7 -1
  102. package/src/model-order.test.ts +40 -1
  103. package/src/model-order.ts +15 -0
  104. package/src/path-refs.test.ts +27 -1
  105. package/src/path-refs.ts +16 -0
  106. package/src/quick-model.test.ts +88 -0
  107. package/src/quick-model.ts +103 -0
  108. package/src/schemas.test.ts +30 -1
  109. package/src/schemas.ts +500 -42
  110. package/src/title.test.ts +130 -0
  111. package/src/title.ts +254 -0
@@ -0,0 +1,130 @@
1
+ import { expect, test } from "vitest";
2
+ import { deriveTitle } from "./title.js";
3
+
4
+ /* The name a conversation opens under. Every case here is a shape the naive rule (collapse whitespace, cut at
5
+ * 40) got wrong in a way a user notices: the title named the paste instead of the work, spent the budget on
6
+ * politeness, or stopped mid-syllable. */
7
+
8
+ test("spends the budget on the ask rather than on the politeness in front of it", () => {
9
+ // `Can you please fix the auth…` is what the naive cut produced — three of its five words are ceremony.
10
+ expect(deriveTitle("Can you please fix the auth tests?")).toBe("Fix the auth tests?");
11
+ expect(deriveTitle("Hey, can you please look at the flaky test")).toBe("Look at the flaky test");
12
+ });
13
+
14
+ test("keeps a greeting whole when peeling it off would leave a fragment", () => {
15
+ // `Hi there` is not a conversation about `there`, and `So what?` is not one about `what?` — a one-word
16
+ // remnant means the opener was carrying the sentence, so the line stands as the user wrote it.
17
+ expect(deriveTitle("Hi there")).toBe("Hi there");
18
+ expect(deriveTitle("So what?")).toBe("So what?");
19
+ });
20
+
21
+ test("titles a pasted stack trace after the sentence the user wrote around it", () => {
22
+ // The paste is evidence, not the ask. Titling from it gives every one of the day's pastes the same name.
23
+ const prompt = [
24
+ "The build is broken again.",
25
+ "",
26
+ "```",
27
+ "Error: ENOENT: no such file or directory",
28
+ " at Object.openSync (node:fs)",
29
+ "```",
30
+ ].join("\n");
31
+
32
+ expect(deriveTitle(prompt)).toBe("The build is broken again");
33
+ });
34
+
35
+ test("skips a greeting line and takes the ask from the line below it", () => {
36
+ // A line that unwinds to nothing was throat-clearing; the ask is further down. The naive rule stopped at
37
+ // the greeting and named every such conversation `Hey, quick one —`.
38
+ expect(deriveTitle("Hey, quick one —\n\nWhy does the tab title truncate mid-word?")).toBe("Why does the tab title truncate mid-word?");
39
+ });
40
+
41
+ test("keeps an ask that fits the registry's 80-character budget instead of cutting at 40", () => {
42
+ // The old 40-character clamp cut this to `In intentic-app/web when conflict…` on a fleet card that had
43
+ // room for twice that. The stored title matches what the registry and the rename input accept.
44
+ const prompt = "In intentic-app/web when conflicts happen during rebase show a resolution banner";
45
+
46
+ expect(deriveTitle(prompt)).toBe(prompt);
47
+ });
48
+
49
+ test("cuts on a word boundary instead of mid-syllable", () => {
50
+ const prompt = "Why does the tab title truncate mid-word even though the strip clearly still has unused horizontal room?";
51
+ const title = deriveTitle(prompt);
52
+ const kept = title.slice(0, -1);
53
+
54
+ expect(title.endsWith("…")).toBe(true);
55
+ // The old rule sliced at exactly 40, ending this title inside `mid-word`. Every word that survives has to
56
+ // be a whole one, so what precedes the ellipsis must stop where the prompt itself has a break.
57
+ expect(prompt.startsWith(kept)).toBe(true);
58
+ expect(prompt[kept.length]).toBe(" ");
59
+ });
60
+
61
+ test("keeps a link's last meaningful segment rather than its host and scaffolding", () => {
62
+ const title = deriveTitle("Look at https://gitlab.com/radarsu/intentic/-/merge_requests/42 and tell me what broke");
63
+
64
+ // A numeric tail alone (`42`) names nothing, so the segment above it comes along.
65
+ expect(title).toContain("merge_requests/42");
66
+ expect(title).not.toContain("gitlab.com");
67
+ });
68
+
69
+ test("collapses a deep path to its basename and leaves a shallow one alone", () => {
70
+ expect(deriveTitle("Refactor _apps/web/src/composables/chat/conversation.ts")).toBe("Refactor conversation.ts");
71
+ // Two segments already read as a place; collapsing them would lose the only context the reference carries.
72
+ expect(deriveTitle("Refactor src/foo.ts")).toBe("Refactor src/foo.ts");
73
+ });
74
+
75
+ test("ends on the first sentence when the prompt keeps going", () => {
76
+ expect(deriveTitle("Fix the flaky test. It fails about one run in five, usually on CI.")).toBe("Fix the flaky test");
77
+ });
78
+
79
+ test("does not mistake an abbreviation for the end of a sentence", () => {
80
+ // `i.e.` would otherwise cut the title to `i.e`, which names nothing at all.
81
+ expect(deriveTitle("i.e. the derived title should survive")).toBe("i.e. the derived title should survive");
82
+ });
83
+
84
+ test("leaves casing alone when the opening word carries meaning in its casing", () => {
85
+ expect(deriveTitle("useAgents leaks a watcher on unmount")).toBe("useAgents leaks a watcher on unmount");
86
+ expect(deriveTitle("fix the leaking watcher")).toBe("Fix the leaking watcher");
87
+ });
88
+
89
+ test("names a prompt that is nothing but a paste after what was pasted", () => {
90
+ // Returning empty here would render a nameless tab; the first line of the paste at least says what it is.
91
+ expect(deriveTitle("```ts\nconst x = 1;\n```")).toBe("const x = 1;");
92
+ });
93
+
94
+ test("never returns empty for a prompt that has any content at all", () => {
95
+ // A greeting with no ask behind it, and a prompt with no letters in it — both still have to name a tab.
96
+ expect(deriveTitle("Hey!")).toBe("Hey!");
97
+ expect(deriveTitle("!!!")).toBe("!!!");
98
+ });
99
+
100
+ test("reads past quoted material to the user's own words", () => {
101
+ expect(deriveTitle("> previous message\nWhat changed here?")).toBe("What changed here?");
102
+ });
103
+
104
+ test("skips past-work narration to the instruction behind it", () => {
105
+ // `We have recently added…` is the scene, not the ask — titling from it names every such conversation
106
+ // after last week's work. The instruction further in is what the conversation is about.
107
+ expect(deriveTitle("We have recently added iq map and iq deps commands. Now let's also add a health contract for the daemon.")).toBe(
108
+ "Add a health contract for the daemon",
109
+ );
110
+ expect(deriveTitle("We've just landed the fleet board.\n\nRename the Attention lane to Blocked.")).toBe("Rename the Attention lane to Blocked");
111
+ });
112
+
113
+ test("skips narration to an outright question", () => {
114
+ expect(deriveTitle("I've implemented the empty state. What should the loading state show?")).toBe("What should the loading state show?");
115
+ });
116
+
117
+ test("keeps narration when nothing behind it is unmistakably the ask", () => {
118
+ // A hazy follow-up is not worth skipping for — better a title about last week's work than one about `it`.
119
+ expect(deriveTitle("We migrated the board to SSE last week. It feels slower since.")).toBe("We migrated the board to SSE last week");
120
+ });
121
+
122
+ test("keeps a declarative problem report even when advice follows it", () => {
123
+ // Only NARRATION is skippable. A problem statement is the ask, and the imperative behind it is merely a
124
+ // pointer — `Check the broadcast path` names a step, not the conversation.
125
+ expect(deriveTitle("The fleet board flickers when agents land. Check the broadcast path.")).toBe("The fleet board flickers when agents land");
126
+ });
127
+
128
+ test("strips control characters the same way the registry's sanitiser does", () => {
129
+ expect(deriveTitle("Fix\u0000 the\u200b bug")).toBe("Fix the bug");
130
+ });
package/src/title.ts ADDED
@@ -0,0 +1,254 @@
1
+ /* HOW A CONVERSATION NAMES ITSELF from its opening prompt — the title a chat tab, a fleet card and the agent
2
+ * detail header wear from the first keystroke of a turn until something better arrives.
3
+ *
4
+ * The naive rule (collapse whitespace, cut at N) reads the prompt as if it were a headline, and opening
5
+ * prompts are not headlines. They are pasted stack traces, links dropped in with two words of context,
6
+ * politeness that eats half the budget before the ask begins, and multi-line asks whose first line is throat-
7
+ * clearing. Cutting those at 40 characters yields `Error: ENOENT: no such file or directo…` — a title that
8
+ * names the paste rather than the work, and that reads identically for every one of the day's five pastes.
9
+ *
10
+ * So the prompt is read as prose FIRST and cut second: quoted material and fenced code are not the ask, the
11
+ * opener is not the ask, and a reference is worth only its last segment. What survives is the earliest line
12
+ * that still says something, cut on a sentence or a word rather than mid-syllable.
13
+ *
14
+ * Both sides derive: the browser names a conversation the instant it sends (conversation.ts), and the daemon
15
+ * names a turn that arrived without one — an automation, a Discord message, a webchat visitor (agents-
16
+ * registry.ts). One rule, because two would let the same prompt open under two different names depending on
17
+ * where it entered. Nothing here calls a model: the title has to exist before the first frame comes back.
18
+ * (A model DOES get its say later — the daemon's title-summary pass renames a still-derived title once the
19
+ * first turn has an answer to read — but that is a promotion over this title, never a replacement for it.) */
20
+
21
+ // The registry's title budget (agents-registry MAX_TITLE_LENGTH, the rename input's maxlength) — the widest
22
+ // any surface stores. Every surface truncates in CSS to its own width, so the clamp here is a storage cap,
23
+ // not a display rule: cutting shorter than the widest surface (a full-width fleet card) would throw away
24
+ // signal that surface has room to show, and no narrow surface is hurt because its CSS cuts first anyway.
25
+ const MAX_LENGTH = 80;
26
+ // Below this a sentence-shaped cut is more likely an abbreviation (`e.g.`, `i.e.`) than a sentence, so the
27
+ // length clamp takes over.
28
+ const MIN_SENTENCE = 12;
29
+ // A word-boundary cut this far back strands a fragment; better to cut mid-word than to title a conversation
30
+ // with two characters and an ellipsis.
31
+ const MIN_WORD_CUT = MAX_LENGTH * 0.6;
32
+
33
+ // Markdown's two fence syntaxes, and the quote marker. Both open and close on the same shape, so a fence line
34
+ // toggles rather than matching a pair — an UNTERMINATED fence (a paste the user never closed) then swallows
35
+ // the rest of the prompt, which is what it visually does too.
36
+ const FENCE = /^\s*(?:```|~~~)/;
37
+ const QUOTE = /^\s*>/;
38
+
39
+ // The ways an ask opens without starting. Stripped repeatedly, so `Hey, can you please …` unwinds one layer
40
+ // per pass; a line that unwinds to nothing was pure throat-clearing and the next line carries the ask.
41
+ const OPENERS: readonly RegExp[] = [
42
+ /^(?:hey|hi|hello|yo|ok|okay|so|well|also|btw|now|next|then)\b[\s,.:;!—–-]*/i,
43
+ /^(?:please|pls)\b[\s,.:;!—–-]*/i,
44
+ /^(?:can|could|would|will)\s+(?:you|u|we)\b\s*(?:please|pls)?[\s,.:;!—–-]*/i,
45
+ /^i(?:'d|\s+would)?\s+(?:want|need|like)\s+(?:you\s+)?to\b[\s,.:;!—–-]*/i,
46
+ /^(?:let'?s|lets)\b[\s,.:;!—–-]*/i,
47
+ /^we\s+(?:need|have|ought)\s+to\b[\s,.:;!—–-]*/i,
48
+ /^(?:quick\s+(?:one|question|q)|one\s+more\s+thing)\b[\s,.:;!—–-]*/i,
49
+ ];
50
+
51
+ // A sentence ends on terminal punctuation that is actually followed by a break — which is what holds `v1.2`
52
+ // and `foo.ts` together while still ending `Why is it red?`.
53
+ const SENTENCE_END = /[.!?](?=\s|$)/;
54
+
55
+ /* A report of work already done — `We have recently added…`, `I've just landed…`, `we were refactoring…`. The
56
+ * one first-sentence shape that is reliably CONTEXT rather than the ask: prompts that open on it set the scene
57
+ * for an instruction further down, and titling from the scene names every such conversation after last week's
58
+ * work instead of this one's.
59
+ *
60
+ * Detected as we/I, an optional aux chain, then a verb in past or progressive form: the regular `…ed`/`…ing`
61
+ * endings plus the irregular pasts that actually appear in prompts about software. The lookahead excludes the
62
+ * handful of present-tense verbs the `…ed` ending would swallow (`we need…`, `we embed…`); `…ing` wants three
63
+ * letters in front so `we ping`/`we bring` stay present tense. */
64
+ const NARRATION =
65
+ /^(?:(?:recently|previously|earlier|today|yesterday|lately)[\s,]+)?(?:we|i)(?:'ve|'d|'m|'re)?(?:\s+(?:have|had|am|are|was|were|been|just|recently|already|earlier|previously|also|finally|now|currently|still))*\s+(?!(?:need|feed|speed|shed|heed|breed|bleed|embed|proceed|exceed|succeed)\b)(?:\w+ed|\w{3,}ing|built|wrote|rewrote|made|found|saw|thought|began|got|kept|put|set|sent|split|ran|did|redid|went|came|gave|held|hit|cut|let|read|understood|broke|chose|became|brought|spent|meant|lost|forgot|rebuilt|told|taught|stood|drew|grew|knew|threw|took|left|felt|hid)\b/i;
66
+
67
+ // The ask read as an instruction: a sentence leading with a bare verb of the kind coding asks lead with. A
68
+ // curated list rather than grammar — English hands out noun/verb ambiguity too freely for anything cleverer.
69
+ const IMPERATIVE =
70
+ /^(?:add|fix|make|implement|create|build|write|rewrite|refactor|rename|remove|delete|drop|update|change|convert|migrate|move|extract|split|merge|rebase|revert|restore|wire|connect|integrate|support|handle|improve|clean|simplify|redesign|rework|rethink|investigate|analyze|analyse|debug|find|figure|check|verify|test|run|try|document|describe|explain|propose|design|draft|prepare|stop|prevent|ensure|allow|enable|disable|introduce|replace|swap|optimize|optimise|reduce|bump|upgrade|deploy|ship|release|adjust|tweak|polish|finish|complete|continue|extend|unify|dedupe|deduplicate|cache|persist|expose|hide|show|render|port|automate|wrap|inline|audit|review|profile|measure|instrument|validate|parse|generate|turn|look|think|come|consider|help|start|use|keep|avoid|teach|harden|tighten|localize|localise|translate)\b/i;
71
+
72
+ // An unmistakable ask: an instruction, or a question asked outright. Anything less deliberate is not worth
73
+ // skipping a sentence for.
74
+ const isAsk = (sentence: string): boolean => sentence.endsWith("?") || IMPERATIVE.test(sentence);
75
+
76
+ const collapse = (text: string): string =>
77
+ text
78
+ .replaceAll(/[\p{Cc}\p{Cf}]+/gu, " ")
79
+ .replaceAll(/\s+/g, " ")
80
+ .trim();
81
+
82
+ // The prompt minus everything the user pasted rather than wrote. Fenced blocks and quoted blocks are evidence
83
+ // attached to the ask, never the ask itself.
84
+ const proseLines = (prompt: string): string[] => {
85
+ const lines: string[] = [];
86
+ let fenced = false;
87
+ for (const line of prompt.split(/\r?\n/)) {
88
+ if (FENCE.test(line)) {
89
+ fenced = !fenced;
90
+ continue;
91
+ }
92
+ if (!fenced && !QUOTE.test(line)) {
93
+ lines.push(line);
94
+ }
95
+ }
96
+ return lines;
97
+ };
98
+
99
+ /* Peel the openers off a line, but only keep the peeling when something with shape is left underneath.
100
+ *
101
+ * Three outcomes, and the difference between them is the whole point. A remnant of SEVERAL words is the ask —
102
+ * `Can you please fix the auth tests?` was carrying `fix the auth tests?`. A remnant of NOTHING was pure
103
+ * greeting (`Hey, quick one —`), and returning empty is how the caller learns to read the next line instead.
104
+ * A remnant of ONE word means the opener was load-bearing — `Hi there` is not a conversation about `there`,
105
+ * and `So what?` is not one about `what?` — so the line stands as written. */
106
+ const withoutOpener = (line: string): string => {
107
+ let text = line;
108
+ for (let pass = 0; pass < OPENERS.length; pass++) {
109
+ const stripped = OPENERS.reduce((current, opener) => current.replace(opener, ""), text);
110
+ if (stripped === text) {
111
+ break;
112
+ }
113
+ text = stripped;
114
+ }
115
+ const remnant = text.trim();
116
+ if (text === line || remnant === "") {
117
+ return text;
118
+ }
119
+ return remnant.includes(" ") ? text : line;
120
+ };
121
+
122
+ // A URL is worth its last meaningful segment: the host when there is no path, and the segment before a purely
123
+ // numeric one so an issue link reads `merge_requests/42` rather than `42`.
124
+ const shortUrl = (token: string): string => {
125
+ let url: URL;
126
+ try {
127
+ url = new URL(token);
128
+ } catch {
129
+ return token;
130
+ }
131
+ const segments = url.pathname.split("/").filter((segment) => segment !== "");
132
+ const last = segments.at(-1);
133
+ if (last === undefined) {
134
+ return url.hostname.replace(/^www\./, "");
135
+ }
136
+ const previous = segments.at(-2);
137
+ return /^\d+$/.test(last) && previous !== undefined ? `${previous}/${last}` : last;
138
+ };
139
+
140
+ // A path is worth its basename once it is deep enough that the lead is scaffolding — `src/foo.ts` already
141
+ // reads as a place, `_apps/web/src/composables/chat/conversation.ts` reads as a wall.
142
+ const shortPath = (token: string): string => {
143
+ const segments = token.replace(/^@/, "").split("/");
144
+ return segments.length > 2 ? (segments.findLast((segment) => segment !== "") ?? token) : token;
145
+ };
146
+
147
+ // Trailing punctuation belongs to the sentence, not to the reference inside it, so it is set aside across the
148
+ // shortening and put back — `see _apps/web/src/foo.ts,` collapses to `see foo.ts,`.
149
+ const shortReference = (token: string): string => {
150
+ const match = /^(.*?)([\s,.;:!?)\]]*)$/s.exec(token);
151
+ const core = match?.[1] ?? token;
152
+ const tail = match?.[2] ?? "";
153
+ if (/^https?:\/\//i.test(core)) {
154
+ return `${shortUrl(core)}${tail}`;
155
+ }
156
+ if (core.includes("/")) {
157
+ return `${shortPath(core)}${tail}`;
158
+ }
159
+ return token;
160
+ };
161
+
162
+ const collapseReferences = (text: string): string => text.split(" ").map(shortReference).join(" ");
163
+
164
+ const firstSentence = (text: string): string => {
165
+ const end = text.search(SENTENCE_END);
166
+ if (end < MIN_SENTENCE) {
167
+ return text;
168
+ }
169
+ // A question mark or a bang carries tone worth keeping; a full stop on a title is just noise.
170
+ const terminator = text[end];
171
+ return terminator === "." ? text.slice(0, end) : text.slice(0, end + 1);
172
+ };
173
+
174
+ // Every sentence in turn, terminators handled as in firstSentence — the walk the ask-scan below reads. No
175
+ // abbreviation guard here: a fragment split off at an `e.g.` never leads with an imperative, so it simply
176
+ // loses the scan rather than corrupting it.
177
+ const sentencesOf = (text: string): string[] => {
178
+ const parts: string[] = [];
179
+ let rest = text;
180
+ while (rest !== "") {
181
+ const end = rest.search(SENTENCE_END);
182
+ if (end === -1) {
183
+ parts.push(rest);
184
+ break;
185
+ }
186
+ parts.push(rest[end] === "." ? rest.slice(0, end) : rest.slice(0, end + 1));
187
+ rest = rest.slice(end + 1).trimStart();
188
+ }
189
+ return parts;
190
+ };
191
+
192
+ const clamped = (text: string): string => {
193
+ if (text.length <= MAX_LENGTH) {
194
+ return text;
195
+ }
196
+ const cut = text.slice(0, MAX_LENGTH);
197
+ const boundary = cut.lastIndexOf(" ");
198
+ const kept = boundary >= MIN_WORD_CUT ? cut.slice(0, boundary) : cut;
199
+ return `${kept.replace(/[\s,;:—–-]+$/, "")}…`;
200
+ };
201
+
202
+ // Sentence case, but only when the opening word is plainly lowercase prose. An identifier (`useAgents`), a
203
+ // path (`src/foo`) or an abbreviation (`e.g`) means something by its casing and is left alone.
204
+ const capitalized = (text: string): string => {
205
+ const [first = ""] = text.split(" ", 1);
206
+ if (!/^[a-z]/.test(first) || /[A-Z/.\\]/.test(first)) {
207
+ return text;
208
+ }
209
+ return `${text[0]?.toUpperCase() ?? ""}${text.slice(1)}`;
210
+ };
211
+
212
+ /* Split a plan's markdown into its leading heading and the remaining body.
213
+ *
214
+ * The heading is the one place in a turn where an agent writes a name for the WHOLE job rather than for a step
215
+ * — a task checklist describes `Read the failing test`, `Fix the assertion`, `Run the suite`, none of which is
216
+ * what the conversation is about, whereas a plan opens `## Fix the flaky auth tests`. That makes it the only
217
+ * authored line in the stream worth promoting to a title, which is why this lives here rather than next to the
218
+ * plan card that also renders it: the browser titles its own tabs from it and the daemon titles fleet cards. */
219
+ export const planParts = (text: string): { title?: string; body: string } => {
220
+ const match = /^\s*#{1,6}\s+(.+)/.exec(text);
221
+ if (match === null) {
222
+ return { body: text };
223
+ }
224
+ return { title: match[1]!.trim(), body: text.slice(match.index + match[0].length).trimStart() };
225
+ };
226
+
227
+ /* Name a conversation after the prompt that opened it. Never empty for a non-empty prompt: a paste with no
228
+ * prose around it still titles as its own first line, which at least names what was pasted. */
229
+ export const deriveTitle = (prompt: string): string => {
230
+ const prose = proseLines(prompt)
231
+ .map(collapse)
232
+ .filter((line) => line !== "");
233
+ // Lines that survive opener-stripping with letters in them. The first is the title's default; the rest
234
+ // only matter when that default turns out to be scene-setting.
235
+ const lines = prose.map(withoutOpener).filter((line) => /\p{L}/u.test(line));
236
+ const primary = lines[0];
237
+ if (primary !== undefined) {
238
+ /* A first sentence that reports work already done is the scene, not the ask — `We have recently added
239
+ * iq map. Now let's expose health.` is a conversation about exposing health. Skipped only for an
240
+ * unmistakable ask further in (an instruction or an outright question); anything hazier and the
241
+ * narration stands, exactly as every non-narrative opening always does. */
242
+ const head = firstSentence(primary);
243
+ const ask = NARRATION.test(head)
244
+ ? lines
245
+ .flatMap(sentencesOf)
246
+ .map(withoutOpener)
247
+ .find((sentence) => sentence !== head && isAsk(sentence))
248
+ : undefined;
249
+ return capitalized(clamped(firstSentence(collapseReferences(ask ?? primary))));
250
+ }
251
+ // Everything the user sent was code, a quote, or a greeting — title it as what it is rather than blank.
252
+ const fallback = prose[0] ?? collapse(prompt.replaceAll(/^\s*(?:```|~~~).*$/gm, ""));
253
+ return clamped(collapseReferences(fallback === "" ? collapse(prompt) : fallback));
254
+ };