@mrclrchtr/supi-consulting 0.0.0-stage → 9.0.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/CLAUDE.md +23 -0
- package/CONTEXT.md +53 -0
- package/README.md +104 -2
- package/docs/adr/0001-use-an-isolated-antigravity-home.md +5 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +137 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +73 -0
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +42 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +342 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +17 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +313 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +120 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug.ts +14 -0
- package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +64 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +38 -0
- package/node_modules/@mrclrchtr/supi-core/src/llm.ts +356 -0
- package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
- package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
- package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-capabilities.ts +242 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-capability-profiles.ts +240 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +47 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +105 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +454 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/node_modules/@mrclrchtr/supi-core/src/tui.ts +141 -0
- package/package.json +73 -4
- package/scripts/live-probe.ts +139 -0
- package/src/agents/antigravity/activity.ts +22 -0
- package/src/agents/antigravity/adapter.ts +196 -0
- package/src/agents/antigravity/availability.ts +231 -0
- package/src/agents/antigravity/isolated-home.ts +346 -0
- package/src/agents/antigravity/models.ts +15 -0
- package/src/agents/antigravity/process/environment.ts +33 -0
- package/src/agents/antigravity/process/event-values.ts +273 -0
- package/src/agents/antigravity/process/events.ts +366 -0
- package/src/agents/antigravity/process/hooks.ts +219 -0
- package/src/agents/antigravity/process/ndjson.ts +120 -0
- package/src/agents/antigravity/process/protocol.ts +69 -0
- package/src/agents/antigravity/process/runner.ts +147 -0
- package/src/agents/antigravity/process/subprocess.ts +278 -0
- package/src/agents/antigravity/process/usage.ts +50 -0
- package/src/agents/antigravity/structured-output.ts +58 -0
- package/src/agents/antigravity/types.ts +17 -0
- package/src/agents/errors.ts +27 -0
- package/src/agents/types.ts +43 -0
- package/src/catalogue.ts +17 -0
- package/src/config.ts +26 -0
- package/src/conversation/handles.ts +187 -0
- package/src/evidence-hash.ts +6 -0
- package/src/extension.ts +26 -0
- package/src/footer-constants.ts +5 -0
- package/src/footer.ts +25 -0
- package/src/runtime.ts +244 -0
- package/src/settings.ts +38 -0
- package/src/tool/consulting_run/evidence.ts +169 -0
- package/src/tool/consulting_run/execute.ts +201 -0
- package/src/tool/consulting_run/guidance.ts +3 -0
- package/src/tool/consulting_run/input.ts +117 -0
- package/src/tool/consulting_run/register.ts +36 -0
- package/src/tool/consulting_run/render.ts +261 -0
- package/src/tool/consulting_run/result.ts +185 -0
- package/src/tool/consulting_run/spec.ts +20 -0
- package/src/types.ts +104 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", ".pnpm"]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk a project directory tree, calling `onDirectory` for each directory.
|
|
8
|
+
* Skips `node_modules`, `.git`, and `.pnpm`.
|
|
9
|
+
* Stops at depth 0.
|
|
10
|
+
*/
|
|
11
|
+
export function walkProject(
|
|
12
|
+
directory: string,
|
|
13
|
+
depth: number,
|
|
14
|
+
onDirectory: (directory: string, entryNames: Set<string>) => void,
|
|
15
|
+
): void {
|
|
16
|
+
let entries: fs.Dirent[];
|
|
17
|
+
try {
|
|
18
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
19
|
+
} catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const entryNames = new Set(entries.map((entry) => entry.name));
|
|
24
|
+
onDirectory(directory, entryNames);
|
|
25
|
+
|
|
26
|
+
if (depth <= 0) return;
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isDirectory()) continue;
|
|
30
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
31
|
+
walkProject(path.join(directory, entry.name), depth - 1, onDirectory);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Search upward from `startDir` for any of the `markers` files/dirs.
|
|
37
|
+
* Returns the directory containing the first found marker, or `fallback`.
|
|
38
|
+
*/
|
|
39
|
+
export function findProjectRoot(startDir: string, markers: string[], fallback: string): string {
|
|
40
|
+
let dir = path.resolve(startDir);
|
|
41
|
+
const root = path.parse(dir).root;
|
|
42
|
+
|
|
43
|
+
while (dir !== root) {
|
|
44
|
+
for (const marker of markers) {
|
|
45
|
+
if (fs.existsSync(path.join(dir, marker))) {
|
|
46
|
+
return dir;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const parent = path.dirname(dir);
|
|
50
|
+
if (parent === dir) break;
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Deduplicate overlapping roots, keeping only the topmost (shortest path) roots.
|
|
59
|
+
*/
|
|
60
|
+
export function dedupeTopmostRoots(roots: string[]): string[] {
|
|
61
|
+
const accepted: string[] = [];
|
|
62
|
+
|
|
63
|
+
for (const root of [...new Set(roots.map((entry) => path.resolve(entry)))].sort(byPathDepth)) {
|
|
64
|
+
const isChild = accepted.some((parent) => root !== parent && isWithin(parent, root));
|
|
65
|
+
if (!isChild) {
|
|
66
|
+
accepted.push(root);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return accepted;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Minimal shape accepted by `buildKnownRootsMap`.
|
|
75
|
+
* Structurally compatible with `DetectedProjectServer` and similar
|
|
76
|
+
* `{ name, root }` records — callers may pass a wider type safely.
|
|
77
|
+
*/
|
|
78
|
+
export type KnownRootEntry = { name: string; root: string };
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build a map of language/server name to sorted, deduplicated root paths.
|
|
82
|
+
*
|
|
83
|
+
* Accepts an array of detected project entries (e.g. from LSP project discovery)
|
|
84
|
+
* and groups them by name with roots sorted by specificity.
|
|
85
|
+
*/
|
|
86
|
+
export function buildKnownRootsMap(detected: KnownRootEntry[]): Map<string, string[]> {
|
|
87
|
+
const next = new Map<string, string[]>();
|
|
88
|
+
|
|
89
|
+
for (const entry of detected) {
|
|
90
|
+
const roots = next.get(entry.name) ?? [];
|
|
91
|
+
if (!roots.includes(entry.root)) roots.push(entry.root);
|
|
92
|
+
next.set(entry.name, sortRootsBySpecificity(roots));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return next;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Merge a new root into an existing list, deduplicating and sorting.
|
|
100
|
+
*
|
|
101
|
+
* Returns the original reference when the root is already present.
|
|
102
|
+
*/
|
|
103
|
+
export function mergeKnownRoots(roots: string[], root: string): string[] {
|
|
104
|
+
if (roots.includes(root)) return roots;
|
|
105
|
+
return sortRootsBySpecificity([...roots, root]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the most specific known root that contains `filePath`.
|
|
110
|
+
*
|
|
111
|
+
* Searches the given roots list (presumed sorted by specificity) and returns
|
|
112
|
+
* the first root that contains or equals `filePath`.
|
|
113
|
+
*
|
|
114
|
+
* @returns The matching root string, or `null` when none match.
|
|
115
|
+
*/
|
|
116
|
+
export function resolveKnownRoot(filePath: string, roots: string[]): string | null {
|
|
117
|
+
const resolvedPath = path.resolve(filePath);
|
|
118
|
+
return roots.find((root) => isWithinOrEqual(root, resolvedPath)) ?? null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Sort roots by specificity (deepest/longest first), then alphabetically.
|
|
123
|
+
*
|
|
124
|
+
* Deduplicates by resolved path before sorting.
|
|
125
|
+
*/
|
|
126
|
+
export function sortRootsBySpecificity(roots: string[]): string[] {
|
|
127
|
+
return [...new Set(roots.map((root) => path.resolve(root)))].sort(
|
|
128
|
+
(a, b) => b.length - a.length || a.localeCompare(b),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if `child` is strictly inside `parent`.
|
|
134
|
+
*
|
|
135
|
+
* Returns `true` when `child` is a subdirectory of `parent`.
|
|
136
|
+
* Returns `false` for the same path.
|
|
137
|
+
*/
|
|
138
|
+
export function isWithin(parent: string, child: string): boolean {
|
|
139
|
+
const relative = path.relative(parent, child);
|
|
140
|
+
return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== "..";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Check if `filePath` is inside `root` or is the same path.
|
|
145
|
+
*
|
|
146
|
+
* Combines exact-path equality with `isWithin` semantics.
|
|
147
|
+
*/
|
|
148
|
+
export function isWithinOrEqual(root: string, filePath: string): boolean {
|
|
149
|
+
const relative = path.relative(root, filePath);
|
|
150
|
+
return relative === "" || isWithin(root, filePath);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Comparator for sorting paths by depth (shallowest first), then alphabetically.
|
|
155
|
+
*
|
|
156
|
+
* Useful with `.sort()` on arrays of path strings.
|
|
157
|
+
*/
|
|
158
|
+
export function byPathDepth(a: string, b: string): number {
|
|
159
|
+
const depthDiff = segmentCount(a) - segmentCount(b);
|
|
160
|
+
return depthDiff !== 0 ? depthDiff : a.localeCompare(b);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Count path segments in a resolved absolute path.
|
|
165
|
+
*
|
|
166
|
+
* @example segmentCount("/a/b/c") // 3
|
|
167
|
+
*/
|
|
168
|
+
export function segmentCount(target: string): number {
|
|
169
|
+
return path.resolve(target).split(path.sep).filter(Boolean).length;
|
|
170
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// supi-core project domain — project root discovery and traversal.
|
|
2
|
+
export type { KnownRootEntry } from "./project-roots.ts";
|
|
3
|
+
export {
|
|
4
|
+
buildKnownRootsMap,
|
|
5
|
+
byPathDepth,
|
|
6
|
+
dedupeTopmostRoots,
|
|
7
|
+
findProjectRoot,
|
|
8
|
+
isWithin,
|
|
9
|
+
isWithinOrEqual,
|
|
10
|
+
mergeKnownRoots,
|
|
11
|
+
resolveKnownRoot,
|
|
12
|
+
segmentCount,
|
|
13
|
+
sortRootsBySpecificity,
|
|
14
|
+
walkProject,
|
|
15
|
+
} from "./project-roots.ts";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Shared registry utility for SuPi extensions.
|
|
2
|
+
//
|
|
3
|
+
// Provides a globalThis-backed registry pattern so that all jiti module instances
|
|
4
|
+
// (resolved through different node_modules symlinks) share the same Map.
|
|
5
|
+
// Without this, each symlink path gets its own module copy and its own Map,
|
|
6
|
+
// so registrations from one instance are invisible to consumers in another.
|
|
7
|
+
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
|
|
11
|
+
|
|
12
|
+
function getGlobalRegistryMap<T>(name: string): Map<string, T> {
|
|
13
|
+
const key = Symbol.for(SYMBOL_PREFIX + name);
|
|
14
|
+
let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
|
|
15
|
+
if (!map) {
|
|
16
|
+
map = new Map<string, T>();
|
|
17
|
+
(globalThis as Record<symbol, unknown>)[key] = map;
|
|
18
|
+
}
|
|
19
|
+
return map;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a named registry backed by `globalThis` + `Symbol.for`.
|
|
24
|
+
*
|
|
25
|
+
* The registry is lazily initialized on first access and shared across all
|
|
26
|
+
* jiti module instances via the global symbol namespace.
|
|
27
|
+
*
|
|
28
|
+
* @typeParam T - The value type stored in the registry.
|
|
29
|
+
* @param name - Unique registry name (used to construct the `Symbol.for` key).
|
|
30
|
+
* @returns An object with `register`, `unregister`, `getAll`, and `clear` functions.
|
|
31
|
+
*/
|
|
32
|
+
export function createRegistry<T>(name: string) {
|
|
33
|
+
const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
/**
|
|
37
|
+
* Register a value by id. Duplicate ids silently replace the previous registration.
|
|
38
|
+
*/
|
|
39
|
+
register: (id: string, value: T): void => {
|
|
40
|
+
getMap().set(id, value);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Remove a registration by id. No-op if not registered.
|
|
45
|
+
*/
|
|
46
|
+
unregister: (id: string): void => {
|
|
47
|
+
getMap().delete(id);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get all registered values in registration order.
|
|
52
|
+
*/
|
|
53
|
+
getAll: (): T[] => {
|
|
54
|
+
return Array.from(getMap().values());
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clear all entries from the registry (primarily for tests).
|
|
59
|
+
*/
|
|
60
|
+
clear: (): void => {
|
|
61
|
+
getMap().clear();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a named session-state registry keyed by normalized cwd.
|
|
68
|
+
*
|
|
69
|
+
* This helper is intended for session-scoped runtime services that should be
|
|
70
|
+
* shared across duplicate jiti module instances while keeping package-specific
|
|
71
|
+
* state unions and convenience wrappers local to the calling package.
|
|
72
|
+
*/
|
|
73
|
+
export function createSessionStateRegistry<TState>(name: string) {
|
|
74
|
+
const getMap = (): Map<string, TState> => getGlobalRegistryMap<TState>(name);
|
|
75
|
+
const normalizeCwd = (cwd: string): string => path.resolve(cwd);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
/** Get the current state for one session cwd. */
|
|
79
|
+
get: (cwd: string): TState | undefined => {
|
|
80
|
+
return getMap().get(normalizeCwd(cwd));
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/** Store the current state for one session cwd. */
|
|
84
|
+
set: (cwd: string, state: TState): void => {
|
|
85
|
+
getMap().set(normalizeCwd(cwd), state);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
/** Clear the current state for one session cwd. */
|
|
89
|
+
clear: (cwd: string): void => {
|
|
90
|
+
getMap().delete(normalizeCwd(cwd));
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/** Minimal theme surface required by the shared report helpers. */
|
|
5
|
+
export type ReportTheme = Pick<Theme, "fg">;
|
|
6
|
+
|
|
7
|
+
/** Color keys accepted by the report helpers. */
|
|
8
|
+
export type ReportColor = Parameters<Theme["fg"]>[0];
|
|
9
|
+
|
|
10
|
+
/** Options for rendering a themed key/value report row. */
|
|
11
|
+
export interface KeyValueLineOptions {
|
|
12
|
+
/** The label shown on the left. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** The value shown on the right. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Theme used for color formatting. */
|
|
17
|
+
theme: ReportTheme;
|
|
18
|
+
/** Maximum rendered width. */
|
|
19
|
+
width: number;
|
|
20
|
+
/** Left indentation in spaces. */
|
|
21
|
+
indent?: number;
|
|
22
|
+
/** Theme color applied to the label. Defaults to `"text"`. */
|
|
23
|
+
labelColor?: ReportColor;
|
|
24
|
+
/** Theme color applied to the value. Defaults to `"dim"`. */
|
|
25
|
+
valueColor?: ReportColor;
|
|
26
|
+
/** Separator placed between label and value. Defaults to `": "`. */
|
|
27
|
+
separator?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Options for rendering a preview-overflow hint. */
|
|
31
|
+
export interface OverflowHintOptions {
|
|
32
|
+
/** Optional follow-up hint such as `run /supi-context full`. */
|
|
33
|
+
hint?: string | null;
|
|
34
|
+
/** Left indentation in spaces. Defaults to `2`. */
|
|
35
|
+
indent?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Ensure a report width never drops below the minimum readable width. */
|
|
39
|
+
export function clampReportWidth(width: number, minWidth = 24): number {
|
|
40
|
+
return Math.max(minWidth, width);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Render a top-level report title line with theme color and truncation. */
|
|
44
|
+
export function formatReportTitle(
|
|
45
|
+
title: string,
|
|
46
|
+
theme: ReportTheme,
|
|
47
|
+
width: number,
|
|
48
|
+
color: ReportColor = "accent",
|
|
49
|
+
): string {
|
|
50
|
+
return truncateToWidth(theme.fg(color, title), width);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Render a section header with optional dimmed metadata.
|
|
55
|
+
*
|
|
56
|
+
* Example: `Usage by category 42.3k tokens`
|
|
57
|
+
*/
|
|
58
|
+
export function formatSectionHeader(
|
|
59
|
+
title: string,
|
|
60
|
+
meta: string | null,
|
|
61
|
+
theme: ReportTheme,
|
|
62
|
+
width: number,
|
|
63
|
+
): string {
|
|
64
|
+
const left = theme.fg("text", title);
|
|
65
|
+
const content = meta ? `${left}${theme.fg("dim", ` ${meta}`)}` : left;
|
|
66
|
+
return truncateToWidth(content, width);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render a single dimmed report line with optional left indentation. */
|
|
70
|
+
export function formatDimLine(text: string, theme: ReportTheme, width: number, indent = 0): string {
|
|
71
|
+
const safeIndent = Math.max(0, indent);
|
|
72
|
+
return truncateToWidth(`${" ".repeat(safeIndent)}${theme.fg("dim", text)}`, width);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Render a dimmed preview-overflow hint such as `… and 3 more — run /foo full`. */
|
|
76
|
+
export function formatOverflowHint(
|
|
77
|
+
hiddenCount: number,
|
|
78
|
+
theme: ReportTheme,
|
|
79
|
+
width: number,
|
|
80
|
+
options: OverflowHintOptions = {},
|
|
81
|
+
): string {
|
|
82
|
+
const { hint = null, indent = 2 } = options;
|
|
83
|
+
const suffix = hint ? ` — ${hint}` : "";
|
|
84
|
+
return formatDimLine(`… and ${hiddenCount} more${suffix}`, theme, width, indent);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render a single themed `label: value` row with truncation. */
|
|
88
|
+
export function formatKeyValueLine(options: KeyValueLineOptions): string {
|
|
89
|
+
const {
|
|
90
|
+
label,
|
|
91
|
+
value,
|
|
92
|
+
theme,
|
|
93
|
+
width,
|
|
94
|
+
indent = 2,
|
|
95
|
+
labelColor = "text",
|
|
96
|
+
valueColor = "dim",
|
|
97
|
+
separator = ": ",
|
|
98
|
+
} = options;
|
|
99
|
+
const safeIndent = Math.max(0, indent);
|
|
100
|
+
|
|
101
|
+
return truncateToWidth(
|
|
102
|
+
`${" ".repeat(safeIndent)}${theme.fg(labelColor, label)}${separator}${theme.fg(valueColor, value)}`,
|
|
103
|
+
width,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Wrap a report text block to the available width and optionally prefix each line.
|
|
109
|
+
*
|
|
110
|
+
* This is useful for wrapped bullets or explanatory notes that should align
|
|
111
|
+
* under an existing report indent.
|
|
112
|
+
*/
|
|
113
|
+
export function wrapReportText(
|
|
114
|
+
text: string,
|
|
115
|
+
width: number,
|
|
116
|
+
options: { indent?: string } = {},
|
|
117
|
+
): string[] {
|
|
118
|
+
const indent = options.indent ?? "";
|
|
119
|
+
const wrapped = wrapTextWithAnsi(text, Math.max(1, width - indent.length));
|
|
120
|
+
return indent ? wrapped.map((line) => `${indent}${line}`) : wrapped;
|
|
121
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeSessionCapabilityProfileSnapshot,
|
|
3
|
+
type SessionCapabilityProfile,
|
|
4
|
+
type SessionCapabilityProfileSnapshot,
|
|
5
|
+
} from "./session-capability-profiles.ts";
|
|
6
|
+
|
|
7
|
+
/** Session-only tool and skill visibility state. */
|
|
8
|
+
export interface SessionCapabilityState {
|
|
9
|
+
version: 1;
|
|
10
|
+
eligibleToolNames: string[];
|
|
11
|
+
initiallyInactiveToolNames: string[];
|
|
12
|
+
toolDenylist: string[];
|
|
13
|
+
/** Tools that the user explicitly enabled for this session. */
|
|
14
|
+
toolEnabledNames?: string[];
|
|
15
|
+
hiddenSkillNames: string[];
|
|
16
|
+
/** Original profile identity and exclusions. Later configuration changes do not affect this copy. */
|
|
17
|
+
profileSnapshot?: SessionCapabilityProfileSnapshot;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A model-visible skill that a provider can offer to the selector. */
|
|
21
|
+
export interface SessionCapabilitySkill {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Skill inventory provider registered by the skills extension. */
|
|
27
|
+
export interface SessionCapabilitySkillProvider<TContext = unknown> {
|
|
28
|
+
listEligibleSkills(
|
|
29
|
+
context: TContext,
|
|
30
|
+
): Promise<SessionCapabilitySkill[]> | SessionCapabilitySkill[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Custom session-entry type used for hidden capability snapshots. */
|
|
34
|
+
export const SESSION_CAPABILITIES_ENTRY = "supi-capabilities";
|
|
35
|
+
|
|
36
|
+
const REGISTRY_PREFIX = "@mrclrchtr/supi-core/session-capabilities/";
|
|
37
|
+
const STATE_KEY = Symbol.for(`${REGISTRY_PREFIX}state`);
|
|
38
|
+
const PROVIDER_KEY = Symbol.for(`${REGISTRY_PREFIX}skill-providers`);
|
|
39
|
+
const FORK_KEY = Symbol.for(`${REGISTRY_PREFIX}pending-forks`);
|
|
40
|
+
|
|
41
|
+
type GlobalRegistry = typeof globalThis & Record<symbol, unknown>;
|
|
42
|
+
|
|
43
|
+
type SkillProviderMap = Map<string, SessionCapabilitySkillProvider<unknown>>;
|
|
44
|
+
type ForkStateMap = Map<string, SessionCapabilityState>;
|
|
45
|
+
|
|
46
|
+
function getStateMap(): Map<string, SessionCapabilityState> {
|
|
47
|
+
const target = globalThis as GlobalRegistry;
|
|
48
|
+
let map = target[STATE_KEY] as Map<string, SessionCapabilityState> | undefined;
|
|
49
|
+
if (!map) {
|
|
50
|
+
map = new Map();
|
|
51
|
+
target[STATE_KEY] = map;
|
|
52
|
+
}
|
|
53
|
+
return map;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getProviderMap(): SkillProviderMap {
|
|
57
|
+
const target = globalThis as GlobalRegistry;
|
|
58
|
+
let map = target[PROVIDER_KEY] as SkillProviderMap | undefined;
|
|
59
|
+
if (!map) {
|
|
60
|
+
map = new Map();
|
|
61
|
+
target[PROVIDER_KEY] = map;
|
|
62
|
+
}
|
|
63
|
+
return map;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getForkMap(): ForkStateMap {
|
|
67
|
+
const target = globalThis as GlobalRegistry;
|
|
68
|
+
let map = target[FORK_KEY] as ForkStateMap | undefined;
|
|
69
|
+
if (!map) {
|
|
70
|
+
map = new Map();
|
|
71
|
+
target[FORK_KEY] = map;
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function stringList(value: unknown): string[] {
|
|
77
|
+
if (!Array.isArray(value)) return [];
|
|
78
|
+
return Array.from(new Set(value.filter((item): item is string => typeof item === "string")));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Create an empty state snapshot. */
|
|
82
|
+
export function createEmptySessionCapabilityState(): SessionCapabilityState {
|
|
83
|
+
return {
|
|
84
|
+
version: 1,
|
|
85
|
+
eligibleToolNames: [],
|
|
86
|
+
initiallyInactiveToolNames: [],
|
|
87
|
+
toolDenylist: [],
|
|
88
|
+
toolEnabledNames: [],
|
|
89
|
+
hiddenSkillNames: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Validate and copy one stored snapshot. */
|
|
94
|
+
export function decodeSessionCapabilities(value: unknown): SessionCapabilityState | undefined {
|
|
95
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
96
|
+
const record = value as Record<string, unknown>;
|
|
97
|
+
if (record.version !== 1) return undefined;
|
|
98
|
+
|
|
99
|
+
const initiallyInactiveToolNames = stringList(record.initiallyInactiveToolNames);
|
|
100
|
+
const initiallyInactive = new Set(initiallyInactiveToolNames);
|
|
101
|
+
const eligibleToolNames = stringList(record.eligibleToolNames).filter(
|
|
102
|
+
(name) => !initiallyInactive.has(name),
|
|
103
|
+
);
|
|
104
|
+
const eligible = new Set(eligibleToolNames);
|
|
105
|
+
const toolDenylist = stringList(record.toolDenylist);
|
|
106
|
+
const denied = new Set(toolDenylist);
|
|
107
|
+
const profileSnapshot = decodeSessionCapabilityProfileSnapshot(record.profileSnapshot);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
version: 1,
|
|
111
|
+
eligibleToolNames,
|
|
112
|
+
initiallyInactiveToolNames,
|
|
113
|
+
toolDenylist,
|
|
114
|
+
toolEnabledNames: stringList(record.toolEnabledNames).filter(
|
|
115
|
+
(name) => eligible.has(name) && !denied.has(name),
|
|
116
|
+
),
|
|
117
|
+
hiddenSkillNames: stringList(record.hiddenSkillNames),
|
|
118
|
+
...(profileSnapshot ? { profileSnapshot } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Copy current exclusion sets for saving. Do not use inventory to remove names. */
|
|
123
|
+
export function captureSessionCapabilityExclusions(
|
|
124
|
+
state: SessionCapabilityState,
|
|
125
|
+
): SessionCapabilityProfile {
|
|
126
|
+
return {
|
|
127
|
+
excludedTools: [...new Set(state.toolDenylist)],
|
|
128
|
+
hiddenSkills: [...new Set(state.hiddenSkillNames)],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Replace exclusions and capture a profile copy. Return undefined for an invalid profile.
|
|
134
|
+
* Do not mutate the input state or activate tools. The caller owns runtime restoration.
|
|
135
|
+
*/
|
|
136
|
+
export function applySessionCapabilityProfile(
|
|
137
|
+
state: SessionCapabilityState,
|
|
138
|
+
profile: unknown,
|
|
139
|
+
): SessionCapabilityState | undefined {
|
|
140
|
+
const profileSnapshot = decodeSessionCapabilityProfileSnapshot(profile);
|
|
141
|
+
if (!profileSnapshot) return undefined;
|
|
142
|
+
return decodeSessionCapabilities({
|
|
143
|
+
...state,
|
|
144
|
+
toolDenylist: profileSnapshot.excludedTools,
|
|
145
|
+
hiddenSkillNames: profileSnapshot.hiddenSkills,
|
|
146
|
+
profileSnapshot,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function equalNameSets(left: readonly string[], right: readonly string[]): boolean {
|
|
151
|
+
const leftSet = new Set(left);
|
|
152
|
+
const rightSet = new Set(right);
|
|
153
|
+
return leftSet.size === rightSet.size && [...leftSet].every((name) => rightSet.has(name));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Compare current exclusions with the applied copy. No profile means no Modified marker. */
|
|
157
|
+
export function isSessionCapabilityProfileModified(state: SessionCapabilityState): boolean {
|
|
158
|
+
const snapshot = state.profileSnapshot;
|
|
159
|
+
return (
|
|
160
|
+
snapshot !== undefined &&
|
|
161
|
+
(!equalNameSets(state.toolDenylist, snapshot.excludedTools) ||
|
|
162
|
+
!equalNameSets(state.hiddenSkillNames, snapshot.hiddenSkills))
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Return a safe copy of the latest valid snapshot in all session entries. */
|
|
167
|
+
export function findSessionCapabilities(
|
|
168
|
+
entries: readonly unknown[],
|
|
169
|
+
): SessionCapabilityState | undefined {
|
|
170
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
171
|
+
const entry = entries[index];
|
|
172
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
173
|
+
const record = entry as Record<string, unknown>;
|
|
174
|
+
if (record.type !== "custom" || record.customType !== SESSION_CAPABILITIES_ENTRY) continue;
|
|
175
|
+
const state = decodeSessionCapabilities(record.data);
|
|
176
|
+
if (state) return state;
|
|
177
|
+
}
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Shared session state. Session IDs keep sessions in the same cwd separate. */
|
|
182
|
+
export const sessionCapabilityState = {
|
|
183
|
+
get(sessionId: string): SessionCapabilityState | undefined {
|
|
184
|
+
const state = getStateMap().get(sessionId);
|
|
185
|
+
return state ? decodeSessionCapabilities(state) : undefined;
|
|
186
|
+
},
|
|
187
|
+
set(sessionId: string, state: SessionCapabilityState): void {
|
|
188
|
+
const decoded = decodeSessionCapabilities(state);
|
|
189
|
+
if (decoded) getStateMap().set(sessionId, decoded);
|
|
190
|
+
},
|
|
191
|
+
clear(sessionId: string): void {
|
|
192
|
+
getStateMap().delete(sessionId);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Register the current session's skill inventory provider. */
|
|
197
|
+
export function registerSessionCapabilitySkillProvider<TContext>(
|
|
198
|
+
sessionId: string,
|
|
199
|
+
provider: SessionCapabilitySkillProvider<TContext>,
|
|
200
|
+
): () => void {
|
|
201
|
+
const providers = getProviderMap();
|
|
202
|
+
providers.set(sessionId, provider as SessionCapabilitySkillProvider<unknown>);
|
|
203
|
+
return () => {
|
|
204
|
+
if (providers.get(sessionId) === provider) providers.delete(sessionId);
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Get the current session's skill inventory provider, if one is loaded. */
|
|
209
|
+
export function getSessionCapabilitySkillProvider<TContext = unknown>(
|
|
210
|
+
sessionId: string,
|
|
211
|
+
): SessionCapabilitySkillProvider<TContext> | undefined {
|
|
212
|
+
return getProviderMap().get(sessionId) as SessionCapabilitySkillProvider<TContext> | undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function forkKey(sourceSessionFile: string | undefined): string {
|
|
216
|
+
return sourceSessionFile ?? "<memory-session>";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Hold a state copy until PI starts the forked session. */
|
|
220
|
+
export function setPendingSessionCapabilityFork(
|
|
221
|
+
sourceSessionFile: string | undefined,
|
|
222
|
+
state: SessionCapabilityState,
|
|
223
|
+
): void {
|
|
224
|
+
const decoded = decodeSessionCapabilities(state);
|
|
225
|
+
if (decoded) getForkMap().set(forkKey(sourceSessionFile), decoded);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Take the state copy for one fork. The copy is used once. */
|
|
229
|
+
export function readPendingSessionCapabilityFork(
|
|
230
|
+
sourceSessionFile: string | undefined,
|
|
231
|
+
): SessionCapabilityState | undefined {
|
|
232
|
+
const forks = getForkMap();
|
|
233
|
+
const key = forkKey(sourceSessionFile);
|
|
234
|
+
const state = forks.get(key);
|
|
235
|
+
forks.delete(key);
|
|
236
|
+
return state ? decodeSessionCapabilities(state) : undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Clear pending fork copies after a cancelled or unrelated transition. */
|
|
240
|
+
export function clearPendingSessionCapabilityForks(): void {
|
|
241
|
+
getForkMap().clear();
|
|
242
|
+
}
|