pi-system-one 1.1.3 → 1.2.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/src/tool.ts CHANGED
@@ -1,87 +1,488 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: intentional dynamic boundary over JSON protocol values
2
- // pi-system-one/src/tool.ts
3
-
4
1
  import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
5
- import { SystemOne, type SystemOneProvider } from "system-one-core";
2
+ import {
3
+ type QuestionMap,
4
+ SystemOne,
5
+ type SystemOneProvider,
6
+ type SystemOneState,
7
+ } from "system-one-core";
6
8
  import type { Static } from "typebox";
7
9
  import { Type } from "typebox";
8
10
  import { renderSystemOneResult } from "./render.ts";
9
11
 
10
- const ChoiceQ = Type.Object({
11
- type: Type.Literal("choice"),
12
- instructions: Type.Any(),
13
- criteria: Type.Record(Type.String(), Type.Union([Type.Any(), Type.Null()])),
14
- });
15
- const NoulQ = Type.Object({
16
- type: Type.Literal("noul"),
17
- instructions: Type.Any(),
18
- criteria: Type.Optional(
19
- Type.Record(Type.String(), Type.Union([Type.Any(), Type.Null()])),
20
- ),
21
- });
22
- const ScoreQ = Type.Object({
23
- type: Type.Literal("score"),
24
- instructions: Type.Any(),
25
- criteria: Type.Array(Type.Any()),
26
- });
27
- export const systemOneParams = Type.Object({
28
- state: Type.Union([
29
- Type.String(),
30
- Type.Record(Type.String(), Type.Any()),
31
- Type.Array(Type.Any()),
32
- ]),
33
- questions: Type.Record(Type.String(), Type.Union([ChoiceQ, NoulQ, ScoreQ])),
34
- model: Type.Optional(Type.String()),
35
- });
12
+ /**
13
+ * Public tool parameters (the LLM-facing boundary schema).
14
+ *
15
+ * The LLM-facing tool contract is intentionally more permissive than the
16
+ * System One domain model. The schema answers only "can I safely accept
17
+ * and inspect this?" — every field is optional, extra keys are allowed,
18
+ * and `questions` accepts any JSON so near-miss shapes (singular
19
+ * `question`, arrays, unwrapped singles, alias spellings like `Choice`,
20
+ * `rating`, or `Yes/No`) survive Pi's pre-execution validation on every
21
+ * Pi version, including older ones without `prepareArguments`.
22
+ * Normalization (`prepareSystemOneArgs`) repairs representation only;
23
+ * semantic validation in `execute()` (via `assertAnswerableQuestions`)
24
+ * establishes the canonical System One contract — `{type: "choice" |
25
+ * "noul" | "score", instructions, criteria}` per question — and rejects
26
+ * what cannot be repaired with an actionable error.
27
+ */
28
+ export const systemOneParams = Type.Object(
29
+ {
30
+ state: Type.Optional(
31
+ Type.Any({
32
+ description:
33
+ "Shared state or evidence that all questions should be evaluated against. Usually a string or object. Aliases: context, evidence.",
34
+ }),
35
+ ),
36
+
37
+ context: Type.Optional(Type.Any()),
38
+ evidence: Type.Optional(Type.Any()),
39
+
40
+ questions: Type.Optional(
41
+ Type.Any({
42
+ description:
43
+ 'One or more named decision questions. "choice" selects one outcome from a bounded set (criteria is an object keyed by label); "noul" estimates a yes/no likelihood (criteria usually omitted); "score" scores against an ordered rubric (criteria is an array of at least two levels). Accepts a name-keyed object, an array of questions, or a single unwrapped question. Aliases: question, queries; near-miss type spellings are normalized.',
44
+ }),
45
+ ),
46
+ question: Type.Optional(Type.Any()),
47
+ queries: Type.Optional(Type.Any()),
48
+ },
49
+ {
50
+ additionalProperties: true,
51
+ description:
52
+ 'Evaluate bounded decision questions against shared state. Example: {"state": "...", "questions": {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null, "b": null}}}}.',
53
+ },
54
+ );
55
+
36
56
  export type SystemOneParams = Static<typeof systemOneParams>;
