@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.3
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 +29 -1
- package/dist/types/cli/dry-balance-cli.d.ts +104 -0
- package/dist/types/commands/dry-balance.d.ts +31 -0
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/settings.d.ts +11 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
- package/dist/types/hindsight/bank.d.ts +17 -9
- package/dist/types/hindsight/mental-models.d.ts +1 -1
- package/dist/types/hindsight/state.d.ts +9 -3
- package/dist/types/mcp/manager.d.ts +1 -1
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage.d.ts +2 -2
- package/dist/types/task/types.d.ts +2 -0
- package/dist/types/tools/index.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +11 -0
- package/package.json +9 -9
- package/src/cli/dry-balance-cli.ts +823 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/dry-balance.ts +43 -0
- package/src/config/model-registry.ts +6 -0
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings.ts +38 -0
- package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
- package/src/discovery/github.ts +37 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
- package/src/hindsight/backend.ts +184 -35
- package/src/hindsight/bank.ts +32 -22
- package/src/hindsight/mental-models.ts +1 -1
- package/src/hindsight/state.ts +21 -7
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/omp-protocol.ts +8 -2
- package/src/mcp/manager.ts +40 -21
- package/src/modes/components/transcript-container.ts +14 -3
- package/src/modes/components/tree-selector.ts +29 -2
- package/src/modes/controllers/input-controller.ts +8 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
- package/src/prompts/agents/explore.md +1 -0
- package/src/prompts/agents/librarian.md +1 -0
- package/src/prompts/dry-balance-bench.md +8 -0
- package/src/sdk.ts +82 -9
- package/src/session/agent-session.ts +66 -7
- package/src/session/auth-storage.ts +4 -0
- package/src/task/executor.ts +6 -2
- package/src/task/index.ts +8 -7
- package/src/task/types.ts +2 -0
- package/src/tools/bash.ts +3 -4
- package/src/tools/index.ts +16 -0
- package/src/tools/job.ts +3 -3
- package/src/tools/memory-reflect.ts +2 -2
- package/src/tools/path-utils.ts +21 -0
- package/src/tools/search.ts +18 -1
- package/src/utils/file-mentions.ts +7 -107
- package/src/utils/title-generator.ts +58 -37
|
@@ -12,12 +12,16 @@ export type {
|
|
|
12
12
|
AuthStorageOptions,
|
|
13
13
|
OAuthCredential,
|
|
14
14
|
SerializedAuthStorage,
|
|
15
|
+
SnapshotResponse,
|
|
15
16
|
StoredAuthCredential,
|
|
16
17
|
} from "@oh-my-pi/pi-ai";
|
|
17
18
|
export {
|
|
18
19
|
AuthBrokerClient,
|
|
19
20
|
AuthStorage,
|
|
21
|
+
DEFAULT_SNAPSHOT_CACHE_TTL_MS,
|
|
20
22
|
REMOTE_REFRESH_SENTINEL,
|
|
21
23
|
RemoteAuthCredentialStore,
|
|
24
|
+
readAuthBrokerSnapshotCache,
|
|
22
25
|
SqliteAuthCredentialStore,
|
|
26
|
+
writeAuthBrokerSnapshotCache,
|
|
23
27
|
} from "@oh-my-pi/pi-ai";
|
package/src/task/executor.ts
CHANGED
|
@@ -531,7 +531,7 @@ function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
|
|
|
531
531
|
});
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
-
function createSubagentSettings(baseSettings: Settings): Settings {
|
|
534
|
+
function createSubagentSettings(baseSettings: Settings, overrides?: Partial<Record<SettingPath, unknown>>): Settings {
|
|
535
535
|
const snapshot: Partial<Record<SettingPath, unknown>> = {};
|
|
536
536
|
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
|
|
537
537
|
snapshot[key] = baseSettings.get(key);
|
|
@@ -545,6 +545,7 @@ function createSubagentSettings(baseSettings: Settings): Settings {
|
|
|
545
545
|
// the parent task approval is the authorization boundary. Use yolo mode
|
|
546
546
|
// to preserve unattended subagent execution. User `tools.approval` policies still apply.
|
|
547
547
|
"tools.approvalMode": "yolo",
|
|
548
|
+
...overrides,
|
|
548
549
|
});
|
|
549
550
|
}
|
|
550
551
|
|
|
@@ -619,7 +620,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
619
620
|
}
|
|
620
621
|
|
|
621
622
|
const settings = options.settings ?? Settings.isolated();
|
|
622
|
-
const subagentSettings = createSubagentSettings(
|
|
623
|
+
const subagentSettings = createSubagentSettings(
|
|
624
|
+
settings,
|
|
625
|
+
agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
|
|
626
|
+
);
|
|
623
627
|
const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
|
|
624
628
|
const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
|
|
625
629
|
const parentDepth = options.taskDepth ?? 0;
|
package/src/task/index.ts
CHANGED
|
@@ -17,9 +17,8 @@ import * as os from "node:os";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
19
19
|
import type { Usage } from "@oh-my-pi/pi-ai";
|
|
20
|
-
import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
|
20
|
+
import { $env, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
|
|
21
21
|
import type { ToolSession } from "..";
|
|
22
|
-
import { AsyncJobManager } from "../async";
|
|
23
22
|
import { resolveAgentModelPatterns } from "../config/model-resolver";
|
|
24
23
|
import { MCPManager } from "../mcp/manager";
|
|
25
24
|
import type { Theme } from "../modes/theme/theme";
|
|
@@ -343,12 +342,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
343
342
|
return this.#executeSync(_toolCallId, params, signal, onUpdate);
|
|
344
343
|
}
|
|
345
344
|
|
|
346
|
-
const manager =
|
|
345
|
+
const manager = this.session.asyncJobManager;
|
|
347
346
|
if (!manager) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
347
|
+
// Async was requested but no manager is registered (e.g. an
|
|
348
|
+
// orphaned session whose host never wired one up). Falling back
|
|
349
|
+
// to the sync path keeps the tool usable; only background/job-poll
|
|
350
|
+
// semantics are lost.
|
|
351
|
+
logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
|
|
352
|
+
return this.#executeSync(_toolCallId, params, signal, onUpdate);
|
|
352
353
|
}
|
|
353
354
|
|
|
354
355
|
const taskItems = params.tasks ?? [];
|
package/src/task/types.ts
CHANGED
|
@@ -174,6 +174,8 @@ export interface AgentDefinition {
|
|
|
174
174
|
output?: unknown;
|
|
175
175
|
blocking?: boolean;
|
|
176
176
|
autoloadSkills?: string[];
|
|
177
|
+
/** When `false`, the agent's `read` tool returns verbatim file content instead of structural summaries. */
|
|
178
|
+
readSummarize?: boolean;
|
|
177
179
|
source: AgentSource;
|
|
178
180
|
filePath?: string;
|
|
179
181
|
}
|
package/src/tools/bash.ts
CHANGED
|
@@ -10,7 +10,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
10
10
|
import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
11
11
|
import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
|
|
12
12
|
import * as z from "zod/v4";
|
|
13
|
-
import { AsyncJobManager } from "../async";
|
|
14
13
|
import { type BashResult, executeBash } from "../exec/bash-executor";
|
|
15
14
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
16
15
|
import { InternalUrlRouter } from "../internal-urls";
|
|
@@ -489,7 +488,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
489
488
|
onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
|
|
490
489
|
startBackgrounded: boolean;
|
|
491
490
|
}): ManagedBashJobHandle {
|
|
492
|
-
const manager =
|
|
491
|
+
const manager = this.session.asyncJobManager;
|
|
493
492
|
if (!manager) {
|
|
494
493
|
throw new ToolError("Background job manager unavailable for this session.");
|
|
495
494
|
}
|
|
@@ -716,7 +715,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
716
715
|
if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
|
|
717
716
|
|
|
718
717
|
if (asyncRequested) {
|
|
719
|
-
if (!
|
|
718
|
+
if (!this.session.asyncJobManager) {
|
|
720
719
|
throw new ToolError("Async job manager unavailable for this session.");
|
|
721
720
|
}
|
|
722
721
|
const job = this.#startManagedBashJob({
|
|
@@ -737,7 +736,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
737
736
|
});
|
|
738
737
|
}
|
|
739
738
|
|
|
740
|
-
const autoBgManager =
|
|
739
|
+
const autoBgManager = this.session.asyncJobManager;
|
|
741
740
|
if (this.#autoBackgroundEnabled && !pty && autoBgManager) {
|
|
742
741
|
const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
|
|
743
742
|
const startBackgrounded = autoBackgroundWaitMs === 0;
|
package/src/tools/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
|
2
2
|
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
3
3
|
import type { ToolChoice } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import type { AsyncJobManager } from "../async/job-manager";
|
|
5
6
|
import type { PromptTemplate } from "../config/prompt-templates";
|
|
6
7
|
import type { Settings } from "../config/settings";
|
|
7
8
|
import { EditTool } from "../edit";
|
|
@@ -183,6 +184,21 @@ export interface ToolSession {
|
|
|
183
184
|
modelRegistry?: import("../config/model-registry").ModelRegistry;
|
|
184
185
|
/** Agent output manager for unique agent:// IDs across task invocations */
|
|
185
186
|
agentOutputManager?: AgentOutputManager;
|
|
187
|
+
/**
|
|
188
|
+
* Async job manager scoped to this session.
|
|
189
|
+
*
|
|
190
|
+
* - Top-level session that constructed one: its own manager.
|
|
191
|
+
* - Subagent (`parentTaskPrefix` set): the parent's manager, so background
|
|
192
|
+
* bash/task work and `onJobComplete` deliveries flow into the conversation
|
|
193
|
+
* that spawned it.
|
|
194
|
+
* - Secondary in-process top-level session that found a singleton already
|
|
195
|
+
* installed (issue #1923): `undefined`. Tools refuse async work rather
|
|
196
|
+
* than silently route completions into the owning session's `yieldQueue`.
|
|
197
|
+
*
|
|
198
|
+
* Tools MUST use this instead of `AsyncJobManager.instance()` so a secondary
|
|
199
|
+
* session never borrows the owning session's manager by accident.
|
|
200
|
+
*/
|
|
201
|
+
asyncJobManager?: AsyncJobManager;
|
|
186
202
|
/** MCP manager visible to subagents without relying on the process-global singleton. */
|
|
187
203
|
mcpManager?: MCPManager;
|
|
188
204
|
/** Local protocol root to propagate to nested subagents and eval-created agents. */
|
package/src/tools/job.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
|
|
|
3
3
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
4
4
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import * as z from "zod/v4";
|
|
6
|
-
import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
|
|
6
|
+
import { type AsyncJob, type AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
|
|
7
7
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
8
8
|
import type { Theme } from "../modes/theme/theme";
|
|
9
9
|
import jobDescription from "../prompts/tools/job.md" with { type: "text" };
|
|
@@ -90,7 +90,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
|
|
|
90
90
|
onUpdate?: AgentToolUpdateCallback<JobToolDetails>,
|
|
91
91
|
_context?: AgentToolContext,
|
|
92
92
|
): Promise<AgentToolResult<JobToolDetails>> {
|
|
93
|
-
const manager =
|
|
93
|
+
const manager = this.session.asyncJobManager;
|
|
94
94
|
if (!manager) {
|
|
95
95
|
return {
|
|
96
96
|
content: [{ type: "text", text: "Async execution is disabled; no background jobs are available." }],
|
|
@@ -254,7 +254,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
|
|
|
254
254
|
): JobSnapshot[] {
|
|
255
255
|
const now = Date.now();
|
|
256
256
|
return jobs.map(j => {
|
|
257
|
-
const current =
|
|
257
|
+
const current = this.session.asyncJobManager?.getJob(j.id);
|
|
258
258
|
const latest = current ?? j;
|
|
259
259
|
return {
|
|
260
260
|
id: latest.id,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import * as z from "zod/v4";
|
|
4
|
-
import {
|
|
4
|
+
import { ensureBankExists } from "../hindsight/bank";
|
|
5
5
|
import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" };
|
|
6
6
|
import type { ToolSession } from ".";
|
|
7
7
|
|
|
@@ -67,7 +67,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
try {
|
|
70
|
-
await
|
|
70
|
+
await ensureBankExists(state.client, state.bankId, state.config, state.banksSet);
|
|
71
71
|
const response = await state.client.reflect(state.bankId, params.query, {
|
|
72
72
|
context: params.context,
|
|
73
73
|
budget: state.config.recallBudget,
|
package/src/tools/path-utils.ts
CHANGED
|
@@ -217,6 +217,27 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
|
|
|
217
217
|
return merged as [LineRange, ...LineRange[]];
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Extract the line-range component from a read-tool selector that may also
|
|
222
|
+
* carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
|
|
223
|
+
* with a range (`raw:50-100`, `50-100:raw`). Returns the parsed ranges when the
|
|
224
|
+
* selector names any, otherwise `undefined` (pure `raw`/`conflicts`/none).
|
|
225
|
+
*
|
|
226
|
+
* Used by content search, which honors line ranges as a match filter but has no
|
|
227
|
+
* use for verbatim/conflict display modes — so those selectors are accepted and
|
|
228
|
+
* treated as an unfiltered, whole-resource search rather than rejected.
|
|
229
|
+
*/
|
|
230
|
+
export function selectorLineRanges(sel: string | undefined): [LineRange, ...LineRange[]] | undefined {
|
|
231
|
+
if (!sel) return undefined;
|
|
232
|
+
for (const chunk of sel.split(":")) {
|
|
233
|
+
const lower = chunk.toLowerCase();
|
|
234
|
+
if (lower === "raw" || lower === "conflicts") continue;
|
|
235
|
+
const ranges = parseLineRanges(chunk);
|
|
236
|
+
if (ranges) return ranges;
|
|
237
|
+
}
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
220
241
|
/** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
|
|
221
242
|
export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
|
|
222
243
|
for (const range of ranges) {
|
package/src/tools/search.ts
CHANGED
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
type ResolvedSearchTarget,
|
|
39
39
|
resolveReadPath,
|
|
40
40
|
resolveToolSearchScope,
|
|
41
|
+
selectorLineRanges,
|
|
42
|
+
splitInternalUrlSel,
|
|
41
43
|
splitPathAndSel,
|
|
42
44
|
} from "./path-utils";
|
|
43
45
|
import {
|
|
@@ -109,6 +111,21 @@ interface SearchPathSpec {
|
|
|
109
111
|
function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
|
|
110
112
|
const specs: SearchPathSpec[] = [];
|
|
111
113
|
for (const entry of rawEntries) {
|
|
114
|
+
// Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
|
|
115
|
+
// which peels selector-shaped tails only for selector-capable schemes and
|
|
116
|
+
// leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
|
|
117
|
+
// verbatim/index display modes (`raw`, `conflicts`) carry no meaning for
|
|
118
|
+
// content search, so we accept them — searching the whole resource — and
|
|
119
|
+
// still honor any embedded line range as a match filter.
|
|
120
|
+
const internalSplit = splitInternalUrlSel(entry);
|
|
121
|
+
if (internalSplit.sel !== undefined) {
|
|
122
|
+
specs.push({
|
|
123
|
+
original: entry,
|
|
124
|
+
clean: internalSplit.path,
|
|
125
|
+
ranges: selectorLineRanges(internalSplit.sel),
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
112
129
|
const split = splitPathAndSel(entry);
|
|
113
130
|
let clean = entry;
|
|
114
131
|
let ranges: [LineRange, ...LineRange[]] | undefined;
|
|
@@ -259,7 +276,7 @@ interface IndexedContentLines {
|
|
|
259
276
|
}
|
|
260
277
|
|
|
261
278
|
const INTERNAL_URL_DISPLAY_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
262
|
-
const OMP_ROOT_URL_RE = /^omp
|
|
279
|
+
const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i;
|
|
263
280
|
|
|
264
281
|
function normalizeSearchLine(line: string): string {
|
|
265
282
|
return line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
@@ -10,8 +10,6 @@ import path from "node:path";
|
|
|
10
10
|
import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
|
|
11
11
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
12
12
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
13
|
-
import { glob } from "@oh-my-pi/pi-natives";
|
|
14
|
-
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
|
|
15
13
|
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
|
16
14
|
import { normalizeToLF } from "../edit/normalize";
|
|
17
15
|
import type { FileMentionMessage } from "../session/messages";
|
|
@@ -30,27 +28,6 @@ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
|
|
|
30
28
|
const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
|
|
31
29
|
const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
|
|
32
30
|
const DEFAULT_DIR_LIMIT = 500;
|
|
33
|
-
const MIN_FUZZY_QUERY_LENGTH = 5;
|
|
34
|
-
const MAX_RESOLUTION_CANDIDATES = 20_000;
|
|
35
|
-
const PATH_SEPARATOR_REGEX = /[/._\-\s]+/g;
|
|
36
|
-
|
|
37
|
-
type MentionDiscoveryProfile = {
|
|
38
|
-
hidden: boolean;
|
|
39
|
-
gitignore: boolean;
|
|
40
|
-
includeNodeModules: boolean;
|
|
41
|
-
maxResults: number;
|
|
42
|
-
cache: boolean;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
function getMentionCandidateDiscoveryProfile(): MentionDiscoveryProfile {
|
|
46
|
-
return {
|
|
47
|
-
hidden: true,
|
|
48
|
-
gitignore: true,
|
|
49
|
-
cache: true,
|
|
50
|
-
includeNodeModules: true,
|
|
51
|
-
maxResults: MAX_RESOLUTION_CANDIDATES,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
31
|
|
|
55
32
|
// Avoid OOM when users @mention very large files. Above these limits we skip
|
|
56
33
|
// auto-reading and only include the path in the message.
|
|
@@ -70,16 +47,6 @@ function sanitizeMentionPath(rawPath: string): string | null {
|
|
|
70
47
|
return cleaned.length > 0 ? cleaned : null;
|
|
71
48
|
}
|
|
72
49
|
|
|
73
|
-
type MentionCandidate = {
|
|
74
|
-
path: string;
|
|
75
|
-
pathLower: string;
|
|
76
|
-
normalizedPath: string;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
function normalizeMentionQuery(query: string): string {
|
|
80
|
-
return query.toLowerCase().replace(PATH_SEPARATOR_REGEX, "");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
50
|
async function pathExists(filePath: string): Promise<boolean> {
|
|
84
51
|
try {
|
|
85
52
|
await Bun.file(filePath).stat();
|
|
@@ -89,75 +56,13 @@ async function pathExists(filePath: string): Promise<boolean> {
|
|
|
89
56
|
}
|
|
90
57
|
}
|
|
91
58
|
|
|
92
|
-
async function
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
pattern: "**/*",
|
|
98
|
-
path: cwd,
|
|
99
|
-
...discoveryProfile,
|
|
100
|
-
});
|
|
101
|
-
entries = result.matches.map(match => match.path);
|
|
102
|
-
} catch {
|
|
103
|
-
return [];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
107
|
-
const candidates: MentionCandidate[] = [];
|
|
108
|
-
for (const entry of entries) {
|
|
109
|
-
const pathLower = entry.toLowerCase();
|
|
110
|
-
const normalizedPath = normalizeMentionQuery(entry);
|
|
111
|
-
if (normalizedPath.length === 0) {
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
candidates.push({ path: entry, pathLower, normalizedPath });
|
|
115
|
-
}
|
|
116
|
-
return candidates;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function resolveMentionPath(
|
|
120
|
-
filePath: string,
|
|
121
|
-
cwd: string,
|
|
122
|
-
getMentionCandidates: () => Promise<MentionCandidate[]>,
|
|
123
|
-
): Promise<string | null> {
|
|
59
|
+
async function resolveMentionPath(filePath: string, cwd: string): Promise<string | null> {
|
|
60
|
+
// Exact resolution only. The TUI @-selector inserts the real, complete path, so a
|
|
61
|
+
// mention that does not resolve to an existing file or directory is prose, not a file
|
|
62
|
+
// reference. Fuzzy/prefix guessing here previously dragged in unrelated same-named
|
|
63
|
+
// files; that disambiguation belongs to the selector's display, not post-send.
|
|
124
64
|
const absolutePath = resolveReadPath(filePath, cwd);
|
|
125
|
-
|
|
126
|
-
return filePath;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const queryLower = filePath.toLowerCase();
|
|
130
|
-
const candidates = await getMentionCandidates();
|
|
131
|
-
const prefixMatches = candidates.filter(candidate => candidate.pathLower.startsWith(queryLower));
|
|
132
|
-
if (prefixMatches.length === 1) {
|
|
133
|
-
return prefixMatches[0]?.path ?? null;
|
|
134
|
-
}
|
|
135
|
-
if (prefixMatches.length > 1) {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const normalizedQuery = normalizeMentionQuery(filePath);
|
|
140
|
-
if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const scored = candidates
|
|
145
|
-
.map(candidate => ({ candidate, match: fuzzyMatch(normalizedQuery, candidate.normalizedPath) }))
|
|
146
|
-
.filter(entry => entry.match.matches)
|
|
147
|
-
.sort((a, b) => {
|
|
148
|
-
if (a.match.score !== b.match.score) {
|
|
149
|
-
return a.match.score - b.match.score;
|
|
150
|
-
}
|
|
151
|
-
return a.candidate.path.localeCompare(b.candidate.path);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
if (scored.length === 0) {
|
|
155
|
-
return null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const best = scored[0];
|
|
159
|
-
|
|
160
|
-
return best?.candidate.path ?? null;
|
|
65
|
+
return (await pathExists(absolutePath)) ? filePath : null;
|
|
161
66
|
}
|
|
162
67
|
|
|
163
68
|
function buildTextOutput(textContent: string): { output: string; lineCount: number } {
|
|
@@ -285,14 +190,9 @@ export async function generateFileMentionMessages(
|
|
|
285
190
|
const autoResizeImages = options?.autoResizeImages ?? true;
|
|
286
191
|
|
|
287
192
|
const files: FileMentionMessage["files"] = [];
|
|
288
|
-
let mentionCandidatesPromise: Promise<MentionCandidate[]> | null = null;
|
|
289
|
-
const getMentionCandidates = (): Promise<MentionCandidate[]> => {
|
|
290
|
-
mentionCandidatesPromise ??= listMentionCandidates(cwd);
|
|
291
|
-
return mentionCandidatesPromise;
|
|
292
|
-
};
|
|
293
193
|
|
|
294
194
|
for (const filePath of filePaths) {
|
|
295
|
-
const resolvedPath = await resolveMentionPath(filePath, cwd
|
|
195
|
+
const resolvedPath = await resolveMentionPath(filePath, cwd);
|
|
296
196
|
if (!resolvedPath) {
|
|
297
197
|
continue;
|
|
298
198
|
}
|
|
@@ -149,7 +149,10 @@ export async function generateSessionTitle(
|
|
|
149
149
|
// tiny title model can't reliably decline trivial input, so this happens
|
|
150
150
|
// deterministically before any model is invoked; the caller retries on the
|
|
151
151
|
// next user message while the session stays unnamed.
|
|
152
|
-
if (isLowSignalTitleInput(firstMessage))
|
|
152
|
+
if (isLowSignalTitleInput(firstMessage)) {
|
|
153
|
+
logger.debug("title-generator: skipped low-signal input", { sessionId, reason: "low-signal" });
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
153
156
|
|
|
154
157
|
const tinyModel = settings.get("providers.tinyModel");
|
|
155
158
|
if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
|
|
@@ -159,7 +162,14 @@ export async function generateSessionTitle(
|
|
|
159
162
|
const onlineAbortController = new AbortController();
|
|
160
163
|
const localTitle = tinyTitleClient.generate(tinyModel, firstMessage).then(
|
|
161
164
|
title => title || null,
|
|
162
|
-
|
|
165
|
+
err => {
|
|
166
|
+
logger.warn("title-generator: local model error", {
|
|
167
|
+
sessionId,
|
|
168
|
+
model: tinyModel,
|
|
169
|
+
error: err instanceof Error ? err.message : String(err),
|
|
170
|
+
});
|
|
171
|
+
return null;
|
|
172
|
+
},
|
|
163
173
|
);
|
|
164
174
|
const startOnline = (): Promise<string | null> =>
|
|
165
175
|
generateTitleOnline(
|
|
@@ -188,49 +198,48 @@ export async function generateTitleOnline(
|
|
|
188
198
|
): Promise<string | null> {
|
|
189
199
|
const model = getTitleModel(registry, settings, currentModel);
|
|
190
200
|
if (!model) {
|
|
191
|
-
logger.
|
|
201
|
+
logger.warn("title-generator: no title model found", { sessionId, reason: "no-title-model" });
|
|
192
202
|
return null;
|
|
193
203
|
}
|
|
194
204
|
|
|
195
205
|
const userMessage = formatTitleUserMessage(firstMessage);
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
});
|
|
203
|
-
return null;
|
|
204
|
-
}
|
|
205
|
-
// Resolve metadata after getApiKey so the session-sticky credential for this
|
|
206
|
-
// request is already recorded; metadataResolver can then return the correct
|
|
207
|
-
// account_uuid rather than the snapshot-at-call-site value.
|
|
208
|
-
const metadata = metadataResolver?.(model.provider);
|
|
209
|
-
|
|
210
|
-
// Title generation is a 3-6 word task, but some reasoning backends ignore
|
|
211
|
-
// disableReasoning. Keep the normal cheap budget for non-reasoning models
|
|
212
|
-
// while reserving enough output room for reasoning models to still emit
|
|
213
|
-
// the forced tool call after any unavoidable thinking tokens.
|
|
214
|
-
const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
|
|
215
|
-
const request = {
|
|
216
|
-
model: `${model.provider}/${model.id}`,
|
|
217
|
-
systemPrompt: TITLE_SYSTEM_PROMPT,
|
|
218
|
-
userMessage,
|
|
219
|
-
maxTokens,
|
|
206
|
+
const modelName = `${model.provider}/${model.id}`;
|
|
207
|
+
const modelContext = {
|
|
208
|
+
sessionId,
|
|
209
|
+
provider: model.provider,
|
|
210
|
+
id: model.id,
|
|
211
|
+
model: modelName,
|
|
220
212
|
};
|
|
221
|
-
logger.debug("title-generator:
|
|
213
|
+
logger.debug("title-generator: start", modelContext);
|
|
222
214
|
|
|
223
215
|
try {
|
|
216
|
+
const apiKey = await registry.getApiKey(model, sessionId);
|
|
217
|
+
if (!apiKey) {
|
|
218
|
+
logger.warn("title-generator: no API key", { ...modelContext, reason: "missing-api-key" });
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
// Resolve metadata after getApiKey so the session-sticky credential for this
|
|
222
|
+
// request is already recorded; metadataResolver can then return the correct
|
|
223
|
+
// account_uuid rather than the snapshot-at-call-site value.
|
|
224
|
+
const metadata = metadataResolver?.(model.provider);
|
|
225
|
+
|
|
226
|
+
// Title generation is a 3-6 word task, but some reasoning backends ignore
|
|
227
|
+
// disableReasoning. Keep the normal cheap budget for non-reasoning models
|
|
228
|
+
// while reserving enough output room for reasoning models to still emit
|
|
229
|
+
// the forced tool call after any unavoidable thinking tokens.
|
|
230
|
+
const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
|
|
231
|
+
logger.debug("title-generator: request", { ...modelContext, maxTokens });
|
|
232
|
+
|
|
224
233
|
const response = await completeSimple(
|
|
225
234
|
model,
|
|
226
235
|
{
|
|
227
|
-
systemPrompt: [
|
|
228
|
-
messages: [{ role: "user", content:
|
|
236
|
+
systemPrompt: [TITLE_SYSTEM_PROMPT],
|
|
237
|
+
messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
|
|
229
238
|
tools: [setTitleTool],
|
|
230
239
|
},
|
|
231
240
|
{
|
|
232
241
|
apiKey,
|
|
233
|
-
maxTokens
|
|
242
|
+
maxTokens,
|
|
234
243
|
disableReasoning: true,
|
|
235
244
|
toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
|
|
236
245
|
metadata,
|
|
@@ -239,8 +248,9 @@ export async function generateTitleOnline(
|
|
|
239
248
|
);
|
|
240
249
|
|
|
241
250
|
if (response.stopReason === "error") {
|
|
242
|
-
logger.
|
|
243
|
-
|
|
251
|
+
logger.warn("title-generator: response error", {
|
|
252
|
+
...modelContext,
|
|
253
|
+
reason: "provider-response-error",
|
|
244
254
|
stopReason: response.stopReason,
|
|
245
255
|
errorMessage: response.errorMessage,
|
|
246
256
|
});
|
|
@@ -249,8 +259,18 @@ export async function generateTitleOnline(
|
|
|
249
259
|
|
|
250
260
|
const title = normalizeGeneratedTitle(extractGeneratedTitle(response.content));
|
|
251
261
|
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
if (!title) {
|
|
263
|
+
logger.debug("title-generator: no title returned", {
|
|
264
|
+
...modelContext,
|
|
265
|
+
reason: "model-returned-none",
|
|
266
|
+
usage: response.usage,
|
|
267
|
+
stopReason: response.stopReason,
|
|
268
|
+
});
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
logger.debug("title-generator: success", {
|
|
273
|
+
...modelContext,
|
|
254
274
|
title,
|
|
255
275
|
usage: response.usage,
|
|
256
276
|
stopReason: response.stopReason,
|
|
@@ -258,8 +278,9 @@ export async function generateTitleOnline(
|
|
|
258
278
|
|
|
259
279
|
return title;
|
|
260
280
|
} catch (err) {
|
|
261
|
-
logger.
|
|
262
|
-
|
|
281
|
+
logger.warn("title-generator: error", {
|
|
282
|
+
...modelContext,
|
|
283
|
+
reason: "exception",
|
|
263
284
|
error: err instanceof Error ? err.message : String(err),
|
|
264
285
|
});
|
|
265
286
|
return null;
|