@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.4

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 (164) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3682 -3615
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/cli/update-cli.d.ts +15 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/collab/replication-shrink.d.ts +39 -0
  12. package/dist/types/config/provider-globals.d.ts +7 -0
  13. package/dist/types/config/settings-schema.d.ts +81 -0
  14. package/dist/types/debug/log-viewer.d.ts +1 -0
  15. package/dist/types/debug/raw-sse.d.ts +1 -0
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  18. package/dist/types/extensibility/utils.d.ts +12 -0
  19. package/dist/types/mcp/transports/index.d.ts +1 -0
  20. package/dist/types/mcp/transports/sse.d.ts +20 -0
  21. package/dist/types/memories/index.d.ts +20 -1
  22. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  23. package/dist/types/modes/components/index.d.ts +1 -0
  24. package/dist/types/modes/components/model-selector.d.ts +9 -1
  25. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/component.d.ts +35 -1
  27. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  28. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  29. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  30. package/dist/types/modes/interactive-mode.d.ts +11 -4
  31. package/dist/types/modes/skill-command.d.ts +32 -0
  32. package/dist/types/modes/types.d.ts +9 -3
  33. package/dist/types/session/agent-session.d.ts +58 -10
  34. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  35. package/dist/types/session/messages.d.ts +26 -0
  36. package/dist/types/session/messages.test.d.ts +1 -0
  37. package/dist/types/session/session-entries.d.ts +31 -3
  38. package/dist/types/session/session-history-format.d.ts +6 -0
  39. package/dist/types/session/session-loader.d.ts +9 -1
  40. package/dist/types/session/session-manager.d.ts +8 -7
  41. package/dist/types/session/session-storage.d.ts +11 -0
  42. package/dist/types/session/session-title-slot.d.ts +19 -0
  43. package/dist/types/ssh/connection-manager.d.ts +47 -0
  44. package/dist/types/ssh/utils.d.ts +16 -0
  45. package/dist/types/task/executor.d.ts +3 -16
  46. package/dist/types/task/render.d.ts +0 -5
  47. package/dist/types/task/renderer.d.ts +13 -0
  48. package/dist/types/task/types.d.ts +16 -0
  49. package/dist/types/task/yield-assembly.d.ts +28 -0
  50. package/dist/types/tiny/text.d.ts +8 -0
  51. package/dist/types/tools/render-utils.d.ts +2 -0
  52. package/dist/types/tools/review.d.ts +6 -4
  53. package/dist/types/tools/ssh.d.ts +1 -1
  54. package/dist/types/tools/todo.d.ts +6 -0
  55. package/dist/types/tools/yield.d.ts +8 -3
  56. package/dist/types/utils/thinking-display.d.ts +4 -0
  57. package/package.json +12 -12
  58. package/src/advisor/__tests__/advisor.test.ts +242 -10
  59. package/src/advisor/__tests__/config.test.ts +173 -0
  60. package/src/advisor/advise-tool.ts +11 -6
  61. package/src/advisor/config.ts +256 -0
  62. package/src/advisor/index.ts +1 -0
  63. package/src/advisor/runtime.ts +12 -2
  64. package/src/advisor/transcript-recorder.ts +25 -2
  65. package/src/advisor/watchdog.ts +57 -31
  66. package/src/autolearn/controller.ts +13 -22
  67. package/src/autoresearch/index.ts +7 -2
  68. package/src/cli/gc-cli.ts +17 -10
  69. package/src/cli/update-cli.ts +254 -0
  70. package/src/collab/guest.ts +43 -7
  71. package/src/collab/host.ts +13 -8
  72. package/src/collab/replication-shrink.ts +111 -0
  73. package/src/config/model-registry.ts +80 -18
  74. package/src/config/provider-globals.ts +25 -0
  75. package/src/config/settings-schema.ts +77 -0
  76. package/src/debug/index.ts +32 -7
  77. package/src/debug/log-viewer.ts +111 -53
  78. package/src/debug/raw-sse.ts +68 -48
  79. package/src/discovery/codex.ts +13 -5
  80. package/src/edit/hashline/diff.ts +57 -4
  81. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  82. package/src/eval/js/shared/local-module-loader.ts +23 -1
  83. package/src/export/html/template.js +13 -7
  84. package/src/extensibility/extensions/loader.ts +5 -3
  85. package/src/extensibility/extensions/wrapper.ts +9 -3
  86. package/src/extensibility/hooks/loader.ts +3 -3
  87. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  88. package/src/extensibility/plugins/manager.ts +2 -1
  89. package/src/extensibility/tool-event-input.ts +23 -0
  90. package/src/extensibility/utils.ts +74 -0
  91. package/src/internal-urls/docs-index.generated.txt +1 -1
  92. package/src/mcp/client.ts +3 -1
  93. package/src/mcp/manager.ts +12 -5
  94. package/src/mcp/transports/index.ts +1 -0
  95. package/src/mcp/transports/sse.ts +377 -0
  96. package/src/memories/index.ts +130 -15
  97. package/src/memory-backend/local-backend.ts +5 -3
  98. package/src/modes/components/advisor-config.ts +555 -0
  99. package/src/modes/components/advisor-message.ts +9 -2
  100. package/src/modes/components/agent-hub.ts +9 -4
  101. package/src/modes/components/index.ts +2 -0
  102. package/src/modes/components/model-selector.ts +79 -48
  103. package/src/modes/components/settings-selector.ts +1 -0
  104. package/src/modes/components/status-line/component.ts +150 -5
  105. package/src/modes/components/status-line/segments.ts +46 -21
  106. package/src/modes/components/status-line/types.ts +13 -1
  107. package/src/modes/components/tool-execution.ts +47 -6
  108. package/src/modes/controllers/command-controller.ts +27 -36
  109. package/src/modes/controllers/event-controller.ts +113 -1
  110. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  111. package/src/modes/controllers/input-controller.ts +61 -61
  112. package/src/modes/controllers/selector-controller.ts +100 -9
  113. package/src/modes/interactive-mode.ts +74 -25
  114. package/src/modes/skill-command.ts +116 -0
  115. package/src/modes/types.ts +9 -3
  116. package/src/modes/utils/ui-helpers.ts +41 -23
  117. package/src/prompts/agents/reviewer.md +11 -10
  118. package/src/prompts/goals/goal-todo-context.md +12 -0
  119. package/src/prompts/review-custom-request.md +1 -2
  120. package/src/prompts/review-request.md +1 -2
  121. package/src/prompts/system/interrupted-thinking.md +7 -0
  122. package/src/prompts/system/recap-user.md +9 -0
  123. package/src/prompts/system/subagent-system-prompt.md +8 -5
  124. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  125. package/src/prompts/system/system-prompt.md +0 -1
  126. package/src/prompts/tools/irc.md +2 -2
  127. package/src/prompts/tools/read.md +2 -2
  128. package/src/sdk.ts +28 -24
  129. package/src/session/agent-session.ts +899 -429
  130. package/src/session/indexed-session-storage.ts +86 -13
  131. package/src/session/messages.test.ts +125 -0
  132. package/src/session/messages.ts +172 -9
  133. package/src/session/redis-session-storage.ts +49 -2
  134. package/src/session/session-entries.ts +39 -2
  135. package/src/session/session-history-format.ts +29 -2
  136. package/src/session/session-listing.ts +54 -24
  137. package/src/session/session-loader.ts +66 -3
  138. package/src/session/session-manager.ts +115 -22
  139. package/src/session/session-persistence.ts +95 -1
  140. package/src/session/session-storage.ts +36 -0
  141. package/src/session/session-title-slot.ts +141 -0
  142. package/src/session/sql-session-storage.ts +71 -11
  143. package/src/slash-commands/builtin-registry.ts +23 -24
  144. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  145. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  146. package/src/ssh/connection-manager.ts +139 -12
  147. package/src/ssh/file-transfer.ts +23 -18
  148. package/src/ssh/ssh-executor.ts +2 -13
  149. package/src/ssh/utils.ts +19 -0
  150. package/src/task/executor.ts +21 -23
  151. package/src/task/render.ts +162 -20
  152. package/src/task/renderer.ts +14 -0
  153. package/src/task/types.ts +17 -0
  154. package/src/task/yield-assembly.ts +207 -0
  155. package/src/tiny/text.ts +23 -0
  156. package/src/tools/ask.ts +55 -4
  157. package/src/tools/render-utils.ts +2 -0
  158. package/src/tools/renderers.ts +8 -2
  159. package/src/tools/review.ts +17 -7
  160. package/src/tools/ssh.ts +8 -4
  161. package/src/tools/todo.ts +17 -1
  162. package/src/tools/yield.ts +140 -31
  163. package/src/utils/thinking-display.ts +15 -0
  164. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -141,6 +141,100 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