37
57
 
58
+ /**
59
+ * Near-miss spellings low-quality models emit for the `type` discriminator.
60
+ * Lookup is case-insensitive; unlisted values pass through untouched so
61
+ * validation still reports a clear error for genuinely unknown kinds.
62
+ *
63
+ * Every entry here must be SAFE: a representation-only difference whose
64
+ * meaning is unambiguous. Deliberately NOT aliased (rejected instead):
65
+ * - "probability" / "likelihood": describe an output statistic, not a task.
66
+ * They could mean a noul question, a choice with probabilities, or a
67
+ * free-floating number. Guessing noul would silently convert the wrong
68
+ * task into a valid but semantically incorrect request — a believable
69
+ * wrong answer is worse than a validation error the model can retry.
70
+ * - "ranking": ordering items relative to each other is not scoring each
71
+ * against a rubric. Mapping it to score would corrupt the task.
72
+ */
73
+ const QUESTION_TYPE_ALIASES = new Map<string, string>([
74
+ ["choice", "choice"],
75
+ ["choose", "choice"],
76
+ ["multiple_choice", "choice"],
77
+ ["categorical", "choice"],
78
+ ["select", "choice"],
79
+ ["classify", "choice"],
80
+ ["classification", "choice"],
81
+ ["noul", "noul"],
82
+ ["boolean", "noul"],
83
+ ["bool", "noul"],
84
+ ["yes_no", "noul"],
85
+ ["yesno", "noul"],
86
+ ["score", "score"],
87
+ ["rating", "score"],
88
+ ["rate", "score"],
89
+ ["scale", "score"],
90
+ ["rubric", "score"],
91
+ ["grade", "score"],
92
+ ]);
93
+
94
+ /** Field aliases models use instead of `criteria`. First hit wins. */
95
+ const CRITERIA_ALIASES = [
96
+ "criteria",
97
+ "options",
98
+ "choices",
99
+ "levels",
100
+ "rubric",
101
+ "labels",
102
+ "candidates",
103
+ "outcomes",
104
+ ];
105
+
106
+ /** Field aliases models use instead of `instructions`. First hit wins. */
107
+ const INSTRUCTIONS_ALIASES = [
108
+ "instructions",
109
+ "instruction",
110
+ "prompt",
111
+ "question",
112
+ "text",
113
+ "query",
114
+ "task",
115
+ ];
116
+
117
+ /**
118
+ * Question-level keys that are never part of the schema (naming metadata
119
+ * from array-form questions, or hallucinated documentation fields).
120
+ * Dropped so they don't trip `additionalProperties: false`.
121
+ */
122
+ const QUESTION_JUNK_KEYS = new Set([
123
+ "description",
124
+ "title",
125
+ "detail",
126
+ "details",
127
+ "example",
128
+ "examples",
129
+ "id",
130
+ "key",
131
+ "name",
132
+ "label",
133
+ ]);
134
+
135
+ function isRecord(value: unknown): value is Record<string, unknown> {
136
+ return typeof value === "object" && value !== null && !Array.isArray(value);
137
+ }
138
+
139
+ /**
140
+ * Assign an externally controlled key. Plain `obj[key] = value` with
141
+ * `key === "__proto__"` would reparent the object instead of storing an
142
+ * entry; defineProperty always creates an ordinary own data property.
143
+ */
144
+ function setOwn(
145
+ obj: Record<string, unknown>,
146
+ key: string,
147
+ value: unknown,
148
+ ): void {
149
+ Object.defineProperty(obj, key, {
150
+ value,
151
+ enumerable: true,
152
+ writable: true,
153
+ configurable: true,
154
+ });
155
+ }
156
+
157
+ function normalizeQuestionType(raw: unknown): unknown {
158
+ if (typeof raw !== "string") return raw;
159
+ const key = raw
160
+ .trim()
161
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
162
+ .toLowerCase()
163
+ .replace(/[-\s/.]+/g, "_");
164
+ // Map lookup (not a plain object) so inherited names like "__proto__"
165
+ // or "constructor" can never accidentally resolve to an alias.
166
+ return QUESTION_TYPE_ALIASES.get(key) ?? raw;
167
+ }
168
+
169
+ function pickAlias(
170
+ input: Record<string, unknown>,
171
+ aliases: readonly string[],
172
+ ): unknown {
173
+ for (const alias of aliases) {
174
+ if (input[alias] !== undefined) return input[alias];
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * Coerce one question into the flat schema shape: canonical `type`,
181
+ * `instructions` / `criteria` resolved through aliases, array-form choice
182
+ * criteria (`["a", "b"]`) folded to `{"a": null, "b": null}`, and
183
+ * naming/junk keys stripped.
184
+ */
185
+ function normalizeQuestion(input: unknown): unknown {
186
+ if (!isRecord(input)) return input;
187
+ const out: Record<string, unknown> = {};
188
+ if (Object.hasOwn(input, "type"))
189
+ out.type = normalizeQuestionType(input.type);
190
+ const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
191
+ if (instructions !== undefined) out.instructions = instructions;
192
+ else if (Object.hasOwn(input, "instructions"))
193
+ out.instructions = input.instructions;
194
+ let criteria = pickAlias(input, CRITERIA_ALIASES);
195
+ // `null` is this schema's idiom for "no content"; on noul it reads as
196
+ // "omitted" rather than a degenerate value worth a backend round trip.
197
+ if (out.type === "noul" && criteria === null) criteria = undefined;
198
+ if (
199
+ out.type === "choice" &&
200
+ Array.isArray(criteria) &&
201
+ criteria.every((item) => typeof item === "string")
202
+ ) {
203
+ const folded: Record<string, unknown> = {};
204
+ for (const item of criteria as string[]) setOwn(folded, item, null);
205
+ criteria = folded;
206
+ }
207
+ if (criteria !== undefined) out.criteria = criteria;
208
+ // Preserve any other non-junk keys so genuinely new fields still surface
209
+ // in validation instead of being silently dropped.
210
+ for (const [key, value] of Object.entries(input)) {
211
+ if (
212
+ key === "type" ||
213
+ INSTRUCTIONS_ALIASES.includes(key) ||
214
+ CRITERIA_ALIASES.includes(key) ||
215
+ QUESTION_JUNK_KEYS.has(key)
216
+ ) {
217
+ continue;
218
+ }
219
+ setOwn(out, key, value);
220
+ }
221
+ return out;
222
+ }
223
+
224
+ function normalizeQuestions(input: unknown): unknown {
225
+ if (Array.isArray(input)) {
226
+ const record: Record<string, unknown> = {};
227
+ const used = new Set<string>();
228
+ // Duplicate names would silently overwrite; suffix instead so every
229
+ // requested question reaches the backend.
230
+ const claimName = (base: string): string => {
231
+ let candidate = base;
232
+ for (let n = 2; used.has(candidate); n += 1) candidate = `${base}_${n}`;
233
+ used.add(candidate);
234
+ return candidate;
235
+ };
236
+ input.forEach((entry, index) => {
237
+ if (isRecord(entry)) {
238
+ const { name, id, key, ...rest } = entry;
239
+ const rawName = name ?? id ?? key;
240
+ const base =
241
+ typeof rawName === "string" && rawName.trim() !== ""
242
+ ? rawName
243
+ : `q${index + 1}`;
244
+ setOwn(record, claimName(base), normalizeQuestion(rest));
245
+ } else {
246
+ setOwn(record, claimName(`q${index + 1}`), entry);
247
+ }
248
+ });
249
+ return record;
250
+ }
251
+ if (isRecord(input)) {
252
+ // Single question sent without a wrapping name (`questions: {type, ...}`)
253
+ // instead of (`questions: {q1: {type, ...}}`). Unwrap on a known kind so
254
+ // a hallucinated extra field still reports against the question (not as
255
+ // phantom questions named "type"/"instructions"); fall back to the
256
+ // vocabulary check for unknown types so those report precisely too. A
257
+ // record that merely contains a question *named* "type" has an object
258
+ // there, never a string, so it is never misread.
259
+ if (typeof input.type === "string") {
260
+ const kind = normalizeQuestionType(input.type);
261
+ const knownKind =
262
+ kind === "choice" || kind === "noul" || kind === "score";
263
+ const keys = Object.keys(input);
264
+ const allQuestionKeys = keys.every(
265
+ (key) =>
266
+ key === "type" ||
267
+ INSTRUCTIONS_ALIASES.includes(key) ||
268
+ CRITERIA_ALIASES.includes(key) ||
269
+ QUESTION_JUNK_KEYS.has(key),
270
+ );
271
+ if (knownKind || allQuestionKeys) return { q: normalizeQuestion(input) };
272
+ }
273
+ const record: Record<string, unknown> = {};
274
+ for (const [name, question] of Object.entries(input)) {
275
+ setOwn(record, name, normalizeQuestion(question));
276
+ }
277
+ return record;
278
+ }
279
+ return input;
280
+ }
281
+
282
+ /**
283
+ * Compatibility shim for sloppy tool arguments. Runs before schema
284
+ * validation (wired as `prepareArguments`) and is idempotent, so `execute`
285
+ * re-applies it defensively for direct calls that bypass validation.
286
+ */
287
+ export function prepareSystemOneArgs(args: unknown): SystemOneParams {
288
+ if (!isRecord(args)) return args as SystemOneParams;
289
+ const out: Record<string, unknown> = {};
290
+ if (args.state !== undefined) {
291
+ out.state = args.state;
292
+ } else if (args.context !== undefined) {
293
+ out.state = args.context;
294
+ } else if (args.evidence !== undefined) {
295
+ out.state = args.evidence;
296
+ }
297
+ const questions =
298
+ args.questions ?? args.question ?? args.queries ?? undefined;
299
+ if (questions !== undefined) out.questions = normalizeQuestions(questions);
300
+ // Drop unknown top-level keys; a missing `state` / `questions` still
301
+ // produces a clear error from semantic validation in execute().
302
+ return out as SystemOneParams;
303
+ }
304
+
38
305
  export interface SystemOneDetails {
39
306
  answers: Record<string, unknown>;
40
307
  model?: string;
41
- usage?: { inputTokens?: number; outputTokens?: number };
42
- metadata: { provider: string; latencyMs?: number };
308
+ usage?: {
309
+ inputTokens?: number;
310
+ outputTokens?: number;
311
+ };
312
+ requestId?: string;
313
+ metadata: {
314
+ provider: string;
315
+ latencyMs?: number;
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Runtime semantic validation: "is this actually a valid System One
321
+ * request?" The public schema lets repairable shapes through; this gate
322
+ * enforces the canonical contract the provider requires. Limits here are
323
+ * the ones the tool itself guarantees (verified against
324
+ * system-one-core, which enforces no option/level counts — the backend
325
+ * is the final arbiter for anything stricter). Every error states what
326
+ * was wrong, which question it was in, and what valid shape looks like.
327
+ */
328
+ const KNOWN_QUESTION_KEYS = new Set(["type", "instructions", "criteria"]);
329
+
330
+ function assertAnswerableQuestions(questions: unknown): void {
331
+ if (questions === undefined) {
332
+ throw new Error(
333
+ 'system_one: questions is required. Provide at least one named question, e.g. {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null}}}.',
334
+ );
335
+ }
336
+ // The semantic gate is authoritative: anything that is not a question
337
+ // map after normalization is rejected here, never sent to the provider.
338
+ if (!isRecord(questions)) {
339
+ throw new Error(
340
+ 'system_one: "questions" must resolve to a question map after normalization, e.g. {"q1": {"type": "choice", "instructions": "...", "criteria": {"a": null}}}.',
341
+ );
342
+ }
343
+ if (Object.keys(questions).length === 0) {
344
+ throw new Error(
345
+ "system_one: at least one question is required in questions.",
346
+ );
347
+ }
348
+ for (const [name, value] of Object.entries(questions)) {
349
+ if (!isRecord(value)) {
350
+ throw new Error(
351
+ `system_one: question "${name}" must be an object with "type", "instructions", and "criteria".`,
352
+ );
353
+ }
354
+ for (const key of Object.keys(value)) {
355
+ if (!KNOWN_QUESTION_KEYS.has(key)) {
356
+ throw new Error(
357
+ `system_one: question "${name}" has unknown field "${key}"; use only "type", "instructions", and "criteria".`,
358
+ );
359
+ }
360
+ }
361
+ if (value.instructions === undefined || value.instructions === null) {
362
+ throw new Error(
363
+ `system_one: question "${name}" needs "instructions" describing what to evaluate, e.g. {"type": "${typeof value.type === "string" ? value.type : "choice"}", "instructions": "Which option fits best?"}.`,
364
+ );
365
+ }
366
+ if (value.type === "choice") {
367
+ if (
368
+ !isRecord(value.criteria) ||
369
+ Object.keys(value.criteria).length === 0
370
+ ) {
371
+ throw new Error(
372
+ `system_one: choice question "${name}" needs criteria as an object keyed by choice label with at least one choice, e.g. {"a": null, "b": null}.`,
373
+ );
374
+ }
375
+ } else if (value.type === "score") {
376
+ if (!Array.isArray(value.criteria) || value.criteria.length < 2) {
377
+ throw new Error(
378
+ `system_one: score question "${name}" needs criteria as an ordered array of at least two levels from lowest to highest, e.g. ["low", "high"].`,
379
+ );
380
+ }
381
+ } else if (value.type === "noul") {
382
+ if (value.criteria !== undefined && !isRecord(value.criteria)) {
383
+ throw new Error(
384
+ `system_one: noul question "${name}" needs criteria omitted or as an object describing the true/false outcomes, e.g. {"true": "...", "false": "..."}.`,
385
+ );
386
+ }
387
+ } else {
388
+ const got =
389
+ typeof value.type === "string" ? `"${value.type}"` : "missing";
390
+ throw new Error(
391
+ `system_one: question "${name}" has unknown type ${got}; use "choice", "noul", or "score".`,
392
+ );
393
+ }
394
+ }
43
395
  }
44
396
 
45
- export function buildSystemOneTool(deps: { provider: SystemOneProvider }) {
46
- const systemOne = new SystemOne({ provider: deps.provider });
47
- const tool: ToolDefinition<typeof systemOneParams, SystemOneDetails> = {
397
+ export function buildSystemOneTool(deps: {
398
+ provider: SystemOneProvider;
399
+ }): ToolDefinition<typeof systemOneParams, SystemOneDetails> {
400
+ const systemOne = new SystemOne({
401
+ provider: deps.provider,
402
+ });
403
+
404
+ return {
48
405
  name: "system_one",
49
406
  label: "System One",
407
+
50
408
  description:
51
- "Evaluate one or more bounded decision questions against shared state using the configured System One provider. " +
52
- "Use this when the answer space is known and a fast probabilistic decision is preferable to generating free-form text. " +
53
- "Multiple independent choice, noul, and score questions should be batched into one call when they share the same state.",
409
+ "Judge bounded decisions over explicitly supplied state with a dedicated judge model — calibrated probabilities conditional on that state (confidence for choice/score only; noul has no confidence field, its probability is the uncertainty measure). " +
410
+ "Call system_one instead of answering directly when the request is a pick from named options, a yes/no likelihood, or a rubric score over state already in context — the judge's state-conditional probabilities beat guesses from priors. " +
411
+ "The judge only weighs the state you supply: it cannot recall facts or browse, so retrieve evidence first and never ask it to recall facts. " +
412
+ "Do not use it for open-ended text generation or factual lookup. " +
413
+ "Questions that share the same state can be evaluated together in one call. " +
414
+ 'Each question is {"type": "choice" | "noul" | "score", "instructions": "...", "criteria": ...}: ' +
415
+ 'choice criteria is an object keyed by choice label ({"a": null, "b": null}), ' +
416
+ "noul usually omits criteria, " +
417
+ "score criteria is an ordered array of at least two levels.",
418
+
54
419
  parameters: systemOneParams,
420
+
55
421
  promptSnippet:
56
- "Perform calibrated structured decisions and classifications over state",
422
+ "Use system_one for bounded choice, yes/no, and ordered-scale judgments over supplied evidence",
423
+
57
424
  promptGuidelines: [
58
- "Use system_one to evaluate one or more bounded decision questions",
59
- "Use system_one when you need structured probability, categorical choice, or scored rubric decisions rather than text generation.",
425
+ "Before answering a bounded judgment over supplied or retrieved evidence, call system_one instead of making the judgment yourself. A bounded judgment is: choosing among explicit alternatives, making a yes/no judgment, or rating something on an ordered scale.",
426
+ "Pick the system_one question type mechanically: unordered alternatives (frontend, backend, platform) -> choice; yes/no -> noul; ordered scale, rating, severity, risk, or grade -> score. Never use choice for an ordered scale merely because its levels have names: very low / low / moderate / high / very high is score, not choice.",
427
+ "Retrieve missing factual evidence into state first; never ask system_one to recall facts or browse. Use system_one for bounded judgments over available evidence, not as a replacement for factual lookup.",
428
+ "Batch independent system_one questions sharing the same state into one call instead of one call per question.",
429
+ "For system_one choice questions, the keys of criteria are the available choices — preserve user-specified labels and use null values when labels are self-explanatory; never add a separate options field.",
60
430
  ],
61
431
 
62
- async execute(toolCallId, params, signal, _onUpdate, _ctx) {
63
- void toolCallId;
64
- // Throw on failure per Pi contract — never encode errors in content.
65
- // Safe to propagate unwrapped: core error messages/codes never contain secrets.
432
+ prepareArguments: (args) => prepareSystemOneArgs(args),
433
+
434
+ async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
435
+ /*
436
+ * The LLM-facing TypeBox schema is intentionally separate from the
437
+ * provider protocol types. Cast only at this boundary instead of
438
+ * spreading `any` throughout the implementation.
439
+ *
440
+ * `prepareArguments` above is an optimization for newer Pi only: it
441
+ * runs before schema validation there, but older Pi versions ignore
442
+ * it and validate the raw model output against the permissive
443
+ * schema instead. Correctness never depends on it — re-normalize
444
+ * here so every path (new Pi, old Pi, direct calls) converges on
445
+ * the same canonical shape before semantic validation.
446
+ */
447
+ const normalized = prepareSystemOneArgs(params);
448
+ // prepareSystemOneArgs passes non-objects through untouched; direct
449
+ // calls bypassing Pi validation must get an actionable error, not a
450
+ // TypeError from property access on null.
451
+ if (!isRecord(normalized)) {
452
+ throw new Error(
453
+ 'system_one: request must be an object with "state" and "questions".',
454
+ );
455
+ }
456
+ if (normalized.state === undefined) {
457
+ throw new Error(
458
+ 'system_one: state is required. Put the material under judgment in "state" (string or object), e.g. {"state": "<evidence>", "questions": {...}}.',
459
+ );
460
+ }
461
+ assertAnswerableQuestions(normalized.questions);
66
462
  const response = await systemOne.evaluate(
67
463
  {
68
- state: params.state as any,
69
- questions: params.questions as any,
70
- ...(params.model ? { model: params.model } : {}),
464
+ state: normalized.state as SystemOneState,
465
+ questions: normalized.questions as QuestionMap,
71
466
  },
72
467
  { signal },
73
468
  );
74
- const text = renderSystemOneResult(response);
469
+
75
470
  return {
76
- content: [{ type: "text" as const, text }],
471
+ content: [
472
+ {
473
+ type: "text" as const,
474
+ text: renderSystemOneResult(response),
475
+ },
476
+ ],
477
+
77
478
  details: {
78
479
  answers: response.answers,
79
480
  model: response.model,
80
481
  usage: response.usage,
482
+ requestId: response.requestId,
81
483
  metadata: response.metadata,
82
484
  },
83
485
  };
84
486
  },
85
487
  };
86
- return tool;
87
488
  }
@@ -109,8 +109,13 @@ describe("so command", () => {
109
109
  await cap.handler?.("", ctxFor(["", "", "sk-mem", ""], cap));
110
110
  assert.equal(store.session?.keyInMemory, true);
111
111
  await cap.handler?.("", ctxFor(["", "", "-", ""], cap));
112
+ // After forgetting the memory key, the API key should revert to the environment variable (if any).
113
+ // In the CI environment, SYSTEM_ONE_API_KEY may be set; otherwise it will be undefined.
112
114
  assert.equal(store.session?.keyInMemory, false);
113
- assert.equal(store.session?.current.apiKey, undefined);
115
+ assert.equal(
116
+ store.session?.current.apiKey,
117
+ process.env.SYSTEM_ONE_API_KEY || undefined,
118
+ );
114
119
  });
115
120
  it("status shows current config; STATUS routes case-insensitively", async () => {
116
121
  const { pi, cap } = harness();
@@ -0,0 +1,123 @@
1
+ // pi-system-one/tests/extension.test.ts
2
+
3
+ import assert from "node:assert/strict";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { describe, it } from "node:test";
7
+ import piSystemOneExtension, {
8
+ systemOnePromptsDir,
9
+ systemOneSkillsDir,
10
+ } from "../src/extension.ts";
11
+
12
+ describe("extension resources", () => {
13
+ it("contributes a valid system-one skill directory", async () => {
14
+ const prev = process.env.SYSTEM_ONE_BASE_URL;
15
+ process.env.SYSTEM_ONE_BASE_URL = "http://localhost:8008";
16
+ try {
17
+ const handlers: Record<string, (event: never) => unknown> = {};
18
+ const pi = {
19
+ registerTool: () => {},
20
+ registerCommand: () => {},
21
+ on: (channel: string, handler: (event: never) => unknown) => {
22
+ handlers[channel] = handler;
23
+ },
24
+ };
25
+ piSystemOneExtension(pi as never);
26
+ const discover = handlers.resources_discover;
27
+ assert.ok(discover, "resources_discover handler registered");
28
+ const result = (await discover({} as never)) as {
29
+ skillPaths: string[];
30
+ };
31
+ assert.equal(result.skillPaths.length, 1);
32
+ assert.equal(result.skillPaths[0], systemOneSkillsDir());
33
+ const skillMd = join(result.skillPaths[0], "system-one", "SKILL.md");
34
+ assert.ok(existsSync(skillMd), `missing ${skillMd}`);
35
+ const raw = readFileSync(skillMd, "utf-8");
36
+ const frontmatter = raw.split("---")[1] ?? "";
37
+ // YAML forbids `: ` inside unquoted plain scalars (it starts a nested
38
+ // mapping — Pi rejects the skill). Every multi-word value must be
39
+ // quoted or use a block scalar.
40
+ for (const line of frontmatter.split("\n")) {
41
+ const trimmed = line.trim();
42
+ if (!trimmed || trimmed.startsWith("#")) continue;
43
+ const value = trimmed.replace(/^\w+:\s*/, "");
44
+ const quoted =
45
+ (value.startsWith('"') && value.endsWith('"')) ||
46
+ (value.startsWith("'") && value.endsWith("'")) ||
47
+ value.startsWith("|") ||
48
+ value.startsWith(">");
49
+ assert.ok(
50
+ quoted || !value.includes(": "),
51
+ `unquoted ": " in SKILL.md frontmatter: ${trimmed}`,
52
+ );
53
+ }
54
+ const stripQuotes = (s: string) =>
55
+ s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
56
+ const name = stripQuotes(
57
+ frontmatter.match(/name:\s*(.+)/)?.[1]?.trim() ?? "",
58
+ );
59
+ const description = stripQuotes(
60
+ frontmatter.match(/description:\s*(.+)/)?.[1]?.trim() ?? "",
61
+ );
62
+ // Pi's skill validation rules (docs/skills.md): name 1-64 chars,
63
+ // lowercase alphanumerics/hyphens, no leading/trailing or consecutive
64
+ // hyphens; description required, max 1024 chars.
65
+ assert.match(name, /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/);
66
+ assert.ok(name.length >= 1 && name.length <= 64);
67
+ assert.ok(!name.includes("--"));
68
+ assert.ok(description.length >= 1 && description.length <= 1024);
69
+ assert.match(description, /system_one|System One/);
70
+ assert.ok(
71
+ existsSync(
72
+ join(
73
+ result.skillPaths[0],
74
+ "system-one",
75
+ "references",
76
+ "use-cases.md",
77
+ ),
78
+ ),
79
+ "missing use-cases reference",
80
+ );
81
+ } finally {
82
+ if (prev === undefined) delete process.env.SYSTEM_ONE_BASE_URL;
83
+ else process.env.SYSTEM_ONE_BASE_URL = prev;
84
+ }
85
+ });
86
+
87
+ it("contributes a valid /judge prompt template", async () => {
88
+ const prev = process.env.SYSTEM_ONE_BASE_URL;
89
+ process.env.SYSTEM_ONE_BASE_URL = "http://localhost:8008";
90
+ try {
91
+ const handlers: Record<string, (event: never) => unknown> = {};
92
+ const pi = {
93
+ registerTool: () => {},
94
+ registerCommand: () => {},
95
+ on: (channel: string, handler: (event: never) => unknown) => {
96
+ handlers[channel] = handler;
97
+ },
98
+ };
99
+ piSystemOneExtension(pi as never);
100
+ const discover = handlers.resources_discover;
101
+ assert.ok(discover, "resources_discover handler registered");
102
+ const result = (await discover({} as never)) as {
103
+ promptPaths: string[];
104
+ };
105
+ assert.equal(result.promptPaths.length, 1);
106
+ assert.equal(result.promptPaths[0], systemOnePromptsDir());
107
+ const judgeMd = join(result.promptPaths[0], "judge.md");
108
+ assert.ok(existsSync(judgeMd), `missing ${judgeMd}`);
109
+ const content = readFileSync(judgeMd, "utf-8");
110
+ // Prompt templates take `$@` args and must name the tool explicitly:
111
+ // that explicit naming is what makes routing deterministic.
112
+ assert.match(content, /\$@/);
113
+ assert.match(content, /system_one/);
114
+ assert.match(content, /do not\s+answer it directly/i);
115
+ // noul answers carry no confidence field; the template must not ask
116
+ // the model to report one or it will invent numbers.
117
+ assert.match(content, /for noul the probability/i);
118
+ } finally {
119
+ if (prev === undefined) delete process.env.SYSTEM_ONE_BASE_URL;
120
+ else process.env.SYSTEM_ONE_BASE_URL = prev;
121
+ }
122
+ });
123
+ });