@xbzbing/dsh-git-panel 1.6.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +31 -22
- package/README.md +31 -22
- package/lib/client.js +219 -69
- package/lib/client.mermaid.js +4338 -0
- package/lib/host/actions.js +49 -5
- package/lib/host/core.d.ts +13 -5
- package/lib/host/core.js +33 -8
- package/lib/host/fs-deps.d.ts +2 -0
- package/lib/host/fs-deps.js +25 -0
- package/lib/host/history-topology.d.ts +25 -0
- package/lib/host/history-topology.js +55 -0
- package/lib/host/index.d.ts +8 -2
- package/lib/host/index.js +32 -20
- package/lib/host/llm-face.d.ts +21 -0
- package/lib/host/queries.js +88 -23
- package/lib/host/snapshot-fingerprint.d.ts +3 -0
- package/lib/host/snapshot-fingerprint.js +12 -0
- package/lib/host/suggest.d.ts +13 -0
- package/lib/host/suggest.js +49 -9
- package/lib/host/types.d.ts +85 -4
- package/lib/host/types.js +13 -2
- package/package.json +20 -6
package/lib/host/actions.js
CHANGED
|
@@ -36,10 +36,10 @@ function planAction(action, unborn) {
|
|
|
36
36
|
}
|
|
37
37
|
case "branch-checkout": {
|
|
38
38
|
if (!isSafeBranchName(action.name)) return { error: "invalid-name", message: `unsafe branch name: ${action.name}` };
|
|
39
|
-
let checkout = ["git", "checkout",
|
|
39
|
+
let checkout = ["git", "checkout", action.name];
|
|
40
40
|
if (action.createFrom !== void 0) {
|
|
41
41
|
if (!isSafeBranchName(action.createFrom)) return { error: "invalid-name", message: `unsafe tracking ref: ${action.createFrom}` };
|
|
42
|
-
checkout = ["git", "checkout", "-b", action.name, "--track",
|
|
42
|
+
checkout = ["git", "checkout", "-b", action.name, "--track", action.createFrom];
|
|
43
43
|
}
|
|
44
44
|
const msg = action.stashMessage?.trim() ?? "";
|
|
45
45
|
return { argv: msg === "" ? [checkout] : [["git", "stash", "push", "-u", "-m", msg], checkout] };
|
|
@@ -85,7 +85,7 @@ function planAction(action, unborn) {
|
|
|
85
85
|
if (action.mode !== "soft" && action.mode !== "mixed" && action.mode !== "hard") {
|
|
86
86
|
return { error: "invalid-name", message: `invalid reset mode: ${String(action.mode)}` };
|
|
87
87
|
}
|
|
88
|
-
return { argv: [["git", "reset", `--${action.mode}`,
|
|
88
|
+
return { argv: [["git", "reset", `--${action.mode}`, action.commit]] };
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -211,6 +211,26 @@ async function runAction(deps, config, request) {
|
|
|
211
211
|
}
|
|
212
212
|
return { ok: true, snapshot: snapshot.value, output: lastOutput.trim() };
|
|
213
213
|
}
|
|
214
|
+
async function looksLikeRepository(deps, target) {
|
|
215
|
+
try {
|
|
216
|
+
await deps.fs.stat(join(target, ".git"));
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
}
|
|
220
|
+
const head = await pathKind(deps, join(target, "HEAD"));
|
|
221
|
+
const refs = await pathKind(deps, join(target, "refs"));
|
|
222
|
+
const objects = await pathKind(deps, join(target, "objects"));
|
|
223
|
+
if (!head.present || head.directory === true) return false;
|
|
224
|
+
const directoryLike = (kind) => kind.present && kind.directory !== false;
|
|
225
|
+
return directoryLike(refs) && directoryLike(objects);
|
|
226
|
+
}
|
|
227
|
+
async function pathKind(deps, path) {
|
|
228
|
+
try {
|
|
229
|
+
return { present: true, directory: (await deps.fs.stat(path)).isDirectory() };
|
|
230
|
+
} catch {
|
|
231
|
+
return { present: await deps.fs.symlinkTarget(path) !== null, directory: null };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
214
234
|
async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
215
235
|
const snap = await snapshotForSession(deps, config, sessionId);
|
|
216
236
|
if (!snap.ok) return null;
|
|
@@ -218,12 +238,20 @@ async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
|
218
238
|
const untracked = paths.filter((p) => untrackedSet.has(p));
|
|
219
239
|
if (untracked.length === 0) return null;
|
|
220
240
|
const tracked = paths.filter((p) => !untrackedSet.has(p));
|
|
241
|
+
const directoryEntries = new Set(snap.value.changes.filter((c) => c.isDirectory === true).map((c) => c.path));
|
|
242
|
+
for (const path of untracked) {
|
|
243
|
+
if (!directoryEntries.has(path)) continue;
|
|
244
|
+
if (!await looksLikeRepository(deps, join(root, path))) continue;
|
|
245
|
+
return { ok: false, result: { ok: false, error: { code: "nested-repo", message: `repository directory: ${path}` } } };
|
|
246
|
+
}
|
|
221
247
|
let rootReal;
|
|
222
248
|
try {
|
|
223
249
|
rootReal = await deps.fs.realpath(root);
|
|
224
250
|
} catch {
|
|
225
251
|
return { ok: false, result: { ok: false, error: { code: "git-error", message: "repository root unavailable" } } };
|
|
226
252
|
}
|
|
253
|
+
const failures = [];
|
|
254
|
+
let removedCount = 0;
|
|
227
255
|
for (const path of untracked) {
|
|
228
256
|
if (!isSafePath(path)) return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `unsafe path: ${path}` } } };
|
|
229
257
|
const target = join(root, path);
|
|
@@ -236,8 +264,24 @@ async function discardUntracked(deps, config, root, sessionId, paths) {
|
|
|
236
264
|
if (targetReal !== rootReal && !targetReal.startsWith(rootReal + sep)) {
|
|
237
265
|
return { ok: false, result: { ok: false, error: { code: "invalid-path", message: `path escapes repository: ${path}` } } };
|
|
238
266
|
}
|
|
239
|
-
await deps
|
|
240
|
-
|
|
267
|
+
if (await looksLikeRepository(deps, target)) {
|
|
268
|
+
const code = removedCount === 0 ? "nested-repo" : "nested-repo-partial";
|
|
269
|
+
const detail = removedCount === 0 ? "" : `; ${removedCount} path(s) already removed`;
|
|
270
|
+
return { ok: false, result: { ok: false, error: { code, message: `repository directory: ${path}${detail}` } } };
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
await deps.fs.remove(target);
|
|
274
|
+
removedCount += 1;
|
|
275
|
+
} catch (e) {
|
|
276
|
+
failures.push(`${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (failures.length > 0) {
|
|
280
|
+
const skipped = tracked.length > 0 ? `; ${tracked.length} tracked path(s) not restored` : "";
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
result: { ok: false, error: { code: "git-error", message: `could not discard ${failures.length} path(s): ${failures.join("; ")}${skipped}` } }
|
|
284
|
+
};
|
|
241
285
|
}
|
|
242
286
|
return { ok: true, remainingTracked: tracked };
|
|
243
287
|
}
|
package/lib/host/core.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GitRunner } from './git.js';
|
|
2
|
-
import type { AgentDefaultModelFace, LlmFace } from './llm-face.js';
|
|
2
|
+
import type { AgentDefaultModelFace, LlmFace, SessionModelSelection } from './llm-face.js';
|
|
3
3
|
import type { DiffViewMode, GraphStyle, GitChange, GitErrorCode, GitRemote, GitSnapshotResult, SuggestLanguage } from './types.js';
|
|
4
4
|
export interface GitPanelConfig {
|
|
5
5
|
readonly timeoutMs: number;
|
|
@@ -26,6 +26,9 @@ export interface GitPanelConfig {
|
|
|
26
26
|
/** Optional provider/model route override; absent → the agent default. */
|
|
27
27
|
readonly suggestProvider?: string;
|
|
28
28
|
readonly suggestModel?: string;
|
|
29
|
+
/** Adapter-owned effort for the fixed route; absent → omit from the request
|
|
30
|
+
* (the model / adapter default applies). Ignored when the route is not fixed. */
|
|
31
|
+
readonly suggestReasoningEffort?: string;
|
|
29
32
|
}
|
|
30
33
|
export declare const DEFAULT_CONFIG: GitPanelConfig;
|
|
31
34
|
export declare function normalizeConfig(raw: unknown): GitPanelConfig;
|
|
@@ -50,9 +53,11 @@ export interface SnapshotDeps {
|
|
|
50
53
|
readonly run: GitRunner;
|
|
51
54
|
readonly fs: {
|
|
52
55
|
realpath(path: string): Promise<string>;
|
|
56
|
+
/** `isDirectory` reports the *resolved* type (symlinks are followed). */
|
|
53
57
|
stat(path: string): Promise<{
|
|
54
58
|
mtimeMs: number;
|
|
55
59
|
size: number;
|
|
60
|
+
isDirectory(): boolean;
|
|
56
61
|
}>;
|
|
57
62
|
/** Raw bytes (no encoding) — image sides for the image-diff query. */
|
|
58
63
|
readFile(path: string): Promise<Buffer>;
|
|
@@ -67,7 +72,7 @@ export interface SnapshotDeps {
|
|
|
67
72
|
name: string;
|
|
68
73
|
isDirectory: boolean;
|
|
69
74
|
}>>;
|
|
70
|
-
/**
|
|
75
|
+
/** Recursive remove (force) — untracked discards pass whole directories; temp-blob cleanup passes files. */
|
|
71
76
|
remove(path: string): Promise<void>;
|
|
72
77
|
};
|
|
73
78
|
readonly sessions: {
|
|
@@ -92,14 +97,17 @@ export interface SnapshotDeps {
|
|
|
92
97
|
*/
|
|
93
98
|
readonly rootNegCache?: Map<string, number>;
|
|
94
99
|
/**
|
|
95
|
-
* Optional per-request resolvers for the host
|
|
96
|
-
*
|
|
97
|
-
*
|
|
100
|
+
* Optional per-request resolvers for the host model services (see
|
|
101
|
+
* llm-face.ts). Resolved on every suggest call — never frozen at
|
|
102
|
+
* construction — so a provider fiber that activates late or restarts is
|
|
98
103
|
* picked up. Absent in deployments without a model backend; the suggest
|
|
99
104
|
* endpoint then reports `llm-unavailable` instead of failing activation.
|
|
100
105
|
*/
|
|
101
106
|
readonly getLlm?: () => LlmFace | undefined;
|
|
102
107
|
readonly getAgentDefaultModel?: () => AgentDefaultModelFace | undefined;
|
|
108
|
+
/** The session's durable model selection (projection next); undefined when
|
|
109
|
+
* the session has never selected a model. */
|
|
110
|
+
readonly getSessionModel?: (sessionId: string) => SessionModelSelection | undefined;
|
|
103
111
|
}
|
|
104
112
|
/**
|
|
105
113
|
* Pick the primary remote from `git remote -v` output and parse its fetch URL.
|
package/lib/host/core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join } from "node:path";
|
|
2
2
|
import { commitFromFields, parseStatus, sumNumstat } from "./parser.js";
|
|
3
3
|
import { parseRemote } from "./remote.js";
|
|
4
|
+
import { snapshotFingerprint } from "./snapshot-fingerprint.js";
|
|
4
5
|
import { isSafePath } from "./validate.js";
|
|
5
6
|
import { SUGGEST_LANGUAGE_DEFAULT, SUGGEST_LANGUAGES, SUGGEST_LIMITS } from "./types.js";
|
|
6
7
|
const DEFAULT_CONFIG = {
|
|
@@ -24,9 +25,14 @@ function normalizeConfig(raw) {
|
|
|
24
25
|
const raw2 = unwrapVolatile(v);
|
|
25
26
|
return typeof raw2 === "number" && Number.isFinite(raw2) ? Math.min(limit.max, Math.max(limit.min, Math.floor(raw2))) : limit.default;
|
|
26
27
|
};
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
28
|
+
const provider = unwrapVolatile(c.suggestProvider);
|
|
29
|
+
const model = unwrapVolatile(c.suggestModel);
|
|
30
|
+
const effort = unwrapVolatile(c.suggestReasoningEffort);
|
|
31
|
+
const route = typeof provider === "string" && provider !== "" && typeof model === "string" && model !== "" ? {
|
|
32
|
+
suggestProvider: provider,
|
|
33
|
+
suggestModel: model,
|
|
34
|
+
...typeof effort === "string" && effort !== "" ? { suggestReasoningEffort: effort } : {}
|
|
35
|
+
} : {};
|
|
30
36
|
return {
|
|
31
37
|
timeoutMs: num(c.timeoutMs, DEFAULT_CONFIG.timeoutMs),
|
|
32
38
|
maxBytes: num(c.maxBytes, DEFAULT_CONFIG.maxBytes),
|
|
@@ -80,6 +86,7 @@ function parsePrimaryRemote(stdout) {
|
|
|
80
86
|
return parseRemote(name, fetchUrls.get(name));
|
|
81
87
|
}
|
|
82
88
|
const STATS_FANOUT_LIMIT = 200;
|
|
89
|
+
const UNTRACKED_NUMSTAT_LIMIT = 32;
|
|
83
90
|
async function resolveBrowseRoot(deps, sessionId) {
|
|
84
91
|
const ws = await resolveWorkspace(deps, sessionId);
|
|
85
92
|
if (ws.ok) return { ok: true, root: ws.root, isGitRepo: true };
|
|
@@ -186,7 +193,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
186
193
|
const [branchRes, headRes, statusRes, aheadBehindRes, lastCommitRes, worktreeNumRes, stagedNumRes, remoteRes] = await Promise.all([
|
|
187
194
|
runCommand(deps.run, ["git", "symbolic-ref", "--quiet", "--short", "HEAD"], root, "branch", deps.signal),
|
|
188
195
|
runCommand(deps.run, ["git", "rev-parse", "--short", "HEAD"], root, "head", deps.signal),
|
|
189
|
-
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z"], root, "status", deps.signal),
|
|
196
|
+
runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], root, "status", deps.signal),
|
|
190
197
|
runCommand(deps.run, ["git", "rev-list", "--count", "--left-right", "@{upstream}...HEAD"], root, "aheadBehind", deps.signal),
|
|
191
198
|
runCommand(deps.run, ["git", "log", "-1", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%aI"], root, "lastCommit", deps.signal),
|
|
192
199
|
runCommand(deps.run, ["git", "diff", "--numstat"], root, "numstat-worktree", deps.signal),
|
|
@@ -204,7 +211,20 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
204
211
|
allChanges = parseStatus(statusRes.run.stdout);
|
|
205
212
|
statusLossy = statusRes.run.stdoutLossy;
|
|
206
213
|
}
|
|
214
|
+
let untrackedCollapsed = false;
|
|
215
|
+
const untrackedEntries = allChanges.filter((c) => c.status === "untracked");
|
|
216
|
+
const collapseCandidate = untrackedEntries.some((c) => c.path.includes("/"));
|
|
217
|
+
if (statusLossy || untrackedEntries.length > config.maxChanges && collapseCandidate) {
|
|
218
|
+
const collapsed = await runCommand(deps.run, ["git", "status", "--porcelain=v1", "-z", "--untracked-files=normal"], root, "status-collapsed", deps.signal);
|
|
219
|
+
if ("run" in collapsed && !collapsed.run.timedOut && collapsed.run.exitCode === 0) {
|
|
220
|
+
const list = parseStatus(collapsed.run.stdout);
|
|
221
|
+
untrackedCollapsed = list.some((c) => c.status === "untracked" && c.isDirectory);
|
|
222
|
+
allChanges = list;
|
|
223
|
+
statusLossy = collapsed.run.stdoutLossy;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
207
226
|
const truncated = allChanges.length > config.maxChanges || statusLossy;
|
|
227
|
+
const truncatedReason = statusLossy ? "lossy" : allChanges.length > config.maxChanges ? "cap" : void 0;
|
|
208
228
|
const changes = allChanges.length > config.maxChanges ? allChanges.slice(0, config.maxChanges) : allChanges;
|
|
209
229
|
let staged = 0;
|
|
210
230
|
let modified = 0;
|
|
@@ -230,9 +250,10 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
230
250
|
const wt = "run" in worktreeNumRes && worktreeNumRes.run.exitCode === 0 ? sumNumstat(worktreeNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
231
251
|
const stg = "run" in stagedNumRes && stagedNumRes.run.exitCode === 0 ? sumNumstat(stagedNumRes.run.stdout) : { insertions: 0, deletions: 0 };
|
|
232
252
|
const statsDetailed = allChanges.length <= STATS_FANOUT_LIMIT;
|
|
253
|
+
const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
|
|
254
|
+
const untrackedCountable = untrackedPaths.length <= UNTRACKED_NUMSTAT_LIMIT;
|
|
233
255
|
let untrackedInsertions = 0;
|
|
234
|
-
if (
|
|
235
|
-
const untrackedPaths = allChanges.filter((c) => c.status === "untracked" && !c.isDirectory).map((c) => c.path);
|
|
256
|
+
if (untrackedCountable) {
|
|
236
257
|
const safeUntracked = untrackedPaths.filter(isSafePath);
|
|
237
258
|
if (safeUntracked.length > 0) {
|
|
238
259
|
const perFile = await Promise.all(
|
|
@@ -252,7 +273,9 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
252
273
|
deletions: wt.deletions + stg.deletions,
|
|
253
274
|
lastChangeAt,
|
|
254
275
|
headCommittedAt: lastCommit?.dateIso ?? null,
|
|
255
|
-
|
|
276
|
+
// Collapsed untracked directories have no per-file line counts and count as
|
|
277
|
+
// one entry each, so the stats are an explicit lower bound there too.
|
|
278
|
+
partial: !statsDetailed || !untrackedCountable || untrackedCollapsed
|
|
256
279
|
};
|
|
257
280
|
const snapshot = {
|
|
258
281
|
root,
|
|
@@ -271,6 +294,8 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
271
294
|
changes,
|
|
272
295
|
stats,
|
|
273
296
|
truncated,
|
|
297
|
+
...truncatedReason !== void 0 ? { truncatedReason } : {},
|
|
298
|
+
...untrackedCollapsed ? { untrackedCollapsed: true } : {},
|
|
274
299
|
refreshIntervalMs: config.refreshIntervalMs,
|
|
275
300
|
showInputPill: config.showInputPill,
|
|
276
301
|
defaultDiffView: config.defaultDiffView,
|
|
@@ -278,7 +303,7 @@ async function snapshotForSession(deps, config, sessionId) {
|
|
|
278
303
|
suggestEnabled: config.suggestEnabled,
|
|
279
304
|
checkedAt: Date.now()
|
|
280
305
|
};
|
|
281
|
-
return { ok: true, value: snapshot };
|
|
306
|
+
return { ok: true, value: { ...snapshot, stateFingerprint: snapshotFingerprint(snapshot) } };
|
|
282
307
|
}
|
|
283
308
|
async function maxChangeMtime(deps, root, changes, cap = 200) {
|
|
284
309
|
let max = null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
function createFsDeps() {
|
|
3
|
+
return {
|
|
4
|
+
realpath,
|
|
5
|
+
stat: async (p) => stat(p),
|
|
6
|
+
readFile,
|
|
7
|
+
symlinkTarget: async (p) => {
|
|
8
|
+
try {
|
|
9
|
+
return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
|
|
15
|
+
// Untracked entries include whole directories (porcelain collapses `?? dir/`
|
|
16
|
+
// into one entry) and a plain `rm` fails EISDIR on them; the caller
|
|
17
|
+
// realpath-contains every target inside the repo root before removing.
|
|
18
|
+
remove: async (p) => {
|
|
19
|
+
await rm(p, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
createFsDeps
|
|
25
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GitRunner } from './git.js';
|
|
2
|
+
import type { GitGraphNode } from './types.js';
|
|
3
|
+
/** Hard cap on the auxiliary walk: deep enough for a filtered page, bounded
|
|
4
|
+
* against a pathological DAG. */
|
|
5
|
+
export declare const HISTORY_TOPOLOGY_LIMIT = 10000;
|
|
6
|
+
/** Hard cap on the walk's stdout. The node cap alone does not bound a line: an
|
|
7
|
+
* octopus merge can list thousands of parents on one commit. Over budget the
|
|
8
|
+
* result is conservatively `[]` (an incomplete graph), never a partial parse. */
|
|
9
|
+
export declare const HISTORY_TOPOLOGY_MAX_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* Parse `git rev-list --parents` output: one `<hash> [<parent>…]` per line.
|
|
12
|
+
* Returns null whenever the capture cannot be trusted as a complete ancestry —
|
|
13
|
+
* a missing trailing newline (git always terminates each line, so a cut read can
|
|
14
|
+
* land exactly on a 40-char hash and otherwise read as a parentless root), any
|
|
15
|
+
* malformed line, or output past the node/byte budget. `[]` is a genuinely empty
|
|
16
|
+
* walk, never a degraded one.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseGraphNodes(stdout: string): GitGraphNode[] | null;
|
|
19
|
+
/**
|
|
20
|
+
* Walk the page's raw ancestry. Seeds must already be full hashes; anything
|
|
21
|
+
* else is dropped rather than passed as argv. Resolves `[]` on every failure
|
|
22
|
+
* path (spawn throw, cancel, timeout, non-zero exit, lossy output, malformed
|
|
23
|
+
* output) so a filtered history page is never lost to this auxiliary command.
|
|
24
|
+
*/
|
|
25
|
+
export declare function walkHistoryTopology(runner: GitRunner, root: string, seeds: readonly string[], signal?: AbortSignal): Promise<GitGraphNode[]>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runCommand } from "./core.js";
|
|
2
|
+
const HISTORY_TOPOLOGY_LIMIT = 1e4;
|
|
3
|
+
const HISTORY_TOPOLOGY_MAX_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
const FULL_HASH = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/;
|
|
5
|
+
function parseGraphNodes(stdout) {
|
|
6
|
+
if (stdout === "") return [];
|
|
7
|
+
if (Buffer.byteLength(stdout, "utf8") > HISTORY_TOPOLOGY_MAX_BYTES) return null;
|
|
8
|
+
if (!stdout.endsWith("\n")) return null;
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const raw of stdout.split("\n")) {
|
|
11
|
+
const line = raw.trim();
|
|
12
|
+
if (line !== "") lines.push(line);
|
|
13
|
+
}
|
|
14
|
+
if (lines.length > HISTORY_TOPOLOGY_LIMIT) return null;
|
|
15
|
+
const nodes = [];
|
|
16
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
const fields = line.split(" ");
|
|
19
|
+
const hash = fields[0];
|
|
20
|
+
if (hash === void 0 || !FULL_HASH.test(hash)) return null;
|
|
21
|
+
const parents = [];
|
|
22
|
+
for (const parent of fields.slice(1)) {
|
|
23
|
+
if (!FULL_HASH.test(parent)) return null;
|
|
24
|
+
parents.push(parent);
|
|
25
|
+
}
|
|
26
|
+
if (seen.has(hash)) continue;
|
|
27
|
+
seen.add(hash);
|
|
28
|
+
nodes.push({ hash, parents });
|
|
29
|
+
}
|
|
30
|
+
return nodes;
|
|
31
|
+
}
|
|
32
|
+
async function walkHistoryTopology(runner, root, seeds, signal) {
|
|
33
|
+
const safeSeeds = seeds.filter((seed) => FULL_HASH.test(seed));
|
|
34
|
+
if (safeSeeds.length === 0) return [];
|
|
35
|
+
const argv = [
|
|
36
|
+
"git",
|
|
37
|
+
"rev-list",
|
|
38
|
+
"--parents",
|
|
39
|
+
`--max-count=${HISTORY_TOPOLOGY_LIMIT}`,
|
|
40
|
+
"--end-of-options",
|
|
41
|
+
...safeSeeds
|
|
42
|
+
];
|
|
43
|
+
const res = await runCommand(runner, argv, root, "history-topology", signal);
|
|
44
|
+
if (!("run" in res)) return [];
|
|
45
|
+
const { run } = res;
|
|
46
|
+
if (run.cancelled || run.timedOut || run.exitCode !== 0 || run.stdoutLossy) return [];
|
|
47
|
+
if (Buffer.byteLength(run.stdout, "utf8") > HISTORY_TOPOLOGY_MAX_BYTES) return [];
|
|
48
|
+
return parseGraphNodes(run.stdout) ?? [];
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
HISTORY_TOPOLOGY_LIMIT,
|
|
52
|
+
HISTORY_TOPOLOGY_MAX_BYTES,
|
|
53
|
+
parseGraphNodes,
|
|
54
|
+
walkHistoryTopology
|
|
55
|
+
};
|
package/lib/host/index.d.ts
CHANGED
|
@@ -10,14 +10,14 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
|
10
10
|
import type { Context } from '@deepseek-ai/cordis';
|
|
11
11
|
import Schema from '@deepseek-ai/schemastery';
|
|
12
12
|
import type { GitActionRequest, GitActionResult, GitQueryRequest, GitQueryResponse, GitSnapshotRequest, GitSnapshotResult, GitSuggestRequest, GitSuggestResult, GitVersionInfo, GitVersionRequest } from './types.js';
|
|
13
|
-
export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, SuggestLanguage, ResolvedLanguage, } from './types.js';
|
|
13
|
+
export type { GitSnapshot, GitSnapshotResult, GitSnapshotRequest, GitFailure, GitCommit, GraphCommit, GitRef, GitChange, GitChangeStatus, GitAction, GitActionRequest, GitActionResult, GitErrorCode, GitQuery, GitQueryRequest, GitQueryResponse, GitQueryResult, GitBranch, GitFileStat, WorktreeStats, StashEntry, GitVersionRequest, GitVersionInfo, DiffViewMode, GraphStyle, DirEntry, ResetMode, GitSuggestRequest, GitSuggestResult, GitRemote, RemoteHostKind, SuggestLanguage, ResolvedLanguage, ModelCatalogGroup, ModelCatalogModel, ModelCatalogEffort, ModelCatalogFailure, } from './types.js';
|
|
14
14
|
export { normalizeConfig, DEFAULT_CONFIG, snapshotForSession, resolveWorkspace, parsePrimaryRemote, readSuggestLanguage } from './core.js';
|
|
15
15
|
export { createGitRunner } from './git.js';
|
|
16
16
|
export { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashList, parseTags, sumNumstat } from './parser.js';
|
|
17
17
|
export { parseRemote, classifyHost } from './remote.js';
|
|
18
18
|
export { isSafePath, planAction, runAction, classifyActionFailure } from './actions.js';
|
|
19
19
|
export { runQuery } from './queries.js';
|
|
20
|
-
export { runSuggest, resolveSuggestLanguage, parseStyleHistory, isCjkText } from './suggest.js';
|
|
20
|
+
export { runSuggest, resolveSuggestLanguage, parseStyleHistory, isCjkText, compactLongLines, LONG_LINE_MAX_CHARS } from './suggest.js';
|
|
21
21
|
export { readVersionInfo, checkLatestVersion, compareVersions, parseRepository } from './version.js';
|
|
22
22
|
export declare class GitPanelService extends TypertRemoteService {
|
|
23
23
|
static inject: string[];
|
|
@@ -36,6 +36,9 @@ export declare class GitPanelService extends TypertRemoteService {
|
|
|
36
36
|
suggestMaxBytes: Schema<number, number, "volatile-defined">;
|
|
37
37
|
suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
|
|
38
38
|
suggestTimeoutMs: Schema<number, number, "volatile-defined">;
|
|
39
|
+
suggestProvider: Schema<string, string, "volatile">;
|
|
40
|
+
suggestModel: Schema<string, string, "volatile">;
|
|
41
|
+
suggestReasoningEffort: Schema<string, string, "volatile">;
|
|
39
42
|
}>>, Schemastery.ObjectT<NoInfer<{
|
|
40
43
|
showInputPill: Schema<boolean, boolean, "volatile-defined">;
|
|
41
44
|
defaultDiffView: Schema<"split" | "unified", "split" | "unified", "volatile-defined">;
|
|
@@ -45,6 +48,9 @@ export declare class GitPanelService extends TypertRemoteService {
|
|
|
45
48
|
suggestMaxBytes: Schema<number, number, "volatile-defined">;
|
|
46
49
|
suggestMaxOutputTokens: Schema<number, number, "volatile-defined">;
|
|
47
50
|
suggestTimeoutMs: Schema<number, number, "volatile-defined">;
|
|
51
|
+
suggestProvider: Schema<string, string, "volatile">;
|
|
52
|
+
suggestModel: Schema<string, string, "volatile">;
|
|
53
|
+
suggestReasoningEffort: Schema<string, string, "volatile">;
|
|
48
54
|
}>>, "plain">;
|
|
49
55
|
private readonly deps;
|
|
50
56
|
private config;
|
package/lib/host/index.js
CHANGED
|
@@ -49,8 +49,8 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
|
|
|
49
49
|
var _version_dec, _suggest_dec, _query_dec, _run_dec, _snapshot_dec, _a, _init;
|
|
50
50
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
51
51
|
import Schema from "@deepseek-ai/schemastery";
|
|
52
|
-
import { lstat, readFile, readdir, readlink, realpath, rm, stat } from "node:fs/promises";
|
|
53
52
|
import { createGitRunner } from "./git.js";
|
|
53
|
+
import { createFsDeps } from "./fs-deps.js";
|
|
54
54
|
import { normalizeConfig, snapshotForSession, DEFAULT_CONFIG } from "./core.js";
|
|
55
55
|
import { runAction } from "./actions.js";
|
|
56
56
|
import { runQuery } from "./queries.js";
|
|
@@ -62,7 +62,7 @@ import { parseStatus, parseGraphLog, parseBranches, parseNameStatus, parseStashL
|
|
|
62
62
|
import { parseRemote, classifyHost } from "./remote.js";
|
|
63
63
|
import { isSafePath, planAction, runAction as runAction2, classifyActionFailure } from "./actions.js";
|
|
64
64
|
import { runQuery as runQuery2 } from "./queries.js";
|
|
65
|
-
import { runSuggest as runSuggest2, resolveSuggestLanguage, parseStyleHistory, isCjkText } from "./suggest.js";
|
|
65
|
+
import { runSuggest as runSuggest2, resolveSuggestLanguage, parseStyleHistory, isCjkText, compactLongLines, LONG_LINE_MAX_CHARS } from "./suggest.js";
|
|
66
66
|
import { readVersionInfo as readVersionInfo2, checkLatestVersion as checkLatestVersion2, compareVersions, parseRepository } from "./version.js";
|
|
67
67
|
class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote("snapshot")], _run_dec = [Remote("run")], _query_dec = [Remote("query")], _suggest_dec = [Remote("suggest")], _version_dec = [Remote("version")], _a) {
|
|
68
68
|
constructor(ctx, config) {
|
|
@@ -79,28 +79,14 @@ class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote
|
|
|
79
79
|
const rootCache = /* @__PURE__ */ new Map();
|
|
80
80
|
const rootNegCache = /* @__PURE__ */ new Map();
|
|
81
81
|
const get = (key) => ctx.get(key);
|
|
82
|
-
const fs =
|
|
83
|
-
realpath,
|
|
84
|
-
stat: async (p) => stat(p),
|
|
85
|
-
readFile,
|
|
86
|
-
symlinkTarget: async (p) => {
|
|
87
|
-
try {
|
|
88
|
-
return (await lstat(p)).isSymbolicLink() ? await readlink(p) : null;
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
readdir: async (p) => (await readdir(p, { withFileTypes: true })).map((e) => ({ name: e.name, isDirectory: e.isDirectory() })),
|
|
94
|
-
remove: async (p) => {
|
|
95
|
-
await rm(p, { force: true });
|
|
96
|
-
}
|
|
97
|
-
};
|
|
82
|
+
const fs = createFsDeps();
|
|
98
83
|
const subprocess = get("subprocess");
|
|
99
84
|
const run = subprocess === void 0 ? { run: async () => {
|
|
100
85
|
throw new Error("subprocess service unavailable");
|
|
101
86
|
} } : createGitRunner(subprocess, config.timeoutMs, config.maxBytes);
|
|
102
87
|
const sessions = get("sessions");
|
|
103
88
|
const persistence = get("sessionPersistence");
|
|
89
|
+
const projections = get("sessionProjections");
|
|
104
90
|
return {
|
|
105
91
|
run,
|
|
106
92
|
fs,
|
|
@@ -123,7 +109,25 @@ class GitPanelService extends (_a = TypertRemoteService, _snapshot_dec = [Remote
|
|
|
123
109
|
// a model backend, where the suggest endpoint reports `llm-unavailable`
|
|
124
110
|
// rather than failing activation (kept out of `static inject` on purpose).
|
|
125
111
|
getLlm: () => get("llm"),
|
|
126
|
-
getAgentDefaultModel: () => get("agentDefaultModel")
|
|
112
|
+
getAgentDefaultModel: () => get("agentDefaultModel"),
|
|
113
|
+
// Session model selection: projection `next` is pending ?? lastUsed —
|
|
114
|
+
// the route the next prompt in this session would use.
|
|
115
|
+
getSessionModel: (sessionId) => {
|
|
116
|
+
const session = sessions?.get(sessionId);
|
|
117
|
+
if (session === void 0 || projections === void 0) return void 0;
|
|
118
|
+
try {
|
|
119
|
+
const next = projections.snapshot(session, ["modelSelection"]).values?.modelSelection?.next;
|
|
120
|
+
if (next == null || typeof next.provider !== "string" || typeof next.model !== "string") return void 0;
|
|
121
|
+
if (next.provider === "" || next.model === "") return void 0;
|
|
122
|
+
return {
|
|
123
|
+
provider: next.provider,
|
|
124
|
+
model: next.model,
|
|
125
|
+
...typeof next.reasoningEffort === "string" && next.reasoningEffort !== "" ? { reasoningEffort: next.reasoningEffort } : {}
|
|
126
|
+
};
|
|
127
|
+
} catch {
|
|
128
|
+
return void 0;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
127
131
|
};
|
|
128
132
|
}
|
|
129
133
|
async snapshot(request, signal) {
|
|
@@ -189,15 +193,23 @@ __publicField(GitPanelService, "Config", Schema.object({
|
|
|
189
193
|
// a literal here could silently drift from what normalizeConfig enforces.
|
|
190
194
|
suggestMaxBytes: Schema.number().default(DEFAULT_CONFIG.suggestMaxBytes).volatile().description("AI \u751F\u6210\u8F93\u5165\u9884\u7B97\uFF08diff \u5B57\u8282\u4E0A\u9650\uFF0C16\u2013512 KiB\uFF09"),
|
|
191
195
|
suggestMaxOutputTokens: Schema.number().default(DEFAULT_CONFIG.suggestMaxOutputTokens).volatile().description("AI \u751F\u6210\u8F93\u51FA\u4E0A\u9650\uFF08tokens\uFF0C256\u201316384\uFF09"),
|
|
192
|
-
suggestTimeoutMs: Schema.number().default(DEFAULT_CONFIG.suggestTimeoutMs).volatile().description("AI \u751F\u6210\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF0C5\u2013120 \u79D2\uFF09")
|
|
196
|
+
suggestTimeoutMs: Schema.number().default(DEFAULT_CONFIG.suggestTimeoutMs).volatile().description("AI \u751F\u6210\u8D85\u65F6\uFF08\u6BEB\u79D2\uFF0C5\u2013120 \u79D2\uFF09"),
|
|
197
|
+
// Fixed model route: both halves of the pair must be non-empty to take
|
|
198
|
+
// effect (normalizeConfig); effort is omitted from the request when blank.
|
|
199
|
+
// No .default(): without one the field stays optional in the object schema.
|
|
200
|
+
suggestProvider: Schema.string().volatile().description("AI \u751F\u6210\u56FA\u5B9A\u6A21\u578B\u7684 provider\uFF08\u7559\u7A7A = \u8DDF\u968F\u4F1A\u8BDD\u9ED8\u8BA4\uFF09"),
|
|
201
|
+
suggestModel: Schema.string().volatile().description("AI \u751F\u6210\u56FA\u5B9A\u6A21\u578B\u7684 model id\uFF08\u987B\u4E0E provider \u6210\u5BF9\uFF09"),
|
|
202
|
+
suggestReasoningEffort: Schema.string().volatile().description("AI \u751F\u6210\u56FA\u5B9A\u6A21\u578B\u7684\u63A8\u7406\u5F3A\u5EA6\uFF08\u7559\u7A7A = \u4E0D\u8BBE\u7F6E\uFF0C\u7528\u6A21\u578B\u9ED8\u8BA4\uFF09")
|
|
193
203
|
}));
|
|
194
204
|
var index_default = GitPanelService;
|
|
195
205
|
export {
|
|
196
206
|
DEFAULT_CONFIG2 as DEFAULT_CONFIG,
|
|
197
207
|
GitPanelService,
|
|
208
|
+
LONG_LINE_MAX_CHARS,
|
|
198
209
|
checkLatestVersion2 as checkLatestVersion,
|
|
199
210
|
classifyActionFailure,
|
|
200
211
|
classifyHost,
|
|
212
|
+
compactLongLines,
|
|
201
213
|
compareVersions,
|
|
202
214
|
createGitRunner2 as createGitRunner,
|
|
203
215
|
index_default as default,
|
package/lib/host/llm-face.d.ts
CHANGED
|
@@ -16,7 +16,22 @@ export interface LlmFace {
|
|
|
16
16
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
17
17
|
listProviders?(): readonly {
|
|
18
18
|
readonly id: string;
|
|
19
|
+
readonly name?: string;
|
|
19
20
|
}[];
|
|
21
|
+
/** Adapter-advertised models for one provider; optional on the wire. */
|
|
22
|
+
listModels?(provider: string): Promise<readonly {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly name?: string;
|
|
25
|
+
}[]>;
|
|
26
|
+
/** Exact-route metadata (reasoning efforts etc.); optional on the wire. */
|
|
27
|
+
resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{
|
|
28
|
+
readonly reasoning?: {
|
|
29
|
+
readonly efforts: readonly {
|
|
30
|
+
readonly id: string;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
}[];
|
|
33
|
+
};
|
|
34
|
+
}>;
|
|
20
35
|
}
|
|
21
36
|
/** Minimal face of the host `agentDefaultModel` service. */
|
|
22
37
|
export interface AgentDefaultModelFace {
|
|
@@ -25,3 +40,9 @@ export interface AgentDefaultModelFace {
|
|
|
25
40
|
readonly model: string;
|
|
26
41
|
} | undefined;
|
|
27
42
|
}
|
|
43
|
+
/** One session's durable model selection (projection `next`: pending, else lastUsed). */
|
|
44
|
+
export interface SessionModelSelection {
|
|
45
|
+
readonly provider: string;
|
|
46
|
+
readonly model: string;
|
|
47
|
+
readonly reasoningEffort?: string;
|
|
48
|
+
}
|