141
141
  return obj;
142
142
  }
143
143
 
144
+ /**
145
+ * Read the duplication-relevant fields of an OpenAI Responses reasoning item.
146
+ * Returns `undefined` for anything that is not a `type: "reasoning"` object, so
147
+ * non-reasoning payload entries and corrupt signatures are never matched.
148
+ */
149
+ function readReasoningItem(item: unknown): { encrypted_content?: string; id?: string } | undefined {
150
+ if (item === null || typeof item !== "object") return undefined;
151
+ if (!("type" in item) || item.type !== "reasoning") return undefined;
152
+ const reasoning: { encrypted_content?: string; id?: string } = {};
153
+ if ("encrypted_content" in item && typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) {
154
+ reasoning.encrypted_content = item.encrypted_content;
155
+ }
156
+ if ("id" in item && typeof item.id === "string" && item.id.length > 0) reasoning.id = item.id;
157
+ return reasoning;
158
+ }
159
+
160
+ /**
161
+ * True when a `thinkingSignature` (a JSON-encoded reasoning item) is already
162
+ * carried by a reasoning item in the message's provider payload — matched on
163
+ * `encrypted_content` (the load-bearing blob) when present, else on item `id`.
164
+ * A signature the payload does not cover is never reported as recoverable, so it
165
+ * is always kept.
166
+ */
167
+ function signatureCoveredByPayload(
168
+ signature: string,
169
+ encrypted: ReadonlySet<string>,
170
+ ids: ReadonlySet<string>,
171
+ ): boolean {
172
+ let parsed: unknown;
173
+ try {
174
+ parsed = JSON.parse(signature);
175
+ } catch {
176
+ return false;
177
+ }
178
+ const reasoning = readReasoningItem(parsed);
179
+ if (!reasoning) return false;
180
+ if (reasoning.encrypted_content) return encrypted.has(reasoning.encrypted_content);
181
+ if (reasoning.id) return ids.has(reasoning.id);
182
+ return false;
183
+ }
184
+
185
+ /**
186
+ * Drop `thinkingSignature` from assistant thinking blocks whose reasoning item is
187
+ * already carried, verbatim, in the message's OpenAI Responses `providerPayload`.
188
+ *
189
+ * Responses/Codex turns mint each reasoning item once and store it twice:
190
+ * `providerPayload.items` (the authoritative native-history copy that replay and
191
+ * remote compaction read) and `content[].thinkingSignature`, which is literally
192
+ * `JSON.stringify(reasoningItem)` — including the large `encrypted_content` blob.
193
+ * Replay only ever reads the payload; the signature is a no-payload fallback that
194
+ * same-provider turns never reach and cross-model turns strip as untrustworthy.
195
+ * Persisting both stores the encrypted reasoning twice for zero token or replay
196
+ * benefit, so the on-disk copy drops the duplicate signature whenever its
197
+ * reasoning item is recoverable from the payload. The in-memory entry is left
198
+ * untouched; only the serialized line is slimmed.
199
+ */
200
+ function stripReplayedReasoningSignatures(entry: FileEntry): FileEntry {
201
+ if (entry.type !== "message" || entry.message.role !== "assistant") return entry;
202
+ const message = entry.message;
203
+ const payload = message.providerPayload;
204
+ if (payload?.type !== "openaiResponsesHistory" || !Array.isArray(payload.items)) return entry;
205
+ const hasSignedThinking = message.content.some(
206
+ block =>
207
+ block.type === "thinking" && typeof block.thinkingSignature === "string" && block.thinkingSignature.length > 0,
208
+ );
209
+ if (!hasSignedThinking) return entry;
210
+
211
+ const encrypted = new Set<string>();
212
+ const ids = new Set<string>();
213
+ for (const rawItem of payload.items) {
214
+ const reasoning = readReasoningItem(rawItem);
215
+ if (!reasoning) continue;
216
+ if (reasoning.encrypted_content) encrypted.add(reasoning.encrypted_content);
217
+ if (reasoning.id) ids.add(reasoning.id);
218
+ }
219
+ if (encrypted.size === 0 && ids.size === 0) return entry;
220
+
221
+ let changed = false;
222
+ const content = message.content.map(block => {
223
+ if (
224
+ block.type !== "thinking" ||
225
+ typeof block.thinkingSignature !== "string" ||
226
+ block.thinkingSignature.length === 0
227
+ ) {
228
+ return block;
229
+ }
230
+ if (!signatureCoveredByPayload(block.thinkingSignature, encrypted, ids)) return block;
231
+ changed = true;
232
+ return { ...block, thinkingSignature: undefined };
233
+ });
234
+ if (!changed) return entry;
235
+ return { ...entry, message: { ...message, content } };
236
+ }
237
+
144
238
  export function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): FileEntry {
145
- return truncateForPersistence(entry, blobStore) as FileEntry;
239
+ return truncateForPersistence(stripReplayedReasoningSignatures(entry), blobStore) as FileEntry;
146
240
  }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
  import { hasFsCode, isEnoent, logger, peekFileEnds, Snowflake, toError } from "@oh-my-pi/pi-utils";
