@baique/pi-web-sky 0.1.35 → 0.1.36

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 (294) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +16 -16
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +1 -1
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/app-update/route.js +1 -1
  23. package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
  24. package/.next/server/app/api/cwd/validate/route.js +1 -1
  25. package/.next/server/app/api/default-cwd/route.js +1 -1
  26. package/.next/server/app/api/file-index/route.js +1 -1
  27. package/.next/server/app/api/files/[...path]/route.js +1 -1
  28. package/.next/server/app/api/mcp/route.js +1 -1
  29. package/.next/server/app/api/models/route.js +1 -1
  30. package/.next/server/app/api/plugins/route.js +1 -1
  31. package/.next/server/app/api/project-trust/route.js +1 -1
  32. package/.next/server/app/api/search/route.js +1 -1
  33. package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
  34. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  35. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
  36. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  37. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  38. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  39. package/.next/server/app/api/sessions/route.js +1 -1
  40. package/.next/server/app/api/skills/check/route.js +1 -1
  41. package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
  42. package/.next/server/app/api/skills/install/route.js +1 -1
  43. package/.next/server/app/api/skills/route.js +2 -31
  44. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/update/route.js +1 -1
  46. package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/route.js +1 -1
  48. package/.next/server/app/index.html +1 -1
  49. package/.next/server/app/index.rsc +5 -5
  50. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  51. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  52. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/page.js +2 -2
  54. package/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app-paths-manifest.json +16 -16
  56. package/.next/server/chunks/2404.js +1 -1
  57. package/.next/server/chunks/3500.js +30 -0
  58. package/.next/server/chunks/4226.js +2 -2
  59. package/.next/server/chunks/542.js +10 -5
  60. package/.next/server/chunks/5794.js +1 -1
  61. package/.next/server/chunks/6429.js +1 -1
  62. package/.next/server/chunks/898.js +1 -1
  63. package/.next/server/middleware-build-manifest.js +1 -1
  64. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  65. package/.next/server/pages/404.html +1 -1
  66. package/.next/server/pages/500.html +1 -1
  67. package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
  68. package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
  69. package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
  70. package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
  71. package/.next/static/css/422c7dc0faa04d89.css +1 -0
  72. package/.next/trace +8 -9
  73. package/.next/trace-build +1 -1
  74. package/bin/pi-web-sky.js +15 -20
  75. package/lib/agent-client.test.mjs +64 -0
  76. package/lib/agent-client.ts +53 -0
  77. package/lib/agent-event-connection.test.mjs +193 -0
  78. package/lib/agent-event-connection.ts +202 -0
  79. package/lib/agent-event-stream.test.mjs +155 -0
  80. package/lib/agent-event-stream.ts +132 -0
  81. package/lib/agent-event-wire.test.mjs +278 -0
  82. package/lib/agent-event-wire.ts +107 -0
  83. package/lib/agent-phase.ts +24 -0
  84. package/lib/allowed-roots.ts +31 -0
  85. package/lib/ansi.test.mjs +52 -0
  86. package/lib/ansi.ts +211 -0
  87. package/lib/api-types.ts +139 -0
  88. package/lib/app-update.test.mjs +27 -0
  89. package/lib/app-update.ts +28 -0
  90. package/lib/atomic-file.test.mjs +37 -0
  91. package/lib/atomic-file.ts +34 -0
  92. package/lib/audit-session.ts +235 -0
  93. package/lib/bash-output.test.mjs +63 -0
  94. package/lib/bash-output.ts +53 -0
  95. package/lib/bg-image.ts +129 -0
  96. package/lib/board-events.ts +83 -0
  97. package/lib/board-purge.test.mjs +66 -0
  98. package/lib/board-purge.ts +65 -0
  99. package/lib/board-search.test.mjs +110 -0
  100. package/lib/board-search.ts +63 -0
  101. package/lib/board-store.test.mjs +542 -0
  102. package/lib/board-store.ts +794 -0
  103. package/lib/board-types.ts +96 -0
  104. package/lib/board-utils.ts +17 -0
  105. package/lib/bounded-form-data.test.mjs +54 -0
  106. package/lib/bounded-form-data.ts +49 -0
  107. package/lib/browser-notifications.test.mjs +167 -0
  108. package/lib/browser-notifications.ts +104 -0
  109. package/lib/chat-lazy-load.test.mjs +140 -0
  110. package/lib/chat-lazy-load.ts +64 -0
  111. package/lib/clipboard-paths.test.mjs +140 -0
  112. package/lib/clipboard-paths.ts +192 -0
  113. package/lib/clipboard.ts +18 -0
  114. package/lib/compaction-summary.test.mjs +44 -0
  115. package/lib/compaction-summary.ts +34 -0
  116. package/lib/custom-ui-terminal.test.mjs +32 -0
  117. package/lib/custom-ui-terminal.ts +27 -0
  118. package/lib/deepseek-pricing.test.mjs +120 -0
  119. package/lib/deepseek-pricing.ts +71 -0
  120. package/lib/directory-browser.test.mjs +60 -0
  121. package/lib/directory-browser.ts +83 -0
  122. package/lib/draft-stash.test.mjs +91 -0
  123. package/lib/draft-stash.ts +102 -0
  124. package/lib/draft-store.ts +105 -0
  125. package/lib/dropdown-direction.ts +27 -0
  126. package/lib/extension-command.test.mjs +63 -0
  127. package/lib/extension-command.ts +81 -0
  128. package/lib/file-access.test.mjs +31 -0
  129. package/lib/file-access.ts +59 -0
  130. package/lib/file-dirent.test.mjs +56 -0
  131. package/lib/file-dirent.ts +17 -0
  132. package/lib/file-explorer-state.test.mjs +63 -0
  133. package/lib/file-explorer-state.ts +36 -0
  134. package/lib/file-fuzzy.test.mjs +29 -0
  135. package/lib/file-fuzzy.ts +189 -0
  136. package/lib/file-links.test.mjs +92 -0
  137. package/lib/file-links.ts +148 -0
  138. package/lib/file-paths.ts +43 -0
  139. package/lib/file-types.test.mjs +34 -0
  140. package/lib/file-types.ts +73 -0
  141. package/lib/file-upload.test.mjs +70 -0
  142. package/lib/file-upload.ts +59 -0
  143. package/lib/file-viewer-state.test.mjs +20 -0
  144. package/lib/file-viewer-state.ts +15 -0
  145. package/lib/frontmatter.test.mjs +55 -0
  146. package/lib/frontmatter.ts +87 -0
  147. package/lib/git-changes.test.mjs +52 -0
  148. package/lib/git-changes.ts +232 -0
  149. package/lib/git-status.ts +47 -0
  150. package/lib/git-types.ts +29 -0
  151. package/lib/http-dispatcher.test.mjs +83 -0
  152. package/lib/http-dispatcher.ts +86 -0
  153. package/lib/i18n/format.test.mjs +19 -0
  154. package/lib/i18n/format.ts +60 -0
  155. package/lib/i18n/messages/en.ts +662 -0
  156. package/lib/i18n/messages/zh-CN.ts +661 -0
  157. package/lib/i18n/registry.test.mjs +32 -0
  158. package/lib/i18n/registry.ts +43 -0
  159. package/lib/i18n/types.ts +15 -0
  160. package/lib/image-attachments.test.mjs +27 -0
  161. package/lib/image-attachments.ts +56 -0
  162. package/lib/initial-navigation.test.mjs +65 -0
  163. package/lib/initial-navigation.ts +15 -0
  164. package/lib/markdown.test.mjs +104 -0
  165. package/lib/markdown.ts +347 -0
  166. package/lib/message-display.test.mjs +130 -0
  167. package/lib/message-display.ts +47 -0
  168. package/lib/model-catalog.test.mjs +327 -0
  169. package/lib/model-catalog.ts +414 -0
  170. package/lib/model-discovery-auth.ts +58 -0
  171. package/lib/model-discovery.test.mjs +50 -0
  172. package/lib/model-discovery.ts +75 -0
  173. package/lib/model-id-preference.ts +46 -0
  174. package/lib/model-scope.test.mjs +193 -0
  175. package/lib/model-scope.ts +198 -0
  176. package/lib/models-cache.test.mjs +122 -0
  177. package/lib/models-cache.ts +90 -0
  178. package/lib/models-config-store.test.mjs +200 -0
  179. package/lib/models-config-store.ts +84 -0
  180. package/lib/next-config-esm.test.mjs +14 -0
  181. package/lib/next-config.test.mjs +13 -0
  182. package/lib/node-version.test.mjs +33 -0
  183. package/lib/normalize.test.mjs +54 -0
  184. package/lib/normalize.ts +63 -0
  185. package/lib/npx.ts +62 -0
  186. package/lib/panel-layout.test.mjs +56 -0
  187. package/lib/panel-layout.ts +52 -0
  188. package/lib/patch.test.mjs +110 -0
  189. package/lib/patch.ts +134 -0
  190. package/lib/path-security.ts +42 -0
  191. package/lib/paths.test.mjs +58 -0
  192. package/lib/paths.ts +71 -0
  193. package/lib/pi-types.ts +183 -0
  194. package/lib/pi-web-options.test.mjs +63 -0
  195. package/lib/process-lifecycle.test.mjs +83 -0
  196. package/lib/project-command-env.test.mjs +298 -0
  197. package/lib/project-command-env.ts +129 -0
  198. package/lib/project-groups.test.mjs +60 -0
  199. package/lib/project-groups.ts +53 -0
  200. package/lib/project-identity.test.mjs +37 -0
  201. package/lib/project-identity.ts +26 -0
  202. package/lib/project-tree.test.mjs +115 -0
  203. package/lib/project-tree.ts +176 -0
  204. package/lib/project-trust.test.mjs +117 -0
  205. package/lib/project-trust.ts +48 -0
  206. package/lib/prompt-recovery.test.mjs +33 -0
  207. package/lib/prompt-recovery.ts +47 -0
  208. package/lib/provider-api-key-route.test.mjs +12 -0
  209. package/lib/provider-credential-store.test.mjs +126 -0
  210. package/lib/provider-credential-store.ts +114 -0
  211. package/lib/provider-listing-runtime.ts +37 -0
  212. package/lib/provider-listing.test.mjs +114 -0
  213. package/lib/provider-listing.ts +118 -0
  214. package/lib/provider-services.ts +64 -0
  215. package/lib/request-security.test.mjs +256 -0
  216. package/lib/request-security.ts +136 -0
  217. package/lib/rpc-manager-shutdown.test.mjs +372 -0
  218. package/lib/rpc-manager-widgets.test.mjs +342 -0
  219. package/lib/rpc-manager.test.mjs +139 -0
  220. package/lib/rpc-manager.ts +1703 -0
  221. package/lib/rpc-session-info.test.mjs +93 -0
  222. package/lib/session-delete.test.mjs +53 -0
  223. package/lib/session-delete.ts +86 -0
  224. package/lib/session-file-references-core.ts +82 -0
  225. package/lib/session-file-references.test.mjs +91 -0
  226. package/lib/session-file-references.ts +29 -0
  227. package/lib/session-path.test.mjs +24 -0
  228. package/lib/session-path.ts +11 -0
  229. package/lib/session-reader.pagination.test.mjs +115 -0
  230. package/lib/session-reader.test.mjs +446 -0
  231. package/lib/session-reader.ts +548 -0
  232. package/lib/session-resolve.test.mjs +65 -0
  233. package/lib/session-row-context-menu.test.mjs +46 -0
  234. package/lib/session-row-context-menu.ts +29 -0
  235. package/lib/session-scanner.test.mjs +151 -0
  236. package/lib/session-scanner.ts +238 -0
  237. package/lib/session-search.test.mjs +173 -0
  238. package/lib/session-search.ts +284 -0
  239. package/lib/session-stats.ts +26 -0
  240. package/lib/session-timing.test.mjs +107 -0
  241. package/lib/session-timing.ts +45 -0
  242. package/lib/session-title.test.mjs +284 -0
  243. package/lib/session-title.ts +255 -0
  244. package/lib/skill-frontmatter.test.mjs +75 -0
  245. package/lib/skill-frontmatter.ts +40 -0
  246. package/lib/skill-lock.test.mjs +182 -0
  247. package/lib/skill-lock.ts +146 -0
  248. package/lib/skill-updates.test.mjs +182 -0
  249. package/lib/skill-updates.ts +263 -0
  250. package/lib/skills-service.ts +16 -0
  251. package/lib/slash-display.test.mjs +122 -0
  252. package/lib/slash-display.ts +30 -0
  253. package/lib/sqlite-db.test.mjs +140 -0
  254. package/lib/sqlite-db.ts +205 -0
  255. package/lib/startup-preferences.test.mjs +130 -0
  256. package/lib/startup-preferences.ts +65 -0
  257. package/lib/streaming-message.test.mjs +212 -0
  258. package/lib/streaming-message.ts +151 -0
  259. package/lib/subagent-widget.test.mjs +87 -0
  260. package/lib/subagent-widget.ts +109 -0
  261. package/lib/sync-room-server.mjs +137 -0
  262. package/lib/task-card-store.test.mjs +309 -0
  263. package/lib/task-card-store.ts +559 -0
  264. package/lib/task-scheduler.ts +498 -0
  265. package/lib/task-store.test.mjs +216 -0
  266. package/lib/task-store.ts +271 -0
  267. package/lib/terminal-input.test.mjs +46 -0
  268. package/lib/terminal-input.ts +63 -0
  269. package/lib/terminal-manager.test.mjs +71 -0
  270. package/lib/terminal-manager.ts +245 -0
  271. package/lib/tool-execution-progress.test.mjs +32 -0
  272. package/lib/tool-execution-progress.ts +29 -0
  273. package/lib/tool-names.ts +25 -0
  274. package/lib/tool-preset-preference.test.mjs +72 -0
  275. package/lib/tool-preset-preference.ts +41 -0
  276. package/lib/tool-presets.test.mjs +56 -0
  277. package/lib/tool-presets.ts +42 -0
  278. package/lib/turn-written-files.test.mjs +137 -0
  279. package/lib/turn-written-files.ts +60 -0
  280. package/lib/types.ts +349 -0
  281. package/lib/wallpaper-settings.ts +191 -0
  282. package/lib/web-auth.test.mjs +50 -0
  283. package/lib/web-auth.ts +45 -0
  284. package/lib/workspace-memory.test.mjs +129 -0
  285. package/lib/workspace-memory.ts +98 -0
  286. package/lib/worktree.test.mjs +50 -0
  287. package/lib/worktree.ts +250 -0
  288. package/package.json +3 -1
  289. package/server.mjs +34 -0
  290. package/.next/server/chunks/7513.js +0 -30
  291. package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
  292. package/.next/static/css/cf25164cb7c04d95.css +0 -1
  293. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
  294. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
