pi-system-one 1.2.0 → 1.2.1

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.md CHANGED
@@ -6,6 +6,22 @@ System One decisions for Pi. Plug in TypeSafe Jev, Reflex, or your own provider.
6
6
  pi install npm:pi-system-one
7
7
  ```
8
8
 
9
+ For Oh My Pi (OMP), install the published package with:
10
+
11
+ ```bash
12
+ omp install npm:pi-system-one
13
+ ```
14
+
15
+ For a local checkout, install or load the package directory directly:
16
+
17
+ ```bash
18
+ omp install /path/to/pi-system-one
19
+ # or
20
+ omp --extension /path/to/pi-system-one
21
+ ```
22
+
23
+ Set `SYSTEM_ONE_BASE_URL` before launch, or run `/so config`; the `system_one` tool is registered after the endpoint is configured.
24
+
9
25
  <img src="./demo.gif" alt="Description" width="640" >
10
26
 
11
27
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-system-one",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "System One decisions for Pi. Plug in TypeSafe Jev, Reflex, or your own provider.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  "coding-agent"
28
28
  ],
29
29
  "dependencies": {
30
- "system-one-core": "^0.2.0"
30
+ "system-one-core": "^0.2.1"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "*",
@@ -37,5 +37,10 @@
37
37
  "extensions": [
38
38
  "./src/extension.ts"
39
39
  ]
40
+ },
41
+ "omp": {
42
+ "extensions": [
43
+ "./src/extension.ts"
44
+ ]
40
45
  }
41
46
  }
package/src/tool.ts CHANGED
@@ -176,14 +176,95 @@ function pickAlias(
176
176
  return undefined;
177
177
  }
178
178
 
179
+ /**
180
+ * Copy-on-write canonicality check for one question. Returns true when
181
+ * running the builder below would produce a key-for-key, value-for-value
182
+ * identical record — in which case the caller reuses the input by
183
+ * reference instead of allocating a copy. Any doubt returns false (the
184
+ * builder then produces the correct output); false negatives only cost an
185
+ * allocation, never correctness.
186
+ *
187
+ * Deliberately allocation-free itself (own-key probes, no entries/keys
188
+ * arrays) so the defensive second normalization in execute() stays cheap.
189
+ */
190
+ function isCanonicalQuestion(input: Record<string, unknown>): boolean {
191
+ const hasType = Object.hasOwn(input, "type");
192
+ // Probe only: an exact canonical spelling (alias-map value identical to
193
+ // the key) needs no work. Near-miss spellings report non-canonical
194
+ // WITHOUT running full normalization here — the builder below runs it
195
+ // exactly once. Non-string types pass (the builder copies them verbatim),
196
+ // so doubt always errs toward a wasted rebuild, never a wrong share.
197
+ if (
198
+ hasType &&
199
+ typeof input.type === "string" &&
200
+ QUESTION_TYPE_ALIASES.get(input.type) !== input.type
201
+ ) {
202
+ return false;
203
+ }
204
+ // Strings reaching here are already canonical, so the raw type doubles
205
+ // as the normalized one for the criteria decisions below.
206
+ const normalizedType = hasType ? input.type : undefined;
207
+ const instructions = pickAlias(input, INSTRUCTIONS_ALIASES);
208
+ // NOTE: no `else` for an own defined "instructions" slot — "instructions"
209
+ // is the first alias, so pickAlias would have returned it above; reaching
210
+ // here with one means it is undefined and the builder omits it too.
211
+ if (instructions !== undefined) {
212
+ // The builder emits own "instructions" with this value; canonical only
213
+ // if it already sits there and no sibling alias key lingers (dropped).
214
+ if (
215
+ !Object.hasOwn(input, "instructions") ||
216
+ input.instructions !== instructions
217
+ ) {
218
+ return false;
219
+ }
220
+ }
221
+ for (const alias of INSTRUCTIONS_ALIASES) {
222
+ if (alias !== "instructions" && Object.hasOwn(input, alias)) return false;
223
+ }
224
+ let criteria = pickAlias(input, CRITERIA_ALIASES);
225
+ if (normalizedType === "noul" && criteria === null) {
226
+ // The builder drops it; canonical only if no own alias key is dropped.
227
+ for (const alias of CRITERIA_ALIASES) {
228
+ if (Object.hasOwn(input, alias)) return false;
229
+ }
230
+ criteria = undefined;
231
+ }
232
+ if (
233
+ normalizedType === "choice" &&
234
+ Array.isArray(criteria) &&
235
+ (criteria as unknown[]).every((item) => typeof item === "string")
236
+ ) {
237
+ return false; // The builder folds string arrays to an object.
238
+ }
239
+ if (criteria !== undefined) {
240
+ if (!Object.hasOwn(input, "criteria") || input.criteria !== criteria) {
241
+ return false;
242
+ }
243
+ } else if (Object.hasOwn(input, "criteria")) {
244
+ return false; // Own undefined criteria is dropped by the builder.
245
+ }
246
+ for (const alias of CRITERIA_ALIASES) {
247
+ if (alias !== "criteria" && Object.hasOwn(input, alias)) return false;
248
+ }
249
+ for (const key of QUESTION_JUNK_KEYS) {
250
+ if (Object.hasOwn(input, key)) return false;
251
+ }
252
+ // Any other own key is preserved by reference, so it cannot differ.
253
+ return true;
254
+ }
255
+
179
256
  /**
180
257
  * Coerce one question into the flat schema shape: canonical `type`,
181
258
  * `instructions` / `criteria` resolved through aliases, array-form choice
182
259
  * criteria (`["a", "b"]`) folded to `{"a": null, "b": null}`, and
183
260
  * naming/junk keys stripped.
261
+ *
262
+ * Pure, idempotent, non-mutating, with structural sharing: canonical input
263
+ * is returned by reference; only repaired questions allocate.
184
264
  */
185
265
  function normalizeQuestion(input: unknown): unknown {
186
266
  if (!isRecord(input)) return input;
267
+ if (isCanonicalQuestion(input)) return input;
187
268
  const out: Record<string, unknown> = {};
188
269
  if (Object.hasOwn(input, "type"))
189
270
  out.type = normalizeQuestionType(input.type);
@@ -235,13 +316,15 @@ function normalizeQuestions(input: unknown): unknown {
235
316
  };
236
317
  input.forEach((entry, index) => {
237
318
  if (isRecord(entry)) {
238
- const { name, id, key, ...rest } = entry;
239
- const rawName = name ?? id ?? key;
319
+ // No intermediate `{...rest}` copy: normalizeQuestion() already
320
+ // drops name/id/key as junk, so passing the entry through repairs
321
+ // naming metadata and aliases in a single construction pass.
322
+ const rawName = entry.name ?? entry.id ?? entry.key;
240
323
  const base =
241
324
  typeof rawName === "string" && rawName.trim() !== ""
242
325
  ? rawName
243
326
  : `q${index + 1}`;
244
- setOwn(record, claimName(base), normalizeQuestion(rest));
327
+ setOwn(record, claimName(base), normalizeQuestion(entry));
245
328
  } else {
246
329
  setOwn(record, claimName(`q${index + 1}`), entry);
247
330
  }
@@ -270,9 +353,24 @@ function normalizeQuestions(input: unknown): unknown {
270
353
  );
271
354
  if (knownKind || allQuestionKeys) return { q: normalizeQuestion(input) };
272
355
  }
356
+ // Copy-on-write: share the map (and each canonical question) when
357
+ // nothing needs repair; otherwise rebuild with repaired questions and
358
+ // unaffected siblings shared by reference. The scan probes
359
+ // canonicality only — it must never build (and discard) repairs.
360
+ let dirty = false;
361
+ for (const name in input) {
362
+ if (!Object.hasOwn(input, name)) continue;
363
+ const question = input[name];
364
+ if (!isRecord(question) || !isCanonicalQuestion(question)) {
365
+ dirty = true;
366
+ break;
367
+ }
368
+ }
369
+ if (!dirty) return input;
273
370
  const record: Record<string, unknown> = {};
274
- for (const [name, question] of Object.entries(input)) {
275
- setOwn(record, name, normalizeQuestion(question));
371
+ for (const name in input) {
372
+ if (!Object.hasOwn(input, name)) continue;
373
+ setOwn(record, name, normalizeQuestion(input[name]));
276
374
  }
277
375
  return record;
278
376
  }
@@ -286,6 +384,38 @@ function normalizeQuestions(input: unknown): unknown {
286
384
  */
287
385
  export function prepareSystemOneArgs(args: unknown): SystemOneParams {
288
386
  if (!isRecord(args)) return args as SystemOneParams;
387
+ const questions =
388
+ args.questions ?? args.question ?? args.queries ?? undefined;
389
+ const normalizedQuestions =
390
+ questions !== undefined ? normalizeQuestions(questions) : undefined;
391
+ // Copy-on-write fast path: canonical state/questions slots, no alias keys
392
+ // to rename, no unknown keys to drop — return the input by reference so
393
+ // the defensive re-normalization in execute() allocates ~nothing.
394
+ const questionsCanonical =
395
+ normalizedQuestions === questions &&
396
+ (questions === undefined
397
+ ? !Object.hasOwn(args, "questions") &&
398
+ !Object.hasOwn(args, "question") &&
399
+ !Object.hasOwn(args, "queries")
400
+ : questions === args.questions &&
401
+ !Object.hasOwn(args, "question") &&
402
+ !Object.hasOwn(args, "queries"));
403
+ if (
404
+ questionsCanonical &&
405
+ Object.hasOwn(args, "state") &&
406
+ args.state !== undefined &&
407
+ !Object.hasOwn(args, "context") &&
408
+ !Object.hasOwn(args, "evidence")
409
+ ) {
410
+ let knownKeysOnly = true;
411
+ for (const key in args) {
412
+ if (Object.hasOwn(args, key) && key !== "state" && key !== "questions") {
413
+ knownKeysOnly = false;
414
+ break;
415
+ }
416
+ }
417
+ if (knownKeysOnly) return args as SystemOneParams;
418
+ }
289
419
  const out: Record<string, unknown> = {};
290
420
  if (args.state !== undefined) {
291
421
  out.state = args.state;
@@ -294,9 +424,7 @@ export function prepareSystemOneArgs(args: unknown): SystemOneParams {
294
424
  } else if (args.evidence !== undefined) {
295
425
  out.state = args.evidence;
296
426
  }
297
- const questions =
298
- args.questions ?? args.question ?? args.queries ?? undefined;
299
- if (questions !== undefined) out.questions = normalizeQuestions(questions);
427
+ if (normalizedQuestions !== undefined) out.questions = normalizedQuestions;
300
428
  // Drop unknown top-level keys; a missing `state` / `questions` still
301
429
  // produces a clear error from semantic validation in execute().
302
430
  return out as SystemOneParams;
@@ -7,6 +7,8 @@
7
7
 
8
8
  import assert from "node:assert/strict";
9
9
  import { execFileSync } from "node:child_process";
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
10
12
  import { describe, it } from "node:test";
11
13
  import { fileURLToPath } from "node:url";
12
14
 
@@ -32,6 +34,17 @@ function packedFiles(): string[] {
32
34
  }
33
35
 
34
36
  describe("npm package runtime resources", () => {
37
+ it("declares the extension entry for both Pi and OMP", () => {
38
+ const packageJson = JSON.parse(
39
+ readFileSync(join(packageRoot, "package.json"), "utf-8"),
40
+ ) as {
41
+ pi?: { extensions?: string[] };
42
+ omp?: { extensions?: string[] };
43
+ };
44
+ assert.deepEqual(packageJson.pi, { extensions: ["./src/extension.ts"] });
45
+ assert.deepEqual(packageJson.omp, { extensions: ["./src/extension.ts"] });
46
+ });
47
+
35
48
  it("packs the skill, its references, and the /judge prompt", () => {
36
49
  const files = packedFiles();
37
50
  for (const required of [
@@ -1075,6 +1075,245 @@ describe("system_one tool", () => {
1075
1075
  });
1076
1076
  });
1077
1077
 
1078
+ describe("copy-on-write normalization", () => {
1079
+ const canonicalRequest = () => ({
1080
+ state: { incident: "latency" },
1081
+ questions: {
1082
+ rollback: { type: "noul", instructions: "Roll back?" },
1083
+ owner: {
1084
+ type: "choice",
1085
+ instructions: "Which team?",
1086
+ criteria: { frontend: null, backend: null },
1087
+ },
1088
+ },
1089
+ });
1090
+
1091
+ it("returns canonical requests, maps, and questions by reference", () => {
1092
+ const args = canonicalRequest();
1093
+ const out = prepareSystemOneArgs(args) as typeof args;
1094
+ assert.equal(out, args);
1095
+ assert.equal(out.questions, args.questions);
1096
+ assert.equal(out.questions.rollback, args.questions.rollback);
1097
+ assert.equal(out.questions.owner, args.questions.owner);
1098
+ // Key order is not canonicalized: identity means "already final".
1099
+ const reordered = {
1100
+ state: "s",
1101
+ questions: {
1102
+ q: { criteria: { a: null }, instructions: "I?", type: "choice" },
1103
+ },
1104
+ };
1105
+ assert.equal(prepareSystemOneArgs(reordered), reordered);
1106
+ });
1107
+
1108
+ it("structurally shares unaffected siblings on partial repair", () => {
1109
+ const args = {
1110
+ state: "s",
1111
+ questions: {
1112
+ fine: { type: "noul", instructions: "OK?" },
1113
+ sloppy: { type: "Choice", prompt: "Pick?", options: ["a", "b"] },
1114
+ },
1115
+ };
1116
+ const out = prepareSystemOneArgs(args) as typeof args;
1117
+ assert.notEqual(out, args);
1118
+ assert.notEqual(out.questions, args.questions);
1119
+ assert.notEqual(out.questions.sloppy, args.questions.sloppy);
1120
+ assert.equal(out.questions.fine, args.questions.fine);
1121
+ assert.deepEqual(out.questions.sloppy, {
1122
+ type: "choice",
1123
+ instructions: "Pick?",
1124
+ criteria: { a: null, b: null },
1125
+ });
1126
+ });
1127
+
1128
+ it("is idempotent and allocates nothing new on the second pass", () => {
1129
+ const once = prepareSystemOneArgs(canonicalRequest()) as Record<
1130
+ string,
1131
+ unknown
1132
+ >;
1133
+ const twice = prepareSystemOneArgs(once);
1134
+ assert.deepEqual(twice, once);
1135
+ assert.equal(twice, once);
1136
+ // Repair once, then the repaired output is itself canonical.
1137
+ const repairedOnce = prepareSystemOneArgs({
1138
+ state: "s",
1139
+ questions: [{ name: "q", type: "boolean", prompt: "Y?" }],
1140
+ });
1141
+ assert.equal(prepareSystemOneArgs(repairedOnce), repairedOnce);
1142
+ });
1143
+
1144
+ it("never mutates its input", () => {
1145
+ const deepFreeze = (v: unknown): void => {
1146
+ if (typeof v === "object" && v !== null && !Object.isFrozen(v)) {
1147
+ Object.freeze(v);
1148
+ for (const k of Object.keys(v)) {
1149
+ deepFreeze((v as Record<string, unknown>)[k]);
1150
+ }
1151
+ }
1152
+ };
1153
+ const sloppy = {
1154
+ context: { incident: "latency" },
1155
+ question: [
1156
+ { name: "a", type: "Rating", prompt: "Rate?", levels: ["1", "5"] },
1157
+ { id: "b", type: "noul", instructions: "OK?", extra: 1 },
1158
+ ],
1159
+ };
1160
+ deepFreeze(sloppy);
1161
+ const out = prepareSystemOneArgs(sloppy);
1162
+ assert.deepEqual(out, {
1163
+ state: { incident: "latency" },
1164
+ questions: {
1165
+ a: {
1166
+ type: "score",
1167
+ instructions: "Rate?",
1168
+ criteria: ["1", "5"],
1169
+ },
1170
+ b: { type: "noul", instructions: "OK?", extra: 1 },
1171
+ },
1172
+ });
1173
+ });
1174
+
1175
+ it("repairs every non-canonical shape exactly (no false sharing)", () => {
1176
+ // [input, expected output, sharesInputRef]: the last two entries are
1177
+ // already builder fixed points, so sharing them is correct.
1178
+ const cases: Array<[unknown, unknown, boolean]> = [
1179
+ // Alias type spelling.
1180
+ [
1181
+ {
1182
+ state: "s",
1183
+ questions: { q: { type: "Yes/No", instructions: "Y?" } },
1184
+ },
1185
+ {
1186
+ state: "s",
1187
+ questions: { q: { type: "noul", instructions: "Y?" } },
1188
+ },
1189
+ false,
1190
+ ],
1191
+ // Alias field names.
1192
+ [
1193
+ {
1194
+ state: "s",
1195
+ questions: { q: { type: "choice", prompt: "P?", choices: ["a"] } },
1196
+ },
1197
+ {
1198
+ state: "s",
1199
+ questions: {
1200
+ q: { type: "choice", instructions: "P?", criteria: { a: null } },
1201
+ },
1202
+ },
1203
+ false,
1204
+ ],
1205
+ // Junk keys dropped.
1206
+ [
1207
+ {
1208
+ state: "s",
1209
+ questions: {
1210
+ q: {
1211
+ type: "noul",
1212
+ instructions: "Y?",
1213
+ description: "d",
1214
+ title: "t",
1215
+ },
1216
+ },
1217
+ },
1218
+ {
1219
+ state: "s",
1220
+ questions: { q: { type: "noul", instructions: "Y?" } },
1221
+ },
1222
+ false,
1223
+ ],
1224
+ // Undefined-valued lingering alias key is still dropped.
1225
+ [
1226
+ {
1227
+ state: "s",
1228
+ questions: {
1229
+ q: { type: "noul", instructions: "Y?", prompt: undefined },
1230
+ },
1231
+ },
1232
+ {
1233
+ state: "s",
1234
+ questions: { q: { type: "noul", instructions: "Y?" } },
1235
+ },
1236
+ false,
1237
+ ],
1238
+ // Own undefined instructions key is preserved (builder copies it).
1239
+ [
1240
+ {
1241
+ state: "s",
1242
+ questions: { q: { type: "noul", instructions: undefined } },
1243
+ },
1244
+ {
1245
+ state: "s",
1246
+ questions: { q: { type: "noul", instructions: undefined } },
1247
+ },
1248
+ true,
1249
+ ],
1250
+ // noul null criteria reads as omitted.
1251
+ [
1252
+ {
1253
+ state: "s",
1254
+ questions: {
1255
+ q: { type: "noul", instructions: "Y?", criteria: null },
1256
+ },
1257
+ },
1258
+ {
1259
+ state: "s",
1260
+ questions: { q: { type: "noul", instructions: "Y?" } },
1261
+ },
1262
+ false,
1263
+ ],
1264
+ // Singular question alias + array form + naming metadata.
1265
+ [
1266
+ { context: "s", question: { type: "boolean", prompt: "Y?" } },
1267
+ {
1268
+ state: "s",
1269
+ questions: { q: { type: "noul", instructions: "Y?" } },
1270
+ },
1271
+ false,
1272
+ ],
1273
+ // Unknown top-level keys dropped; missing state preserved as-is.
1274
+ [
1275
+ {
1276
+ state: "s",
1277
+ questions: { q: { type: "noul", instructions: "Y?" } },
1278
+ extra: 1,
1279
+ },
1280
+ {
1281
+ state: "s",
1282
+ questions: { q: { type: "noul", instructions: "Y?" } },
1283
+ },
1284
+ false,
1285
+ ],
1286
+ // Empty choice array still folds (every() on [] is true).
1287
+ [
1288
+ {
1289
+ state: "s",
1290
+ questions: {
1291
+ q: { type: "choice", instructions: "P?", criteria: [] },
1292
+ },
1293
+ },
1294
+ {
1295
+ state: "s",
1296
+ questions: {
1297
+ q: { type: "choice", instructions: "P?", criteria: {} },
1298
+ },
1299
+ },
1300
+ false,
1301
+ ],
1302
+ // Non-string type passes through untouched (fixed point).
1303
+ [
1304
+ { state: "s", questions: { q: { type: 5, instructions: "Y?" } } },
1305
+ { state: "s", questions: { q: { type: 5, instructions: "Y?" } } },
1306
+ true,
1307
+ ],
1308
+ ];
1309
+ for (const [input, expected, sharesRef] of cases) {
1310
+ const out = prepareSystemOneArgs(input);
1311
+ assert.deepEqual(out, expected);
1312
+ assert.equal(out === input, sharesRef);
1313
+ }
1314
+ });
1315
+ });
1316
+
1078
1317
  describe("backend minima guardrails", () => {
1079
1318
  function stubTool() {
1080
1319
  let calls = 0;