5
+ import { overlayTitleSlotContent, type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
5
6
 
6
7
  const utf8Decoder = new TextDecoder("utf-8");
7
8
 
@@ -31,6 +32,14 @@ export interface SessionStorage {
31
32
  ensureDirSync(dir: string): void;
32
33
  existsSync(path: string): boolean;
33
34
  writeTextSync(path: string, content: string): void;
35
+ /**
36
+ * Update the current session title through the storage backend.
37
+ *
38
+ * File-like backends rewrite the fixed-width JSONL title slot; indexed
39
+ * backends can store the semantic title fields and synthesize the slot when
40
+ * reading.
41
+ */
42
+ updateSessionTitle(path: string, update: SessionTitleUpdate): Promise<void>;
34
43
  statSync(path: string): SessionStorageStat;
35
44
  listFilesSync(dir: string, pattern: string): string[];
36
45
 
@@ -163,6 +172,25 @@ export class FileSessionStorage implements SessionStorage {
163
172
  }
164
173
  }
165
174
 
175
+ async updateSessionTitle(fpath: string, update: SessionTitleUpdate): Promise<void> {
176
+ const fd = fs.openSync(fpath, "r+");
177
+ try {
178
+ const buf = Buffer.from(serializeTitleSlot(update), "utf-8");
179
+ let offset = 0;
180
+ while (offset < buf.length) {
181
+ const written = fs.writeSync(fd, buf, offset, buf.length - offset, offset);
182
+ if (written === 0) {
183
+ throw new Error("Short write");
184
+ }
185
+ offset += written;
186
+ }
187
+ } catch (err) {
188
+ throw toError(err);
189
+ } finally {
190
+ fs.closeSync(fd);
191
+ }
192
+ }
193
+
166
194
  statSync(path: string): SessionStorageStat {
167
195
  const stats = fs.statSync(path);
168
196
  return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
@@ -526,6 +554,14 @@ export class MemorySessionStorage implements SessionStorage {
526
554
  this.#files.set(path, createMemoryFileEntry(content, Date.now()));
527
555
  }
528
556
 
557
+ async updateSessionTitle(path: string, update: SessionTitleUpdate): Promise<void> {
558
+ const entry = this.#requireEntry(path);
559
+ this.#files.set(
560
+ path,
561
+ createMemoryFileEntry(overlayTitleSlotContent(materializeMemoryEntry(entry), update), Date.now()),
562
+ );
563
+ }
564
+
529
565
  /**
530
566
  * Internal O(1) append used by {@link MemorySessionStorageWriter}. Lazily
531
567
  * creates the entry. External callers should go through `openWriter()`
@@ -0,0 +1,141 @@
1
+ import {
2
+ SESSION_TITLE_SLOT_BYTES,
3
+ SESSION_TITLE_SLOT_ENTRY_TYPE,
4
+ type SessionTitleSlotEntry,
5
+ type SessionTitleSource,
6
+ } from "./session-entries";
7
+
8
+ const utf8Encoder = new TextEncoder();
9
+
10
+ /** Semantic title update persisted by session storage backends. */
11
+ export interface SessionTitleUpdate {
12
+ title?: string;
13
+ source?: SessionTitleSource;
14
+ updatedAt: string;
15
+ }
16
+
17
+ function byteLength(value: string): number {
18
+ return utf8Encoder.encode(value).byteLength;
19
+ }
20
+
21
+ function titleSlotLine(title: string, source: SessionTitleSource | undefined, updatedAt: string, pad: string): string {
22
+ const slot: SessionTitleSlotEntry = source
23
+ ? {
24
+ type: SESSION_TITLE_SLOT_ENTRY_TYPE,
25
+ v: 1,
26
+ title,
27
+ source,
28
+ updatedAt,
29
+ pad,
30
+ }
31
+ : {
32
+ type: SESSION_TITLE_SLOT_ENTRY_TYPE,
33
+ v: 1,
34
+ title,
35
+ updatedAt,
36
+ pad,
37
+ };
38
+ return `${JSON.stringify(slot)}\n`;
39
+ }
40
+
41
+ function truncateTitleForSlot(title: string, source: SessionTitleSource | undefined, updatedAt: string): string {
42
+ const codePoints = [...title];
43
+ let low = 0;
44
+ let high = codePoints.length;
45
+ let best = "";
46
+
47
+ while (low <= high) {
48
+ const mid = (low + high) >>> 1;
49
+ const candidate = codePoints.slice(0, mid).join("");
50
+ if (byteLength(titleSlotLine(candidate, source, updatedAt, "")) <= SESSION_TITLE_SLOT_BYTES) {
51
+ best = candidate;
52
+ low = mid + 1;
53
+ } else {
54
+ high = mid - 1;
55
+ }
56
+ }
57
+
58
+ return best;
59
+ }
60
+
61
+ function isSessionTitleSource(value: unknown): value is SessionTitleSource {
62
+ return value === "auto" || value === "user";
63
+ }
64
+
65
+ function parseTitleSlotObject(value: unknown): SessionTitleSlotEntry | undefined {
66
+ if (typeof value !== "object" || value === null) return undefined;
67
+ const record = value as Record<string, unknown>;
68
+ if (record.type !== SESSION_TITLE_SLOT_ENTRY_TYPE || record.v !== 1) return undefined;
69
+ if (typeof record.title !== "string" || typeof record.updatedAt !== "string" || typeof record.pad !== "string") {
70
+ return undefined;
71
+ }
72
+ const source = record.source;
73
+ if (source !== undefined && !isSessionTitleSource(source)) return undefined;
74
+ const slot: SessionTitleSlotEntry = {
75
+ type: SESSION_TITLE_SLOT_ENTRY_TYPE,
76
+ v: 1,
77
+ title: record.title,
78
+ updatedAt: record.updatedAt,
79
+ pad: record.pad,
80
+ };
81
+ if (source) slot.source = source;
82
+ return slot;
83
+ }
84
+
85
+ /** Parse a physical title slot JSONL line. Returns undefined for legacy headers. */
86
+ export function parseTitleSlotLine(line: string): SessionTitleSlotEntry | undefined {
87
+ try {
88
+ return parseTitleSlotObject(JSON.parse(line)) ?? undefined;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Parse the fixed-width title slot from a physical session body. */
95
+ export function parseTitleSlotFromContent(content: string): SessionTitleSlotEntry | undefined {
96
+ const newlineIndex = content.indexOf("\n");
97
+ if (newlineIndex < 0) return undefined;
98
+ return parseTitleSlotLine(content.slice(0, newlineIndex));
99
+ }
100
+
101
+ /** Convert a parsed title slot to the semantic storage update shape. */
102
+ export function titleUpdateFromSlot(slot: SessionTitleSlotEntry | undefined): SessionTitleUpdate | undefined {
103
+ if (!slot) return undefined;
104
+ return {
105
+ title: slot.title,
106
+ source: slot.source,
107
+ updatedAt: slot.updatedAt,
108
+ };
109
+ }
110
+
111
+ /** Serialize the fixed-width first-line title slot, exactly 256 UTF-8 bytes including newline. */
112
+ export function serializeTitleSlot(options: SessionTitleUpdate): string {
113
+ const title = truncateTitleForSlot(options.title ?? "", options.source, options.updatedAt);
114
+ const unpadded = titleSlotLine(title, options.source, options.updatedAt, "");
115
+ const padBytes = SESSION_TITLE_SLOT_BYTES - byteLength(unpadded);
116
+ if (padBytes < 0) throw new Error("Session title slot metadata exceeds fixed slot size");
117
+ const line = titleSlotLine(title, options.source, options.updatedAt, " ".repeat(padBytes));
118
+ if (byteLength(line) !== SESSION_TITLE_SLOT_BYTES) {
119
+ throw new Error("Session title slot serialization failed to produce fixed-width output");
120
+ }
121
+ return line;
122
+ }
123
+
124
+ /** Replace the physical fixed-width title slot in a full session body. */
125
+ export function overlayTitleSlotContent(content: string, update: SessionTitleUpdate): string {
126
+ const slot = Buffer.from(serializeTitleSlot(update), "utf-8");
127
+ const existing = Buffer.from(content, "utf-8");
128
+ if (existing.length <= slot.length) return slot.toString("utf-8");
129
+ return Buffer.concat([slot, existing.subarray(slot.length)]).toString("utf-8");
130
+ }
131
+
132
+ /** Replace the physical fixed-width title slot in a prefix byte window. */
133
+ export function overlayTitleSlotPrefix(prefix: string, prefixBytes: number, update: SessionTitleUpdate): string {
134
+ if (prefixBytes <= 0) return "";
135
+ const slot = Buffer.from(serializeTitleSlot(update), "utf-8");
136
+ if (prefixBytes <= slot.length) return slot.subarray(0, prefixBytes).toString("utf-8");
137
+ const existing = Buffer.from(prefix, "utf-8");
138
+ return Buffer.concat([slot, existing.subarray(slot.length)])
139
+ .subarray(0, prefixBytes)
140
+ .toString("utf-8");
141
+ }
@@ -3,6 +3,7 @@ import {
3
3
  type SessionStorageBackend,
4
4
  type SessionStorageIndexEntry,
5
5
  } from "./indexed-session-storage";
6
+ import type { SessionTitleUpdate } from "./session-title-slot";
6
7
 
7
8
  /**
8
9
  * Supported `bun:sql` adapter dialects. `Bun.SQL` reports this string on
@@ -55,10 +56,14 @@ export interface SqlSessionStorageOptions {
55
56
 
56
57
  interface DialectQueries {
57
58
  createTable: string;
59
+ /** Add title metadata columns to existing tables created before title fields existed. */
60
+ addTitleColumns: readonly string[];
58
61
  /** Insert or replace the full content for `path`. Used for `writeText`/`flags="w"` truncate. */
59
62
  upsertReplace: string;
60
63
  /** Insert if missing; otherwise append the new chunk to existing content. Used for `writeLine`. */
61
64
  upsertAppend: string;
65
+ /** Update indexed title metadata without rewriting the JSONL body. */
66
+ updateTitle: string;
62
67
  /** Delete a single row by path. */
63
68
  delete: string;
64
69
  /** Move a row from one path to another (caller deletes any conflicting destination first). */
@@ -75,6 +80,9 @@ interface IndexRow {
75
80
  path: string;
76
81
  byte_len: number | bigint | string;
77
82
  mtime_ms: number | bigint | string;
83
+ title?: string | null;
84
+ title_source?: string | null;
85
+ title_updated_at?: string | null;
78
86
  }
79
87
 
80
88
  interface ContentRow {
@@ -119,17 +127,26 @@ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): Dialect
119
127
  `CREATE TABLE IF NOT EXISTS ${table} (` +
120
128
  `path VARCHAR(512) NOT NULL PRIMARY KEY, ` +
121
129
  `content LONGTEXT NOT NULL, ` +
122
- `mtime_ms BIGINT NOT NULL` +
130
+ `mtime_ms BIGINT NOT NULL, ` +
131
+ `title TEXT NULL, ` +
132
+ `title_source VARCHAR(16) NULL, ` +
133
+ `title_updated_at VARCHAR(64) NULL` +
123
134
  `) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`,
135
+ addTitleColumns: [
136
+ `ALTER TABLE ${table} ADD COLUMN title TEXT NULL`,
137
+ `ALTER TABLE ${table} ADD COLUMN title_source VARCHAR(16) NULL`,
138
+ `ALTER TABLE ${table} ADD COLUMN title_updated_at VARCHAR(64) NULL`,
139
+ ],
124
140
  upsertReplace:
125
- `INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
126
- `ON DUPLICATE KEY UPDATE content = VALUES(content), mtime_ms = VALUES(mtime_ms)`,
141
+ `INSERT INTO ${table} (path, content, mtime_ms, title, title_source, title_updated_at) VALUES (?, ?, ?, ?, ?, ?) ` +
142
+ `ON DUPLICATE KEY UPDATE content = VALUES(content), mtime_ms = VALUES(mtime_ms), title = VALUES(title), title_source = VALUES(title_source), title_updated_at = VALUES(title_updated_at)`,
127
143
  upsertAppend:
128
144
  `INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
129
145
  `ON DUPLICATE KEY UPDATE content = CONCAT(content, VALUES(content)), mtime_ms = VALUES(mtime_ms)`,
146
+ updateTitle: `UPDATE ${table} SET title = ?, title_source = ?, title_updated_at = ?, mtime_ms = ? WHERE path = ?`,
130
147
  delete: `DELETE FROM ${table} WHERE path = ?`,
131
148
  rename: `UPDATE ${table} SET path = ?, mtime_ms = ? WHERE path = ?`,
132
- loadIndex: `SELECT path, mtime_ms, length(content) AS byte_len FROM ${table}`,
149
+ loadIndex: `SELECT path, mtime_ms, length(content) AS byte_len, title, title_source, title_updated_at FROM ${table}`,
133
150
  readFull: `SELECT content AS content FROM ${table} WHERE path = ?`,
134
151
  readSlices:
135
152
  `SELECT substring(cast(content AS binary), 1, ?) AS head, ` +
@@ -157,19 +174,28 @@ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): Dialect
157
174
  `CREATE TABLE IF NOT EXISTS ${table} (` +
158
175
  `path TEXT PRIMARY KEY, ` +
159
176
  `content TEXT NOT NULL, ` +
160
- `mtime_ms ${mtimeType} NOT NULL` +
177
+ `mtime_ms ${mtimeType} NOT NULL, ` +
178
+ `title TEXT, ` +
179
+ `title_source TEXT, ` +
180
+ `title_updated_at TEXT` +
161
181
  `)`,
182
+ addTitleColumns: [
183
+ `ALTER TABLE ${table} ADD COLUMN title TEXT`,
184
+ `ALTER TABLE ${table} ADD COLUMN title_source TEXT`,
185
+ `ALTER TABLE ${table} ADD COLUMN title_updated_at TEXT`,
186
+ ],
162
187
  upsertReplace:
163
- `INSERT INTO ${table} (path, content, mtime_ms) ` +
164
- `VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
165
- `ON CONFLICT (path) DO UPDATE SET content = excluded.content, mtime_ms = excluded.mtime_ms`,
188
+ `INSERT INTO ${table} (path, content, mtime_ms, title, title_source, title_updated_at) ` +
189
+ `VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}, ${placeholder(4)}, ${placeholder(5)}, ${placeholder(6)}) ` +
190
+ `ON CONFLICT (path) DO UPDATE SET content = excluded.content, mtime_ms = excluded.mtime_ms, title = excluded.title, title_source = excluded.title_source, title_updated_at = excluded.title_updated_at`,
166
191
  upsertAppend:
167
192
  `INSERT INTO ${table} (path, content, mtime_ms) ` +
168
193
  `VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