@@ -0,0 +1,284 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
4
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
5
+ import { createJiti } from "jiti";
6
+
7
+ const jiti = createJiti(import.meta.url);
8
+ const {
9
+ appendTitleRequestToTrailingUser,
10
+ buildSessionTitleAgentOptions,
11
+ generateSessionTitle,
12
+ parseGeneratedSessionTitle,
13
+ sanitizeTitleMessages,
14
+ } = await jiti.import("./session-title.ts");
15
+
16
+ function assistantMessage(text) {
17
+ return {
18
+ role: "assistant",
19
+ content: [{ type: "text", text }],
20
+ api: "test",
21
+ provider: "test",
22
+ model: "test-model",
23
+ usage: {
24
+ input: 0,
25
+ output: 0,
26
+ cacheRead: 0,
27
+ cacheWrite: 0,
28
+ totalTokens: 0,
29
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
30
+ },
31
+ stopReason: "stop",
32
+ timestamp: Date.now(),
33
+ };
34
+ }
35
+
36
+ test("cleans common session title response wrappers", () => {
37
+ assert.equal(parseGeneratedSessionTitle("标题:修复 SSE 重连。"), "修复 SSE 重连");
38
+ assert.equal(parseGeneratedSessionTitle('```json\n{"title":"整理 Session 文件夹"}\n```'), "整理 Session 文件夹");
39
+ assert.equal(parseGeneratedSessionTitle('"Improve worktree session grouping"'), "Improve worktree session grouping");
40
+ });
41
+
42
+ test("rejects responses without a usable title", () => {
43
+ assert.throws(() => parseGeneratedSessionTitle("```\n---\n```"), /usable session title/);
44
+ });
45
+
46
+ test("folds the title request into a trailing user message without mutating the source", () => {
47
+ const source = [
48
+ { role: "assistant", content: [], timestamp: 1 },
49
+ { role: "user", content: [{ type: "text", text: "Fix the running-session race" }], timestamp: 2 },
50
+ ];
51
+
52
+ const prepared = appendTitleRequestToTrailingUser(source);
53
+
54
+ assert.deepEqual(prepared.map((message) => message.role), ["assistant", "user"]);
55
+ assert.match(prepared[1].content.at(-1).text, /Create a concise title/);
56
+ assert.equal(source[1].content.length, 1);
57
+ assert.notEqual(prepared[1], source[1]);
58
+ });
59
+
60
+ test("leaves a completed conversation unchanged before adding the title turn", () => {
61
+ const source = [
62
+ { role: "user", content: "Fix it", timestamp: 1 },
63
+ { role: "assistant", content: [], timestamp: 2 },
64
+ ];
65
+
66
+ assert.equal(appendTitleRequestToTrailingUser(source), source);
67
+ });
68
+
69
+ test("waits for the source reply before sending the title prompt", async () => {
70
+ let sourceReplyFinished = false;
71
+ let providerRoles;
72
+ const sourceAgent = {
73
+ state: {
74
+ systemPrompt: "system",
75
+ model: { provider: "test", id: "test-model" },
76
+ thinkingLevel: "off",
77
+ tools: [],
78
+ messages: [{ role: "user", content: "Implement auto name", timestamp: 1 }],
79
+ },
80
+ waitForIdle: async () => {
81
+ sourceAgent.state.messages.push(assistantMessage("The implementation is complete"));
82
+ sourceReplyFinished = true;
83
+ },
84
+ convertToLlm: (messages) => messages,
85
+ streamFunction: (_model, context) => {
86
+ assert.equal(sourceReplyFinished, true);
87
+ providerRoles = context.messages.map((message) => message.role);
88
+ const stream = createAssistantMessageEventStream();
89
+ queueMicrotask(() => {
90
+ stream.push({
91
+ type: "done",
92
+ reason: "stop",
93
+ message: assistantMessage("Wait for Complete Agent Reply"),
94
+ });
95
+ });
96
+ return stream;
97
+ },
98
+ sessionId: "source-session-id",
99
+ };
100
+
101
+ const result = await generateSessionTitle({ agent: sourceAgent });
102
+
103
+ assert.equal(result.title, "Wait for Complete Agent Reply");
104
+ assert.deepEqual(providerRoles, ["user", "assistant", "user"]);
105
+ });
106
+
107
+ test("generates a title when compaction removed all literal user messages", async () => {
108
+ let providerMessages;
109
+ const sourceAgent = {
110
+ state: {
111
+ systemPrompt: "system",
112
+ model: { provider: "test", id: "test-model" },
113
+ thinkingLevel: "off",
114
+ tools: [],
115
+ messages: [
116
+ {
117
+ role: "compactionSummary",
118
+ summary: "The user asked to fix title generation after compaction.",
119
+ tokensBefore: 100_000,
120
+ timestamp: 1,
121
+ },
122
+ assistantMessage("The implementation is complete"),
123
+ ],
124
+ },
125
+ waitForIdle: async () => {},
126
+ convertToLlm,
127
+ streamFunction: (_model, context) => {
128
+ providerMessages = context.messages;
129
+ const stream = createAssistantMessageEventStream();
130
+ queueMicrotask(() => {
131
+ stream.push({
132
+ type: "done",
133
+ reason: "stop",
134
+ message: assistantMessage("Title Generation After Compaction"),
135
+ });
136
+ });
137
+ return stream;
138
+ },
139
+ sessionId: "source-session-id",
140
+ };
141
+
142
+ const result = await generateSessionTitle({ agent: sourceAgent });
143
+
144
+ assert.equal(result.title, "Title Generation After Compaction");
145
+ assert.deepEqual(providerMessages.map((message) => message.role), ["user", "assistant", "user"]);
146
+ assert.match(providerMessages[0].content[0].text, /fix title generation after compaction/);
147
+ });
148
+
149
+ test("temporary title agent preserves the provider-facing prefix", async () => {
150
+ const model = { provider: "test", id: "cached-model" };
151
+ const messages = [{ role: "user", content: [{ type: "text", text: "Fix it" }] }];
152
+ const originalExecute = async () => ({ content: [], details: {} });
153
+ const tools = [{
154
+ name: "read",
155
+ label: "read",
156
+ description: "Read a file",
157
+ parameters: { type: "object", properties: {} },
158
+ execute: originalExecute,
159
+ }];
160
+ const convertToLlm = (value) => value;
161
+ const transformContext = async (value) => value;
162
+ const streamFunction = () => { throw new Error("not called"); };
163
+ const source = {
164
+ state: {
165
+ systemPrompt: "cached system prompt",
166
+ model,
167
+ thinkingLevel: "high",
168
+ tools,
169
+ messages,
170
+ },
171
+ convertToLlm,
172
+ transformContext,
173
+ streamFunction,
174
+ steeringMode: "one-at-a-time",
175
+ followUpMode: "one-at-a-time",
176
+ sessionId: "source-session-id",
177
+ transport: "sse",
178
+ toolExecution: "parallel",
179
+ };
180
+
181
+ const options = buildSessionTitleAgentOptions(source);
182
+
183
+ assert.equal(options.initialState.systemPrompt, source.state.systemPrompt);
184
+ assert.equal(options.initialState.model, model);
185
+ assert.equal(options.initialState.thinkingLevel, "high");
186
+ assert.equal(options.initialState.messages, messages);
187
+ assert.equal(options.convertToLlm, convertToLlm);
188
+ assert.equal(options.transformContext, transformContext);
189
+ assert.equal(options.streamFn, streamFunction);
190
+ assert.equal(options.sessionId, "source-session-id");
191
+ const withoutExecute = (tool) => Object.fromEntries(
192
+ Object.entries(tool).filter(([key]) => key !== "execute"),
193
+ );
194
+ assert.deepEqual(
195
+ options.initialState.tools.map(withoutExecute),
196
+ tools.map(withoutExecute),
197
+ );
198
+ assert.notEqual(options.initialState.tools[0].execute, originalExecute);
199
+ await assert.rejects(
200
+ options.initialState.tools[0].execute("call", {}, undefined, undefined),
201
+ /cannot be executed/,
202
+ );
203
+ });
204
+
205
+ test("keeps only tool calls with adjacent matching results", () => {
206
+ const messages = [
207
+ { role: "user", content: "inspect both files", timestamp: 1 },
208
+ {
209
+ ...assistantMessage("Inspecting files"),
210
+ content: [
211
+ { type: "text", text: "Inspecting files" },
212
+ { type: "toolCall", id: "call-complete", name: "read", arguments: { path: "a.txt" } },
213
+ { type: "toolCall", id: "call-incomplete", name: "read", arguments: { path: "b.txt" } },
214
+ ],
215
+ stopReason: "toolUse",
216
+ },
217
+ {
218
+ role: "toolResult",
219
+ toolCallId: "call-complete",
220
+ toolName: "read",
221
+ content: [{ type: "text", text: "file contents" }],
222
+ isError: false,
223
+ timestamp: 2,
224
+ },
225
+ ];
226
+
227
+ const sanitized = sanitizeTitleMessages(messages);
228
+
229
+ assert.deepEqual(
230
+ sanitized[1].content.filter((block) => block.type === "toolCall").map((block) => block.id),
231
+ ["call-complete"],
232
+ );
233
+ assert.equal(sanitized[2], messages[2]);
234
+ assert.equal(messages[1].content.length, 3);
235
+ });
236
+
237
+ test("removes incomplete tool calls before invoking the title provider", async () => {
238
+ let providerMessages;
239
+ const sourceAgent = {
240
+ state: {
241
+ systemPrompt: "system",
242
+ model: { provider: "test", id: "test-model" },
243
+ thinkingLevel: "off",
244
+ tools: [],
245
+ messages: [
246
+ { role: "user", content: "run a command", timestamp: 1 },
247
+ {
248
+ ...assistantMessage(""),
249
+ content: [{
250
+ type: "toolCall",
251
+ id: "call-incomplete",
252
+ name: "bash",
253
+ arguments: { command: "sleep 10" },
254
+ }],
255
+ stopReason: "toolUse",
256
+ },
257
+ ],
258
+ },
259
+ waitForIdle: async () => {},
260
+ convertToLlm: (messages) => messages,
261
+ streamFunction: (_model, context) => {
262
+ providerMessages = context.messages.map((message) => ({
263
+ role: message.role,
264
+ content: message.content,
265
+ }));
266
+ const stream = createAssistantMessageEventStream();
267
+ queueMicrotask(() => {
268
+ stream.push({
269
+ type: "done",
270
+ reason: "stop",
271
+ message: assistantMessage("Sanitized Tool Call History"),
272
+ });
273
+ });
274
+ return stream;
275
+ },
276
+ sessionId: "source-session-id",
277
+ };
278
+
279
+ const result = await generateSessionTitle({ agent: sourceAgent });
280
+
281
+ assert.equal(result.title, "Sanitized Tool Call History");
282
+ assert.deepEqual(providerMessages.map((message) => message.role), ["user"]);
283
+ assert.match(providerMessages[0].content, /Create a concise title/);
284
+ });
@@ -0,0 +1,255 @@
1
+ import {
2
+ Agent,
3
+ type AgentMessage,
4
+ type AgentOptions,
5
+ type AgentTool,
6
+ } from "@earendil-works/pi-agent-core";
7
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
8
+
9
+ const TITLE_TIMEOUT_MS = 90_000;
10
+ const MAX_TITLE_LENGTH = 80;
11
+
12
+ const TITLE_PROMPT = `Create a concise title for this session based on the conversation above.
13
+
14
+ Requirements:
15
+ - Match the primary language used by the user.
16
+ - Describe the user's concrete goal or the outcome, not the act of chatting.
17
+ - Use 4-12 words for space-separated languages, or 8-24 characters for CJK text when practical.
18
+ - Do not call any tools.
19
+ - Return only the title as plain text, with no quotes, label, markdown, or explanation.`;
20
+
21
+ export interface GeneratedSessionTitle {
22
+ title: string;
23
+ usage?: {
24
+ input: number;
25
+ output: number;
26
+ cacheRead: number;
27
+ cacheWrite: number;
28
+ total: number;
29
+ };
30
+ }
31
+
32
+ function createShadowTools(tools: AgentTool[]): AgentTool[] {
33
+ return tools.map((tool) => ({
34
+ ...tool,
35
+ execute: async () => {
36
+ throw new Error("Tools cannot be executed while generating a session title");
37
+ },
38
+ }));
39
+ }
40
+
41
+ /**
42
+ * Build a temporary Agent configuration whose provider-facing prefix matches
43
+ * the source Agent. Tool implementations are replaced without changing their
44
+ * names, descriptions, or schemas, so a naming run cannot mutate the project.
45
+ */
46
+ export function buildSessionTitleAgentOptions(source: Agent): AgentOptions {
47
+ const state = source.state;
48
+ return {
49
+ initialState: {
50
+ systemPrompt: state.systemPrompt,
51
+ model: state.model,
52
+ thinkingLevel: state.thinkingLevel,
53
+ tools: createShadowTools(state.tools),
54
+ messages: state.messages,
55
+ },
56
+ convertToLlm: source.convertToLlm,
57
+ transformContext: source.transformContext,
58
+ streamFn: source.streamFunction,
59
+ getApiKey: source.getApiKey,
60
+ onPayload: source.onPayload,
61
+ onResponse: source.onResponse,
62
+ steeringMode: source.steeringMode,
63
+ followUpMode: source.followUpMode,
64
+ sessionId: source.sessionId,
65
+ thinkingBudgets: source.thinkingBudgets,
66
+ transport: source.transport,
67
+ maxRetryDelayMs: source.maxRetryDelayMs,
68
+ toolExecution: source.toolExecution,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * A running source session usually ends in the user message currently being
74
+ * answered. Fold the title request into a copy of that message so the title
75
+ * request does not send two consecutive user messages to the provider.
76
+ */
77
+ export function appendTitleRequestToTrailingUser(messages: AgentMessage[]): AgentMessage[] {
78
+ const lastMessage = messages.at(-1);
79
+ if (!lastMessage || lastMessage.role !== "user") return messages;
80
+
81
+ const content = typeof lastMessage.content === "string"
82
+ ? `${lastMessage.content}\n\n${TITLE_PROMPT}`
83
+ : [...lastMessage.content, { type: "text" as const, text: TITLE_PROMPT }];
84
+
85
+ return [
86
+ ...messages.slice(0, -1),
87
+ { ...lastMessage, content },
88
+ ];
89
+ }
90
+
91
+ function stripWrappingQuotes(value: string): string {
92
+ const pairs: Array<[string, string]> = [
93
+ ['"', '"'],
94
+ ["'", "'"],
95
+ ["`", "`"],
96
+ ["\u201c", "\u201d"],
97
+ ["\u300c", "\u300d"],
98
+ ["\u300e", "\u300f"],
99
+ ];
100
+ for (const [start, end] of pairs) {
101
+ if (value.startsWith(start) && value.endsWith(end) && value.length > start.length + end.length) {
102
+ return value.slice(start.length, -end.length).trim();
103
+ }
104
+ }
105
+ return value;
106
+ }
107
+
108
+ export function parseGeneratedSessionTitle(raw: string): string {
109
+ let value = raw.trim();
110
+ const fenced = value.match(/^```(?:json|text)?\s*([\s\S]*?)\s*```$/i);
111
+ if (fenced) value = fenced[1].trim();
112
+
113
+ if (value.startsWith("{")) {
114
+ try {
115
+ const parsed = JSON.parse(value) as { title?: unknown };
116
+ if (typeof parsed.title === "string") value = parsed.title.trim();
117
+ } catch {
118
+ // Fall back to plain-text cleanup below.
119
+ }
120
+ }
121
+
122
+ value = value.split(/\r?\n/, 1)[0] ?? "";
123
+ value = value.replace(/^(?:session\s+title|title|标题)\s*[::-]\s*/i, "");
124
+ value = stripWrappingQuotes(value).replace(/\s+/g, " ").trim();
125
+ value = value.replace(/[。.!]+$/u, "").trim();
126
+
127
+ if (!/[\p{L}\p{N}]/u.test(value)) {
128
+ throw new Error("The model did not return a usable session title");
129
+ }
130
+
131
+ const characters = Array.from(value);
132
+ if (characters.length > MAX_TITLE_LENGTH) {
133
+ value = characters.slice(0, MAX_TITLE_LENGTH).join("").trim();
134
+ }
135
+ return value;
136
+ }
137
+
138
+ function getAssistantResult(agent: Agent, historyLength: number): GeneratedSessionTitle {
139
+ const generatedMessages = agent.state.messages.slice(historyLength);
140
+ for (let i = generatedMessages.length - 1; i >= 0; i--) {
141
+ const message = generatedMessages[i];
142
+ if (message.role !== "assistant") continue;
143
+ if (message.stopReason === "error") {
144
+ throw new Error(message.errorMessage || "The title model request failed");
145
+ }
146
+ const text = message.content
147
+ .filter((block) => block.type === "text")
148
+ .map((block) => block.text)
149
+ .join("\n")
150
+ .trim();
151
+ if (!text) continue;
152
+ return {
153
+ title: parseGeneratedSessionTitle(text),
154
+ ...(message.usage ? {
155
+ usage: {
156
+ input: message.usage.input,
157
+ output: message.usage.output,
158
+ cacheRead: message.usage.cacheRead,
159
+ cacheWrite: message.usage.cacheWrite,
160
+ total: message.usage.totalTokens,
161
+ },
162
+ } : {}),
163
+ };
164
+ }
165
+ throw new Error("The model did not return a session title");
166
+ }
167
+
168
+ export function sanitizeTitleMessages(messages: AgentMessage[]): AgentMessage[] {
169
+ const sanitized: AgentMessage[] = [];
170
+ let expectedToolResultIds: Set<string> | undefined;
171
+
172
+ for (let index = 0; index < messages.length; index++) {
173
+ const message = messages[index];
174
+
175
+ if (message.role === "assistant") {
176
+ const followingToolResultIds = new Set<string>();
177
+ for (let resultIndex = index + 1; resultIndex < messages.length; resultIndex++) {
178
+ const resultMessage = messages[resultIndex];
179
+ if (resultMessage.role !== "toolResult") break;
180
+ followingToolResultIds.add(resultMessage.toolCallId);
181
+ }
182
+
183
+ expectedToolResultIds = new Set<string>();
184
+ const content = message.content.filter((block) => {
185
+ if (block.type !== "toolCall") return true;
186
+ if (!followingToolResultIds.has(block.id)) return false;
187
+ expectedToolResultIds!.add(block.id);
188
+ return true;
189
+ });
190
+
191
+ if (content.length > 0) {
192
+ sanitized.push({ ...message, content });
193
+ }
194
+ continue;
195
+ }
196
+
197
+ if (message.role === "toolResult") {
198
+ if (expectedToolResultIds?.delete(message.toolCallId)) {
199
+ sanitized.push(message);
200
+ }
201
+ continue;
202
+ }
203
+
204
+ expectedToolResultIds = undefined;
205
+ sanitized.push(message);
206
+ }
207
+
208
+ return sanitized;
209
+ }
210
+
211
+ export async function generateSessionTitle(source: AgentSession): Promise<GeneratedSessionTitle> {
212
+ const sourceAgent = source.agent;
213
+ await sourceAgent.waitForIdle();
214
+
215
+ const sanitizedMessages = sanitizeTitleMessages(sourceAgent.state.messages);
216
+ const historyLength = sanitizedMessages.length;
217
+ if (!sanitizedMessages.some(
218
+ (message) => message.role === "user" || message.role === "compactionSummary",
219
+ )) {
220
+ throw new Error("The session has no user messages to name");
221
+ }
222
+
223
+ const options = buildSessionTitleAgentOptions(sourceAgent);
224
+ options.initialState!.messages = sanitizedMessages;
225
+ const continuesFromTrailingUser = sanitizedMessages.at(-1)?.role === "user";
226
+ if (continuesFromTrailingUser) {
227
+ options.initialState!.messages = appendTitleRequestToTrailingUser(sanitizedMessages);
228
+ }
229
+
230
+ const temporaryAgent = new Agent(options);
231
+ const runPromise = continuesFromTrailingUser
232
+ ? temporaryAgent.continue()
233
+ : temporaryAgent.prompt(TITLE_PROMPT);
234
+ let timeout: ReturnType<typeof setTimeout> | undefined;
235
+
236
+ try {
237
+ await Promise.race([
238
+ runPromise,
239
+ new Promise<never>((_, reject) => {
240
+ timeout = setTimeout(() => {
241
+ temporaryAgent.abort();
242
+ reject(new Error("Session title generation timed out"));
243
+ }, TITLE_TIMEOUT_MS);
244
+ }),
245
+ ]);
246
+ } catch (error) {
247
+ temporaryAgent.abort();
248
+ await runPromise.catch(() => {});
249
+ throw error;
250
+ } finally {
251
+ if (timeout) clearTimeout(timeout);
252
+ }
253
+
254
+ return getAssistantResult(temporaryAgent, historyLength);
255
+ }
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { setDisableModelInvocation } from "./skill-frontmatter.ts";
6
+
7
+ describe("setDisableModelInvocation", () => {
8
+ const withFrontmatter = "---\nname: my-skill\ndescription: Does things\n---\n\nBody text.\n";
9
+
10
+ it("adds the key after the opening fence when absent", () => {
11
+ const updated = setDisableModelInvocation(withFrontmatter, true);
12
+ assert.equal(
13
+ updated,
14
+ "---\ndisable-model-invocation: true\nname: my-skill\ndescription: Does things\n---\n\nBody text.\n",
15
+ );
16
+ assert.equal(parseFrontmatter(updated).frontmatter["disable-model-invocation"], true);
17
+ });
18
+
19
+ it("creates a frontmatter block when the file has none", () => {
20
+ const updated = setDisableModelInvocation("Just a body.\n", true);
21
+ const { frontmatter, body } = parseFrontmatter(updated);
22
+ assert.equal(frontmatter["disable-model-invocation"], true);
23
+ assert.equal(body, "Just a body.");
24
+ });
25
+
26
+ it("replaces an explicit false value instead of adding a duplicate key", () => {
27
+ const content = "---\nname: my-skill\ndisable-model-invocation: false\ndescription: Does things\n---\n\nBody text.\n";
28
+ const updated = setDisableModelInvocation(content, true);
29
+ // A duplicate key would make the whole file unparseable and the loader
30
+ // would drop the skill, so this must parse to a single true value.
31
+ const { frontmatter } = parseFrontmatter(updated);
32
+ assert.equal(frontmatter["disable-model-invocation"], true);
33
+ assert.equal(
34
+ updated.match(/^disable-model-invocation[^\n]*/gm)?.length,
35
+ 1,
36
+ "must not emit a duplicate key",
37
+ );
38
+ });
39
+
40
+ it("keeps a single key when disabling an already-true skill", () => {
41
+ const content = "---\nname: my-skill\ndisable-model-invocation: true\ndescription: Does things\n---\n\nBody text.\n";
42
+ const updated = setDisableModelInvocation(content, true);
43
+ assert.equal(parseFrontmatter(updated).frontmatter["disable-model-invocation"], true);
44
+ assert.equal(updated.match(/^disable-model-invocation[^\n]*/gm)?.length, 1);
45
+ });
46
+
47
+ it("removes the key when disabling is turned off", () => {
48
+ const content = "---\nname: my-skill\ndisable-model-invocation: true\ndescription: Does things\n---\n\nBody text.\n";
49
+ const updated = setDisableModelInvocation(content, false);
50
+ assert.deepEqual(parseFrontmatter(updated).frontmatter, {
51
+ name: "my-skill",
52
+ description: "Does things",
53
+ });
54
+ });
55
+
56
+ it("removes the key when it is the last frontmatter line before the closing fence", () => {
57
+ const content = "---\nname: my-skill\ndescription: Does things\ndisable-model-invocation: true\n---\n\nBody text.\n";
58
+ const updated = setDisableModelInvocation(content, false);
59
+ assert.equal(updated, withFrontmatter);
60
+ });
61
+
62
+ it("is a no-op when disabling is off and the key is absent", () => {
63
+ assert.equal(setDisableModelInvocation(withFrontmatter, false), withFrontmatter);
64
+ });
65
+
66
+ it("preserves unrelated frontmatter formatting and body lines that mention the key", () => {
67
+ const content = "---\nname: my-skill\n# comment\nallowed-tools: [ read, write ]\ndisable-model-invocation: false\n---\nUse `disable-model-invocation: true` to hide me.\n";
68
+ const updated = setDisableModelInvocation(content, true);
69
+ const { frontmatter, body } = parseFrontmatter(updated);
70
+ assert.equal(frontmatter["disable-model-invocation"], true);
71
+ assert.deepEqual(frontmatter["allowed-tools"], ["read", "write"]);
72
+ assert.match(updated, /# comment/);
73
+ assert.equal(body, "Use `disable-model-invocation: true` to hide me.");
74
+ });
75
+ });
@@ -0,0 +1,40 @@
1
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
2
+
3
+ const KEY = "disable-model-invocation";
4
+
5
+ /**
6
+ * Toggle the `disable-model-invocation` frontmatter key with a surgical line
7
+ * edit that preserves the original YAML formatting of every other field.
8
+ *
9
+ * The key is detected by presence rather than truthiness: an explicit
10
+ * `disable-model-invocation: false` must be updated in place. Prepending a
11
+ * second key (as a truthiness check would) creates a duplicate YAML key that
12
+ * makes the whole file unparseable, and the skill loader then drops the skill.
13
+ */
14
+ export function setDisableModelInvocation(content: string, disable: boolean): string {
15
+ const { frontmatter } = parseFrontmatter<Record<string, unknown>>(content);
16
+ const hasKey = Object.prototype.hasOwnProperty.call(frontmatter, KEY);
17
+ if (!disable && !hasKey) return content;
18
+
19
+ // Only edit inside the frontmatter block, so a body line that happens to
20
+ // document the key is never touched.
21
+ const closing = content.startsWith("---") ? content.indexOf("\n---", 3) : -1;
22
+ const head = closing === -1 ? content : content.slice(0, closing);
23
+ const tail = closing === -1 ? "" : content.slice(closing);
24
+
25
+ if (disable) {
26
+ if (hasKey) {
27
+ return head.replace(new RegExp(`^${KEY}[ \\t]*:[^\\n]*`, "m"), `${KEY}: true`) + tail;
28
+ }
29
+ const withKey = head.replace(/^---\r?\n/, `---\n${KEY}: true\n`);
30
+ if (withKey === head) {
31
+ // No frontmatter block at all — create one.
32
+ return `---\n${KEY}: true\n---\n${content}`;
33
+ }
34
+ return withKey + tail;
35
+ }
36
+
37
+ // Drop the line together with its preceding newline so no blank line is
38
+ // left behind; the key is never the first line of the frontmatter block.
39
+ return head.replace(new RegExp(`\\n${KEY}[ \\t]*:[^\\n]*`), "") + tail;
40
+ }