@xynogen/pix-core 0.1.1 → 0.1.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/package.json +3 -1
- package/src/commands/agent-sop/agent-sop.ts +58 -0
- package/src/index.ts +3 -1
- package/src/lib/data.ts +21 -229
- package/src/nudge/capability.test.ts +45 -5
- package/src/nudge/capability.ts +45 -9
- package/src/tool/ask/ask.test.ts +2 -2
- package/src/tool/ask/components.ts +55 -0
- package/src/tool/ask/helpers.ts +77 -0
- package/src/tool/ask/index.ts +130 -0
- package/src/tool/ask/{ask.ts → questionnaire.ts} +60 -473
- package/src/tool/ask/rpc.ts +84 -0
- package/src/tool/ask/schema.ts +69 -0
- package/src/tool/ask/types.ts +17 -0
- package/src/tool/toolbox/toolbox.ts +9 -1
- package/src/ui/diagnostics.ts +3 -6
- package/src/ui/welcome.ts +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Pi extension — core UI/UX bundle (welcome banner, footer, model picker, self-update)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"@xynogen/pix-data": "^0.1.0",
|
|
46
|
+
"@xynogen/pix-skills": "^0.1.1",
|
|
45
47
|
"typebox": "^1.1.38"
|
|
46
48
|
},
|
|
47
49
|
"peerDependencies": {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-sop — inject AGENT.md (from pix-skills) into system prompt
|
|
3
|
+
*
|
|
4
|
+
* Reads AGENT.md from the @xynogen/pix-skills package and appends it to the
|
|
5
|
+
* system prompt on every agent start via `before_agent_start`.
|
|
6
|
+
*
|
|
7
|
+
* This is the "register skill" mechanism for the agent operating spec — no
|
|
8
|
+
* static SKILL.md file needed. The content becomes part of the model's
|
|
9
|
+
* standing instructions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
/** Resolve the absolute path to AGENT.md inside @xynogen/pix-skills. */
|
|
18
|
+
function resolveAgentMdPath(): string | null {
|
|
19
|
+
try {
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const pkgJson = require.resolve("@xynogen/pix-skills/package.json");
|
|
22
|
+
return resolve(pkgJson, "..", "AGENT.md");
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read and return AGENT.md content, or null if unavailable. */
|
|
29
|
+
function loadAgentMd(): string | null {
|
|
30
|
+
const p = resolveAgentMdPath();
|
|
31
|
+
if (!p || !existsSync(p)) return null;
|
|
32
|
+
try {
|
|
33
|
+
return readFileSync(p, "utf-8");
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function registerAgentSop(pi: ExtensionAPI): void {
|
|
40
|
+
// Load once at startup (content is static per session).
|
|
41
|
+
const agentMdContent = loadAgentMd();
|
|
42
|
+
|
|
43
|
+
if (!agentMdContent) {
|
|
44
|
+
// Silent skip — pix-skills might not be installed.
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pi.on("before_agent_start", async (event) => {
|
|
49
|
+
// Skip if already injected (idempotent check via a simple marker).
|
|
50
|
+
if (event.systemPrompt.includes("pix-agent-sop")) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
systemPrompt: `${event.systemPrompt}\n\n<pix-agent-sop>\n${agentMdContent}\n</pix-agent-sop>`,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import registerAgentSop from "./commands/agent-sop/agent-sop.ts";
|
|
19
20
|
import registerClear from "./commands/clear/clear.ts";
|
|
20
21
|
import registerCopyAll from "./commands/copy-all/copy-all.ts";
|
|
21
22
|
import registerDiff from "./commands/diff/diff.ts";
|
|
@@ -24,7 +25,7 @@ import registerModels from "./commands/models/models.ts";
|
|
|
24
25
|
import registerUpdate from "./commands/update/update.ts";
|
|
25
26
|
import registerYeet from "./commands/yeet/yeet.ts";
|
|
26
27
|
import registerNudges from "./nudge/index.ts";
|
|
27
|
-
import registerAsk from "./tool/ask/
|
|
28
|
+
import registerAsk from "./tool/ask/index.ts";
|
|
28
29
|
import registerTodo from "./tool/todo/todo.ts";
|
|
29
30
|
import registerToolbox from "./tool/toolbox/toolbox.ts";
|
|
30
31
|
import registerDiagnostics from "./ui/diagnostics.ts";
|
|
@@ -32,6 +33,7 @@ import registerFooter from "./ui/footer.ts";
|
|
|
32
33
|
import registerWelcome from "./ui/welcome.ts";
|
|
33
34
|
|
|
34
35
|
export default function (pi: ExtensionAPI): void {
|
|
36
|
+
registerAgentSop(pi);
|
|
35
37
|
registerWelcome(pi);
|
|
36
38
|
registerFooter(pi);
|
|
37
39
|
registerDiagnostics(pi);
|
package/src/lib/data.ts
CHANGED
|
@@ -1,241 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* data.ts — model data layer (shim)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Cache lives at
|
|
4
|
+
* Thin re-export of the shared data layer from @xynogen/pix-data
|
|
5
|
+
* (github.com/xynogen/pix-mono/tree/main/packages/pix-data). Cache lives at
|
|
6
|
+
* ~/.cache/pi/ and is shared across all Pi extensions — pix-data warms it on
|
|
7
|
+
* session start; this extension reads from it.
|
|
6
8
|
*
|
|
7
9
|
* Consumers in this extension dir:
|
|
8
10
|
* footer.ts — lookupModelsDev, lookupBenchmark, ModelsDevModel
|
|
9
11
|
* models.ts — lookupModelsDev, lookupBenchmark
|
|
10
12
|
*/
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
cache_read?: number;
|
|
29
|
-
cache_write?: number;
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export type ModelsDevApi = Record<
|
|
34
|
-
string,
|
|
35
|
-
{ models?: Record<string, ModelsDevModel> }
|
|
36
|
-
>;
|
|
37
|
-
|
|
38
|
-
export interface BenchmarkEntry {
|
|
39
|
-
rank: number;
|
|
40
|
-
model: string;
|
|
41
|
-
creator: string;
|
|
42
|
-
sourceType?: string;
|
|
43
|
-
overallScore: number | null;
|
|
44
|
-
categoryScores?: Record<string, number | null>;
|
|
45
|
-
inputPrice: number | null;
|
|
46
|
-
outputPrice: number | null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface BenchmarkResponse {
|
|
50
|
-
lastUpdated?: string;
|
|
51
|
-
mode?: string;
|
|
52
|
-
models: BenchmarkEntry[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ── DataSource ─────────────────────────────────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
interface DataSourceOptions<T> {
|
|
58
|
-
url: string | (() => string);
|
|
59
|
-
headers?: () => Record<string, string> | undefined;
|
|
60
|
-
cachePath: string;
|
|
61
|
-
ttlMs?: number;
|
|
62
|
-
timeoutMs?: number;
|
|
63
|
-
parse: (raw: unknown) => T;
|
|
64
|
-
parseCache: (data: unknown) => T;
|
|
65
|
-
empty: T;
|
|
66
|
-
label: string;
|
|
67
|
-
skip?: () => boolean;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
class DataSource<T> {
|
|
71
|
-
private _mem: T | null = null;
|
|
72
|
-
private _inflight: Promise<T> | null = null;
|
|
73
|
-
private readonly opts: Required<DataSourceOptions<T>>;
|
|
74
|
-
|
|
75
|
-
constructor(opts: DataSourceOptions<T>) {
|
|
76
|
-
this.opts = {
|
|
77
|
-
ttlMs: 24 * 60 * 60 * 1000,
|
|
78
|
-
timeoutMs: 10_000,
|
|
79
|
-
headers: () => undefined,
|
|
80
|
-
skip: () => false,
|
|
81
|
-
...opts,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
async get(): Promise<T> {
|
|
86
|
-
if (this._inflight) return this._inflight;
|
|
87
|
-
this._inflight = this._load().finally(() => {
|
|
88
|
-
this._inflight = null;
|
|
89
|
-
});
|
|
90
|
-
return this._inflight;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
getCached(): T {
|
|
94
|
-
if (this._mem) return this._mem;
|
|
95
|
-
try {
|
|
96
|
-
if (existsSync(this.opts.cachePath)) {
|
|
97
|
-
const raw = JSON.parse(readFileSync(this.opts.cachePath, "utf-8")) as {
|
|
98
|
-
data: unknown;
|
|
99
|
-
};
|
|
100
|
-
this._mem = this.opts.parseCache(raw.data);
|
|
101
|
-
return this._mem;
|
|
102
|
-
}
|
|
103
|
-
} catch {
|
|
104
|
-
// No cache file or parse error — return empty
|
|
105
|
-
}
|
|
106
|
-
return this.opts.empty;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
private async _load(): Promise<T> {
|
|
110
|
-
if (this.opts.skip()) {
|
|
111
|
-
this._mem = this.opts.empty;
|
|
112
|
-
return this.opts.empty;
|
|
113
|
-
}
|
|
114
|
-
const cached = await this._readCache();
|
|
115
|
-
if (cached !== undefined && Date.now() - cached.ts < this.opts.ttlMs) {
|
|
116
|
-
const val = this.opts.parseCache(cached.data);
|
|
117
|
-
this._mem = val;
|
|
118
|
-
return val;
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
const url =
|
|
122
|
-
typeof this.opts.url === "function" ? this.opts.url() : this.opts.url;
|
|
123
|
-
const controller = new AbortController();
|
|
124
|
-
const timer = setTimeout(() => controller.abort(), this.opts.timeoutMs);
|
|
125
|
-
const response = await fetch(url, {
|
|
126
|
-
signal: controller.signal,
|
|
127
|
-
headers: this.opts.headers(),
|
|
128
|
-
}).finally(() => clearTimeout(timer));
|
|
129
|
-
if (!response.ok)
|
|
130
|
-
throw new Error(`${this.opts.label} fetch failed: ${response.status}`);
|
|
131
|
-
const raw = await response.json();
|
|
132
|
-
const val = this.opts.parse(raw);
|
|
133
|
-
this._mem = val;
|
|
134
|
-
void this._writeCache(raw);
|
|
135
|
-
return val;
|
|
136
|
-
} catch (error) {
|
|
137
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
138
|
-
if (cached !== undefined) {
|
|
139
|
-
console.warn(
|
|
140
|
-
`${this.opts.label} fetch failed, using stale cache: ${msg}`,
|
|
141
|
-
);
|
|
142
|
-
const val = this.opts.parseCache(cached.data);
|
|
143
|
-
this._mem = val;
|
|
144
|
-
return val;
|
|
145
|
-
}
|
|
146
|
-
console.warn(`${this.opts.label} unavailable: ${msg}`);
|
|
147
|
-
return this.opts.empty;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private async _readCache(): Promise<
|
|
152
|
-
{ ts: number; data: unknown } | undefined
|
|
153
|
-
> {
|
|
154
|
-
try {
|
|
155
|
-
const raw = await readFile(this.opts.cachePath, "utf8");
|
|
156
|
-
const parsed = JSON.parse(raw) as { ts: number; data: unknown };
|
|
157
|
-
if (typeof parsed.ts !== "number") return undefined;
|
|
158
|
-
return parsed;
|
|
159
|
-
} catch {
|
|
160
|
-
return undefined;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private async _writeCache(data: unknown): Promise<void> {
|
|
165
|
-
try {
|
|
166
|
-
await mkdir(dirname(this.opts.cachePath), { recursive: true });
|
|
167
|
-
await writeFile(
|
|
168
|
-
this.opts.cachePath,
|
|
169
|
-
JSON.stringify({ ts: Date.now(), data }),
|
|
170
|
-
);
|
|
171
|
-
} catch {
|
|
172
|
-
// Write failure is non-fatal
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ── Cache dir ─────────────────────────────────────────────────────────────────
|
|
178
|
-
|
|
179
|
-
const CACHE_DIR = join(
|
|
180
|
-
process.env.XDG_CACHE_HOME || join(homedir(), ".cache"),
|
|
181
|
-
"pi",
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
// ── Data sources (shared cache with pix-data) ─────────────────────────────────
|
|
185
|
-
|
|
186
|
-
const modelsDev = new DataSource<ModelsDevApi>({
|
|
187
|
-
label: "models.dev",
|
|
188
|
-
url: "https://models.dev/api.json",
|
|
189
|
-
cachePath: join(CACHE_DIR, "models.json"),
|
|
190
|
-
parse: (raw) => raw as ModelsDevApi,
|
|
191
|
-
parseCache: (data) => (data as ModelsDevApi) ?? {},
|
|
192
|
-
empty: {},
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
const benchmark = new DataSource<BenchmarkEntry[]>({
|
|
196
|
-
label: "benchlm",
|
|
197
|
-
url: "https://benchlm.ai/api/data/leaderboard",
|
|
198
|
-
cachePath: join(CACHE_DIR, "benchlm.json"),
|
|
199
|
-
parse: (raw) => (raw as BenchmarkResponse).models ?? [],
|
|
200
|
-
parseCache: (data) => (data as BenchmarkResponse)?.models ?? [],
|
|
201
|
-
empty: [],
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
// ── Lookup helpers ─────────────────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
export function lookupModelsDev(
|
|
207
|
-
provider: string,
|
|
208
|
-
id: string,
|
|
209
|
-
): ModelsDevModel | undefined {
|
|
210
|
-
const data = modelsDev.getCached();
|
|
211
|
-
const canonical = id.includes("/") ? id.slice(id.lastIndexOf("/") + 1) : id;
|
|
212
|
-
const exact = data[provider]?.models?.[canonical];
|
|
213
|
-
if (exact) return exact;
|
|
214
|
-
for (const p of Object.keys(data)) {
|
|
215
|
-
const hit = data[p]?.models?.[canonical];
|
|
216
|
-
if (hit) return hit;
|
|
217
|
-
}
|
|
218
|
-
return undefined;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function normBench(s: string): string {
|
|
222
|
-
return s
|
|
223
|
-
.toLowerCase()
|
|
224
|
-
.replace(/[-_.]+/g, " ")
|
|
225
|
-
.replace(/\s+/g, " ")
|
|
226
|
-
.trim();
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function lookupBenchmark(modelName: string): BenchmarkEntry | undefined {
|
|
230
|
-
const entries = benchmark.getCached();
|
|
231
|
-
const needle = normBench(modelName);
|
|
232
|
-
return (
|
|
233
|
-
entries.find((e) => normBench(e.model) === needle) ??
|
|
234
|
-
entries.find((e) => normBench(e.model).includes(needle)) ??
|
|
235
|
-
entries.find((e) => needle.includes(normBench(e.model)))
|
|
236
|
-
);
|
|
237
|
-
}
|
|
14
|
+
export {
|
|
15
|
+
benchmark,
|
|
16
|
+
buildModelsDevIndex,
|
|
17
|
+
CACHE_DIR,
|
|
18
|
+
DataSource,
|
|
19
|
+
fetchModelsDevIndex,
|
|
20
|
+
lookupBenchmark,
|
|
21
|
+
lookupInIndex,
|
|
22
|
+
lookupModelsDev,
|
|
23
|
+
modelsDev,
|
|
24
|
+
} from "@xynogen/pix-data";
|
|
25
|
+
export type {
|
|
26
|
+
BenchmarkEntry,
|
|
27
|
+
ModelsDevApi,
|
|
28
|
+
ModelsDevModel,
|
|
29
|
+
} from "@xynogen/pix-data";
|
|
238
30
|
|
|
239
31
|
export default function (_pi: unknown): void {
|
|
240
|
-
// pix-data warms this cache on startup — nothing to do here
|
|
32
|
+
// pix-data warms this cache on startup — nothing to do here.
|
|
241
33
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { describe, expect, test } from "bun:test";
|
|
2
4
|
import {
|
|
3
5
|
buildOrientation,
|
|
4
6
|
CAPABILITY_REMINDER,
|
|
5
7
|
countInvocableSkills,
|
|
8
|
+
graphifyHint,
|
|
6
9
|
partitionTools,
|
|
7
10
|
} from "./capability.ts";
|
|
8
11
|
|
|
@@ -45,8 +48,14 @@ describe("CAPABILITY_REMINDER", () => {
|
|
|
45
48
|
expect(CAPABILITY_REMINDER.toLowerCase()).toContain("improvis");
|
|
46
49
|
});
|
|
47
50
|
|
|
48
|
-
test("
|
|
49
|
-
expect(CAPABILITY_REMINDER).toContain("
|
|
51
|
+
test("nudges model to call skill() when a skill matches", () => {
|
|
52
|
+
expect(CAPABILITY_REMINDER).toContain("skill()");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("points at /toolbox slash command for discovery (not a function call)", () => {
|
|
56
|
+
expect(CAPABILITY_REMINDER).toContain("/toolbox");
|
|
57
|
+
// must NOT imply toolbox is a callable function
|
|
58
|
+
expect(CAPABILITY_REMINDER).not.toContain("toolbox(");
|
|
50
59
|
expect(CAPABILITY_REMINDER).toContain("function definitions");
|
|
51
60
|
});
|
|
52
61
|
});
|
|
@@ -123,10 +132,13 @@ describe("buildOrientation", () => {
|
|
|
123
132
|
expect(out).toContain("2 skills");
|
|
124
133
|
});
|
|
125
134
|
|
|
126
|
-
test("explains how to
|
|
135
|
+
test("explains how to use skill() and /toolbox for discovery", () => {
|
|
127
136
|
const out = buildOrientation([tool("read", "builtin")], []);
|
|
128
|
-
expect(out).toContain("
|
|
129
|
-
expect(out
|
|
137
|
+
expect(out).toContain("skill()");
|
|
138
|
+
expect(out).toContain("/toolbox");
|
|
139
|
+
// toolbox must NOT appear as a function call
|
|
140
|
+
expect(out).not.toContain("toolbox(");
|
|
141
|
+
expect(out.toLowerCase()).toMatch(/discover|enable/);
|
|
130
142
|
});
|
|
131
143
|
|
|
132
144
|
test("calls out gated tools and points at toolbox to enable them", () => {
|
|
@@ -192,4 +204,32 @@ describe("buildOrientation", () => {
|
|
|
192
204
|
const out = buildOrientation([tool("read", "builtin")], []);
|
|
193
205
|
expect(out.toLowerCase()).toContain("improvis");
|
|
194
206
|
});
|
|
207
|
+
|
|
208
|
+
test("frames the block as non-actionable so the model acts on the prompt", () => {
|
|
209
|
+
const out = buildOrientation([tool("read", "builtin")], []);
|
|
210
|
+
const last = out.trim().split("\n").at(-1) ?? "";
|
|
211
|
+
expect(last.toLowerCase()).toContain("not a task");
|
|
212
|
+
expect(last.toLowerCase()).toContain("do not reply");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("graphifyHint", () => {
|
|
217
|
+
const tmpDir = join(import.meta.dir, ".graphify-hint-test-tmp");
|
|
218
|
+
|
|
219
|
+
test("returns undefined when graphify-out/graph.json absent", () => {
|
|
220
|
+
expect(graphifyHint(tmpDir)).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("returns hint string when graphify-out/graph.json exists", () => {
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(join(tmpDir, "graphify-out"), { recursive: true });
|
|
226
|
+
writeFileSync(join(tmpDir, "graphify-out", "graph.json"), "{}");
|
|
227
|
+
const hint = graphifyHint(tmpDir);
|
|
228
|
+
expect(hint).toBeTypeOf("string");
|
|
229
|
+
expect(hint).toContain("graphify");
|
|
230
|
+
expect(hint).toContain("graphify query");
|
|
231
|
+
} finally {
|
|
232
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
});
|
|
195
235
|
});
|
package/src/nudge/capability.ts
CHANGED
|
@@ -9,13 +9,19 @@
|
|
|
9
9
|
* Two modes:
|
|
10
10
|
* 1. FIRST prompt of the session — an orientation block: a high-level
|
|
11
11
|
* description of WHAT is available (counts of tools / MCP tools / skills)
|
|
12
|
-
* and HOW to explore it
|
|
13
|
-
*
|
|
14
|
-
* (
|
|
12
|
+
* and HOW to explore it. We deliberately do NOT dump the whole inventory
|
|
13
|
+
* every turn — the model should call skill() for skills and use /toolbox
|
|
14
|
+
* (slash command, user-facing) to discover/enable gated tools.
|
|
15
15
|
* 2. EVERY subsequent prompt — the terse one-line CAPABILITY_REMINDER, a
|
|
16
|
-
* cheap (~40 tok) reinforcement that
|
|
16
|
+
* cheap (~40 tok) reinforcement that steers toward skill() and /toolbox.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: `toolbox` is a slash command only (/toolbox) — NOT a model-callable
|
|
19
|
+
* function tool. The model cannot call toolbox() in function definitions.
|
|
20
|
+
* The `skill` tool IS model-callable: skill() lists/loads bundled skills.
|
|
17
21
|
*/
|
|
18
22
|
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
19
25
|
import type {
|
|
20
26
|
BuildSystemPromptOptions,
|
|
21
27
|
ExtensionAPI,
|
|
@@ -26,10 +32,25 @@ type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
|
|
|
26
32
|
|
|
27
33
|
/** The standing per-turn reminder. Kept terse — it ships on every turn. */
|
|
28
34
|
export const CAPABILITY_REMINDER =
|
|
29
|
-
"Reminder —
|
|
35
|
+
"Reminder — check knowledge resources " +
|
|
30
36
|
"(skills/tools/MCP/web/user) before improvising. " +
|
|
31
|
-
"
|
|
32
|
-
"
|
|
37
|
+
"Matching skill? Call skill() first. " +
|
|
38
|
+
"Use /toolbox to discover/enable gated tools. " +
|
|
39
|
+
"All tools callable via function definitions.";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the optional graphify hint line.
|
|
43
|
+
* Returns a string if graphify-out/graph.json exists in cwd, else undefined.
|
|
44
|
+
*/
|
|
45
|
+
export function graphifyHint(cwd: string): string | undefined {
|
|
46
|
+
if (existsSync(join(cwd, "graphify-out", "graph.json"))) {
|
|
47
|
+
return (
|
|
48
|
+
"graphify-out/graph.json exists — for codebase questions (how does X work, " +
|
|
49
|
+
'where is Y, trace Z) run `graphify query "<question>"` before reading files.'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
33
54
|
|
|
34
55
|
/** Count model-invocable skills (excludes user-only /skill:name entries). */
|
|
35
56
|
export function countInvocableSkills(
|
|
@@ -107,11 +128,21 @@ export function buildOrientation(
|
|
|
107
128
|
if (gateLine) lines.push(gateLine);
|
|
108
129
|
lines.push(
|
|
109
130
|
"Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
|
|
110
|
-
"
|
|
131
|
+
"`skill()` lists/loads bundled skills — call it when a skill matches your task. " +
|
|
132
|
+
"/toolbox (slash command) discovers and enables gated tools.",
|
|
111
133
|
);
|
|
112
134
|
if (skillNames.length) {
|
|
113
135
|
lines.push(`Skills: ${skillNames.join(", ")}.`);
|
|
114
136
|
}
|
|
137
|
+
// Graphify hint — only when a graph is already built for this project
|
|
138
|
+
const gHint = graphifyHint(process.cwd());
|
|
139
|
+
if (gHint) lines.push(gHint);
|
|
140
|
+
// Framing — this block is orientation context, not a task. Without it the
|
|
141
|
+
// model can mistake the first-turn orientation for the prompt and reply
|
|
142
|
+
// "Ready, waiting for task" instead of acting on the user's request.
|
|
143
|
+
lines.push(
|
|
144
|
+
"(Orientation only — not a task. Act on the user's request now; do not reply to this notice.)",
|
|
145
|
+
);
|
|
115
146
|
return lines.join("\n");
|
|
116
147
|
}
|
|
117
148
|
|
|
@@ -140,7 +171,12 @@ export default function registerCapabilityNudge(pi: ExtensionAPI): void {
|
|
|
140
171
|
}
|
|
141
172
|
content = buildOrientation(tools, skills, activeToolNames);
|
|
142
173
|
} else {
|
|
143
|
-
|
|
174
|
+
// Per-turn reminder — append graphify hint when graph exists
|
|
175
|
+
const cwd = process.cwd();
|
|
176
|
+
const gHint = graphifyHint(cwd);
|
|
177
|
+
content = gHint
|
|
178
|
+
? `${CAPABILITY_REMINDER}\n${gHint}`
|
|
179
|
+
: CAPABILITY_REMINDER;
|
|
144
180
|
}
|
|
145
181
|
|
|
146
182
|
return {
|
package/src/tool/ask/ask.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type OptionData,
|
|
14
14
|
type QuestionData,
|
|
15
15
|
sentinelsFor,
|
|
16
|
-
} from "./
|
|
16
|
+
} from "./index.ts";
|
|
17
17
|
|
|
18
18
|
// ── Fixtures ──────────────────────────────────────────────────────────
|
|
19
19
|
|
|
@@ -237,7 +237,7 @@ describe("buildResponseText", () => {
|
|
|
237
237
|
|
|
238
238
|
describe("registerAsk", () => {
|
|
239
239
|
test("exports a default function", async () => {
|
|
240
|
-
const mod = await import("./
|
|
240
|
+
const mod = await import("./index.ts");
|
|
241
241
|
expect(typeof mod.default).toBe("function");
|
|
242
242
|
});
|
|
243
243
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { QuestionData } from "./schema.js";
|
|
4
|
+
|
|
5
|
+
// ── Color helpers ──────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export function borderColor(theme: Theme): (s: string) => string {
|
|
8
|
+
return (s: string) => theme.fg("accent", s);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function dim(theme: Theme): (s: string) => string {
|
|
12
|
+
return (s: string) => theme.fg("dim", s);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── TabBar ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export class TabBar implements Component {
|
|
18
|
+
private questions: QuestionData[];
|
|
19
|
+
private activeIndex: number;
|
|
20
|
+
private theme: Theme;
|
|
21
|
+
|
|
22
|
+
constructor(questions: QuestionData[], activeIndex: number, theme: Theme) {
|
|
23
|
+
this.questions = questions;
|
|
24
|
+
this.activeIndex = activeIndex;
|
|
25
|
+
this.theme = theme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
invalidate(): void {}
|
|
29
|
+
|
|
30
|
+
render(width: number): string[] {
|
|
31
|
+
const t = this.theme;
|
|
32
|
+
const inner = Math.max(10, width - 2);
|
|
33
|
+
|
|
34
|
+
const parts: string[] = [];
|
|
35
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
36
|
+
const active = i === this.activeIndex;
|
|
37
|
+
const num = `${i + 1}`;
|
|
38
|
+
const tag = `${num}.${this.questions[i]?.header}`;
|
|
39
|
+
parts.push(active ? t.fg("accent", t.bold(tag)) : t.fg("dim", tag));
|
|
40
|
+
}
|
|
41
|
+
const line = parts.join(t.fg("dim", " "));
|
|
42
|
+
return [
|
|
43
|
+
truncateToWidth(
|
|
44
|
+
t.fg("accent", "╭─") +
|
|
45
|
+
line +
|
|
46
|
+
t.fg(
|
|
47
|
+
"accent",
|
|
48
|
+
`${"─".repeat(Math.max(0, inner - line.length - 1))}╮`,
|
|
49
|
+
),
|
|
50
|
+
width,
|
|
51
|
+
"",
|
|
52
|
+
),
|
|
53
|
+
].filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { MarkdownTheme } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { OptionData, QuestionData } from "./schema.js";
|
|
4
|
+
import { SENTINEL_CHAT, SENTINEL_FREEFORM, SENTINEL_NEXT } from "./schema.js";
|
|
5
|
+
import type { AnswerKind, QuestionAnswer } from "./types.js";
|
|
6
|
+
|
|
7
|
+
// ── Markdown theme ─────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export function safeMarkdownTheme(): MarkdownTheme | undefined {
|
|
10
|
+
try {
|
|
11
|
+
const md = getMarkdownTheme();
|
|
12
|
+
if (!md) return undefined;
|
|
13
|
+
md.bold("");
|
|
14
|
+
return md;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Option / question helpers ──────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export function hasAnyPreview(q: QuestionData): boolean {
|
|
23
|
+
return q.options.some(
|
|
24
|
+
(o) => typeof o.preview === "string" && o.preview.length > 0,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Which sentinel rows are auto-appended for a question. */
|
|
29
|
+
export function sentinelsFor(
|
|
30
|
+
q: QuestionData,
|
|
31
|
+
): Array<{ kind: string; label: string }> {
|
|
32
|
+
const out: Array<{ kind: string; label: string }> = [];
|
|
33
|
+
if (q.multiSelect) {
|
|
34
|
+
out.push({ kind: "next", label: SENTINEL_NEXT });
|
|
35
|
+
} else if (!hasAnyPreview(q)) {
|
|
36
|
+
out.push({ kind: "other", label: SENTINEL_FREEFORM });
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Answer formatting ──────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export function formatAnswerScalar(a: QuestionAnswer): string {
|
|
44
|
+
if (a.kind === "multi") return (a.selected ?? []).join(", ");
|
|
45
|
+
if (a.kind === "custom") return a.answer ?? "(custom)";
|
|
46
|
+
if (a.kind === "chat") return "(chat)";
|
|
47
|
+
return a.answer ?? "(selected)";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildResponseText(
|
|
51
|
+
answers: QuestionAnswer[],
|
|
52
|
+
questions: QuestionData[],
|
|
53
|
+
): string {
|
|
54
|
+
const segs: string[] = [];
|
|
55
|
+
for (const a of answers) {
|
|
56
|
+
const q = questions[a.questionIndex]?.question ?? `Q${a.questionIndex + 1}`;
|
|
57
|
+
let s = `"${q}"="${formatAnswerScalar(a)}"`;
|
|
58
|
+
if (a.preview) s += `. selected preview: ${a.preview}`;
|
|
59
|
+
segs.push(s);
|
|
60
|
+
}
|
|
61
|
+
return segs.length
|
|
62
|
+
? `User answered: ${segs.join(". ")}.`
|
|
63
|
+
: "User declined to answer questions.";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Scroll indicator ───────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function scrollIndicator(index: number, total: number): string {
|
|
69
|
+
if (total <= 1) return "";
|
|
70
|
+
const pos = Math.round((index / (total - 1)) * 6);
|
|
71
|
+
const bar = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦"][pos] ?? "·";
|
|
72
|
+
return ` ${bar} ${index + 1}/${total}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type { AnswerKind, OptionData, QuestionData };
|
|
76
|
+
// Re-export sentinel constants so callers don't need to import schema directly
|
|
77
|
+
export { SENTINEL_CHAT, SENTINEL_FREEFORM, SENTINEL_NEXT };
|