169
194
  `ON CONFLICT (path) DO UPDATE SET content = ${tableQualifier} || excluded.content, mtime_ms = excluded.mtime_ms`,
195
+ updateTitle: `UPDATE ${table} SET title = ${placeholder(1)}, title_source = ${placeholder(2)}, title_updated_at = ${placeholder(3)}, mtime_ms = ${placeholder(4)} WHERE path = ${placeholder(5)}`,
170
196
  delete: `DELETE FROM ${table} WHERE path = ${placeholder(1)}`,
171
197
  rename: `UPDATE ${table} SET path = ${placeholder(1)}, mtime_ms = ${placeholder(2)} WHERE path = ${placeholder(3)}`,
172
- loadIndex: `SELECT path, mtime_ms, ${byteLengthExpr} AS byte_len FROM ${table}`,
198
+ loadIndex: `SELECT path, mtime_ms, ${byteLengthExpr} AS byte_len, title, title_source, title_updated_at FROM ${table}`,
173
199
  readFull: `SELECT content AS content FROM ${table} WHERE path = ${placeholder(1)}`,
174
200
  readSlices,
175
201
  };
@@ -180,6 +206,13 @@ function rowNumber(value: number | bigint | string): number {
180
206
  if (typeof value === "bigint") return Number(value);
181
207
  return Number.parseInt(value, 10);
182
208
  }
