@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.
- package/CHANGELOG.md +60 -0
- package/dist/cli.js +3682 -3615
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/config/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +35 -1
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +11 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +9 -3
- package/dist/types/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +8 -7
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +242 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/autolearn/controller.ts +13 -22
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/guest.ts +43 -7
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-registry.ts +80 -18
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +77 -0
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/edit/hashline/diff.ts +57 -4
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/manager.ts +2 -1
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +130 -15
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +150 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +27 -36
- package/src/modes/controllers/event-controller.ts +113 -1
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +74 -25
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +9 -3
- package/src/modes/utils/ui-helpers.ts +41 -23
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +28 -24
- package/src/session/agent-session.ts +899 -429
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +115 -22
- package/src/session/session-persistence.ts +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +23 -24
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- 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, [
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
1597
|
-
acpDescription: "
|
|
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
|
-
|
|
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();
|