209
+ function rowTitleSource(value: string | null | undefined): SessionTitleUpdate["source"] | undefined {
210
+ return value === "auto" || value === "user" ? value : undefined;
211
+ }
212
+ function isDuplicateColumnError(error: unknown): boolean {
213
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
214
+ return message.includes("duplicate column") || message.includes("already exists");
215
+ }
183
216
 
184
217
  function decodeSqlBytes(value: unknown): string {
185
218
  if (value === null || value === undefined) return "";
@@ -260,6 +293,13 @@ class SqlSessionStorageBackend implements SessionStorageBackend {
260
293
  async init(): Promise<void> {
261
294
  if (this.#createTable) {
262
295
  await this.#client.unsafe(this.#q.createTable);
296
+ for (const query of this.#q.addTitleColumns) {
297
+ try {
298
+ await this.#client.unsafe(query);
299
+ } catch (err) {
300
+ if (!isDuplicateColumnError(err)) throw err;
301
+ }
302
+ }
263
303
  }
264
304
  }
265
305
 
@@ -269,6 +309,9 @@ class SqlSessionStorageBackend implements SessionStorageBackend {
269
309
  path: row.path,
270
310
  size: rowNumber(row.byte_len),
271
311
  mtimeMs: rowNumber(row.mtime_ms),
312
+ title: row.title ?? undefined,
313
+ titleSource: rowTitleSource(row.title_source),
314
+ titleUpdatedAt: row.title_updated_at ?? undefined,
272
315
  }));
273
316
  }
274
317
 
@@ -289,8 +332,25 @@ class SqlSessionStorageBackend implements SessionStorageBackend {
289
332
  return [decodeSqlBytes(row.head), decodeSqlBytes(row.tail)];
290
333
  }
291
334
 
292
- async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
293
- await this.#client.unsafe(this.#q.upsertReplace, [path, content, mtimeMs]);
335
+ async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
336
+ await this.#client.unsafe(this.#q.upsertReplace, [
337
+ path,
338
+ content,
339
+ mtimeMs,
340
+ title?.title ?? null,
341
+ title?.source ?? null,
342
+ title?.updatedAt ?? null,
343
+ ]);
344
+ }
345
+
346
+ async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
347
+ await this.#client.unsafe(this.#q.updateTitle, [
348
+ title.title ?? null,
349
+ title.source ?? null,
350
+ title.updatedAt,
351
+ mtimeMs,
352
+ path,
353
+ ]);
294
354
  }
295
355
 
296
356
  async append(path: string, line: string, mtimeMs: number): Promise<void> {
@@ -6,6 +6,7 @@ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
6
6
  import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
7
7
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
8
8
  import { CollabHost } from "../collab/host";
9
+ import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
9
10
  import type { SettingPath, SettingValue } from "../config/settings";
10
11
  import { settings } from "../config/settings";
11
12
  import {
@@ -29,7 +30,6 @@ import type { InteractiveModeContext } from "../modes/types";
29
30
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
30
31
  import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
31
32
  import { resolveResumableSession } from "../session/session-listing";
32
- import { SessionManager } from "../session/session-manager";
33
33
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
34
34
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
35
35
  import { urlHyperlinkAlways } from "../tui";
@@ -438,16 +438,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
438
438
  name: "advisor",
439
439
  description: "Toggle the advisor (a second model that reviews each turn and injects notes)",
440
440
  acpDescription: "Toggle advisor",
441
- acpInputHint: "[on|off|status|dump [raw]]",
441
+ acpInputHint: "[on|off|status|dump [raw]|configure]",
442
442
  subcommands: [
443
443
  { name: "on", description: "Enable the advisor" },
444
444
  { name: "off", description: "Disable the advisor" },
445
445
  { name: "status", description: "Show advisor status" },
446
446
  { name: "dump", description: "Copy the advisor's transcript to clipboard", usage: "[raw]" },
447
+ { name: "configure", description: "Open the advisor configuration editor (TUI)" },
447
448
  ],
448
449
  allowArgs: true,
449
450
  getTuiAutocompleteDescription: runtime => {
450
451
  const stats = runtime.ctx.session.getAdvisorStats();
452
+ if (stats.active && stats.advisors.length > 1) return `Advisor: on (${stats.advisors.length} advisors)`;
451
453
  if (stats.active && stats.model) return `Advisor: on (${stats.model.provider}/${stats.model.id})`;
452
454
  if (stats.configured) return "Advisor: configured, no model";
453
455
  return "Advisor: off";
@@ -488,7 +490,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
488
490
  await runtime.output(text ?? "Advisor is not active for this session.");
489
491
  return commandConsumed();
490
492
  }
491
- return usage("Usage: /advisor [on|off|status|dump [raw]]", runtime);
493
+ if (verb === "configure") {
494
+ await runtime.output(
495
+ "/advisor configure opens an interactive editor and is only available in the interactive TUI.",
496
+ );
497
+ return commandConsumed();
498
+ }
499
+ return usage("Usage: /advisor [on|off|status|dump [raw]|configure]", runtime);
492
500
  },
493
501
  handleTui: async (command, runtime) => {
494
502
  const { verb, rest } = parseSubcommand(command.args);
@@ -533,7 +541,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
533
541
  runtime.ctx.editor.setText("");
534
542
  return;
535
543
  }
536
- runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]]");
544
+ if (verb === "configure") {
545
+ runtime.ctx.showAdvisorConfigure();
546
+ runtime.ctx.editor.setText("");
547
+ return;
548
+ }
549
+ runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]|configure]");
537
550
  runtime.ctx.editor.setText("");
538
551
  },
539
552
  },
@@ -1593,8 +1606,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1593
1606
  },
1594
1607
  {
1595
1608
  name: "move",
1596
- description: "Switch to a fresh session in a different directory",
1597
- acpDescription: "Start a fresh session in a different directory",
1609
+ description: "Move the current session to a different directory",
1610
+ acpDescription: "Move the current session to a different directory",
1598
1611
  inlineHint: "[<path>]",
1599
1612
  allowArgs: true,
1600
1613
  handle: async (command, runtime) => {
@@ -1609,32 +1622,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1609
1622
  } catch {
1610
1623
  return usage(`Directory does not exist: ${resolvedPath}`, runtime);
1611
1624
  }
1612
- let newSessionFile: string | undefined;
1613
1625
  try {
1614
- newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
1615
- const switched = await runtime.session.switchSession(newSessionFile);
1616
- if (!switched) {
1617
- await runtime.sessionManager.dropSession(newSessionFile);
1618
- return usage("Move cancelled.", runtime);
1619
- }
1626
+ await runtime.sessionManager.moveTo(resolvedPath);
1620
1627
  } catch (err) {
1621
- if (newSessionFile) {
1622
- try {
1623
- await runtime.sessionManager.dropSession(newSessionFile);
1624
- } catch (dropErr) {
1625
- return usage(
1626
- `Move failed: ${errorMessage(err)}; failed to remove empty session: ${errorMessage(dropErr)}`,
1627
- runtime,
1628
- );
1629
- }
1630
- }
1631
1628
  return usage(`Move failed: ${errorMessage(err)}`, runtime);
1632
1629
  }
1633
- runtime.session.markMovedFromEmptySessionFile(newSessionFile!);
1634
1630
  setProjectDir(resolvedPath);
1631
+ await runtime.settings.reloadForCwd(resolvedPath);
1632
+ applyProviderGlobalsFromSettings(runtime.settings);
1635
1633
  // Reload plugin/capability caches so the next prompt sees commands and
1636
1634
  // capabilities scoped to the new cwd.
1637
1635
  await runtime.reloadPlugins();
1636
+ await runtime.notifyConfigChanged?.();
1638
1637
  await runtime.notifyTitleChanged?.();
1639
1638
  await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
1640
1639
  return commandConsumed();