ordo-pow 0.2.1 → 0.2.2

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
@@ -15,7 +15,7 @@ Requires Node.js 22 or newer. No dependencies.
15
15
  ## Usage
16
16
 
17
17
  ```sh
18
- npx ordo-pow@latest <token> [--api <url>] [--no-open]
18
+ npx ordo-pow@latest <token> [--notes <file>] [--api <url>] [--no-open]
19
19
  ```
20
20
 
21
21
  To keep the token out of your shell history, pass it in the environment instead:
@@ -26,12 +26,13 @@ ORDO_POW_TOKEN='opw1.…' npx ordo-pow@latest
26
26
 
27
27
  | Option | |
28
28
  |---|---|
29
+ | `--notes <file>` | A `.txt` or `.md` file of your observations. The interviewer records what it states (measurements, your verdict on each outcome) and asks only about the rest. Its text goes to the model like a typed answer; its hash becomes evidence. |
29
30
  | `--api <url>` | Talk to this Ordo API instead of the address in the token |
30
31
  | `--no-open` | Print the signing link but do not open a browser |
31
32
  | `-h`, `--help` | Help |
32
33
  | `-v`, `--version` | Version |
33
34
 
34
- During the interview, type your answers. `/show` prints what has been recorded so far, `/quit` stops without sending anything. Before anything is sent you see the full record and confirm with `y`.
35
+ During the interview, type your answers. `/show` prints what has been recorded so far, `/notes <file>` reads an observations file as your answer, `/quit` stops without sending anything. Before anything is sent you see the full record and confirm with `y`.
35
36
 
36
37
  When the agent wants to show a file of yours to the model (a photo) or transcribe one (a voice note), it asks first, file by file: `? Show bench.jpg (2.1 MB) to the interviewer model …? [y/N]`. Anything but `y` keeps the file hash-only.
37
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ordo-pow",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Record proof of work on an Ordo stage: a short terminal interview through the 0G Private Computer Router (TEE providers only), signed in the Ordo console.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -14,7 +14,7 @@ import { createRouterClient, trustText } from "./router.js";
14
14
  import { isLoopback, isTruthy, normalizeBaseUrl, parseInvocation, TOKEN_ENV } from "./token.js";
15
15
 
16
16
  export function usage() {
17
- return `Usage: npx ${PACKAGE_NAME}@latest <token> [--api <url>] [--no-open]
17
+ return `Usage: npx ${PACKAGE_NAME}@latest <token> [--notes <file>] [--api <url>] [--no-open]
18
18
  ${TOKEN_ENV}=<token> npx ${PACKAGE_NAME}@latest
19
19
 
20
20
  Record proof of work on an Ordo stage. Copy the command from the stage in the Ordo
@@ -25,6 +25,9 @@ hashed on this machine; Ordo gets only the hashes. A photo or voice note goes to
25
25
  0G model only if you say yes for that file.
26
26
 
27
27
  Options:
28
+ --notes <file> A .txt or .md file of your observations: the interviewer records what it
29
+ states (measurements, your verdict on each outcome) and asks only about the rest.
30
+ Its text goes to the 0G model like your typed answers; its hash is your evidence.
28
31
  --api <url> Use this Ordo API address instead of the one in the token
29
32
  --no-open Do not open the signing link in a browser
30
33
  -h, --help Show this help
@@ -37,7 +40,8 @@ Environment:
37
40
  used anyway when the Ordo deployment has no Router)
38
41
  NO_COLOR=1 Plain output
39
42
 
40
- During the interview: /show prints what is recorded, /quit stops without sending.
43
+ During the interview: /show prints what is recorded, /notes <file> answers from a file,
44
+ /quit stops without sending.
41
45
 
42
46
  Exit codes: 0 signed, 1 failed or expired, 2 bad usage or token, 3 stopped by you, 130 interrupted.`;
43
47
  }
@@ -150,7 +154,7 @@ export async function main(options = {}) {
150
154
 
151
155
  printBanner(terminal, session, { mock: mock ? (forcedMock ? "forced" : "ordo") : false, model: router.model });
152
156
  phase = "interview";
153
- const interview = await runInterview({ session, router, terminal, client, cwd });
157
+ const interview = await runInterview({ session, router, terminal, client, cwd, notes: invocation.notes });
154
158
  if (interview.outcome === "quit") {
155
159
  terminal.print("");
156
160
  terminal.print("Stopped. Nothing was submitted.");
package/src/interview.js CHANGED
@@ -7,15 +7,18 @@
7
7
  /** @import { OrdoClient } from "./ordo-api.js" */
8
8
  import { tmpdir } from "node:os";
9
9
  import path from "node:path";
10
- import { formatBytes } from "./evidence.js";
10
+ import { readFile } from "node:fs/promises";
11
+ import { EvidenceError, formatBytes, hashEvidence } from "./evidence.js";
11
12
  import { contentText, createMediaQueue, stripImages } from "./media.js";
12
13
  import { targetText } from "./outcome-tools.js";
13
- import { buildSystemPrompt, KICKOFF } from "./prompt.js";
14
+ import { buildSystemPrompt, KICKOFF, notesMessage } from "./prompt.js";
14
15
  import { describeRouterError } from "./router.js";
15
16
  import { createRecord, measurables, runTool, settleVision, toolDefinitions, unsentVision } from "./tools.js";
16
17
 
17
18
  /** Tool rounds in a row without hearing from the contributor before we stop and ask them. */
18
19
  const MAX_TOOL_ROUNDS = 8;
20
+ /** the largest observations file the interviewer reads */
21
+ const MAX_NOTES_BYTES = 64 * 1024;
19
22
 
20
23
  /**
21
24
  * @typedef {object} InterviewResult
@@ -33,10 +36,11 @@ const MAX_TOOL_ROUNDS = 8;
33
36
  * client?: Pick<OrdoClient, "output" | "askMachine" | "getSession">,
34
37
  * cwd?: string,
35
38
  * maxEvidenceBytes?: number,
39
+ * notes?: string | null,
36
40
  * }} options
37
41
  * @returns {Promise<InterviewResult>}
38
42
  */
39
- export async function runInterview({ session, router, terminal, client, cwd = process.cwd(), maxEvidenceBytes }) {
43
+ export async function runInterview({ session, router, terminal, client, cwd = process.cwd(), maxEvidenceBytes, notes = null }) {
40
44
  const { style } = terminal;
41
45
  const stage = session.stage;
42
46
  const record = createRecord();
@@ -55,6 +59,36 @@ export async function runInterview({ session, router, terminal, client, cwd = pr
55
59
  ];
56
60
  let inputEnded = false;
57
61
  let quitRequested = false;
62
+
63
+ /**
64
+ * The contributor's observations file: hashed as evidence, and its text sent to the interviewer as their answers.
65
+ * @param {string} input
66
+ * @returns {Promise<string | null>} the message, or null when the file cannot be used (the reason is printed)
67
+ */
68
+ async function loadNotes(input) {
69
+ let file;
70
+ try {
71
+ file = await hashEvidence(input, { cwd, maxBytes: MAX_NOTES_BYTES });
72
+ } catch (err) {
73
+ if (err instanceof EvidenceError) {
74
+ terminal.error(` Notes: ${err.message}`);
75
+ return null;
76
+ }
77
+ throw err;
78
+ }
79
+ const text = (await readFile(file.path, "utf8")).replace(/\r\n/g, "\n").trim();
80
+ if (!text) {
81
+ terminal.error(` Notes: ${file.name} is empty.`);
82
+ return null;
83
+ }
84
+ if (/[\u0000-\u0008\u000e-\u001f]/.test(text.slice(0, 4096))) {
85
+ terminal.error(` Notes: ${file.name} is not a text file. Use a .txt or .md file of your observations.`);
86
+ return null;
87
+ }
88
+ if (!record.evidence.some((e) => e.sha256 === file.sha256)) record.evidence.push({ name: file.name, sha256: file.sha256, size: file.size, mediaType: "text/plain" });
89
+ terminal.print(style.green(` ✓ read your notes ${file.name} (${formatBytes(file.size)}), sha256 ${file.sha256.slice(0, 16)}…: its text goes to the interviewer as your answers, and its hash is your evidence`));
90
+ return notesMessage({ name: file.name, sha256: file.sha256, text });
91
+ }
58
92
  let warnedTee = false;
59
93
  let toolRounds = 0;
60
94
 
@@ -82,7 +116,17 @@ export async function runInterview({ session, router, terminal, client, cwd = pr
82
116
  continue;
83
117
  }
84
118
  if (command === "/help" || command === "/?") {
85
- terminal.print(style.dim(" /show what is recorded so far\n /quit stop without sending anything"));
119
+ terminal.print(style.dim(" /show what is recorded so far\n /notes <file> answer from a text file of your observations\n /quit stop without sending anything"));
120
+ continue;
121
+ }
122
+ if (command === "/notes" || command.startsWith("/notes ")) {
123
+ const file = text.slice("/notes".length).trim();
124
+ if (!file) {
125
+ terminal.print(style.dim(" /notes <file>: a .txt or .md file of your observations, for example /notes ~/stage-01.txt"));
126
+ continue;
127
+ }
128
+ const message = await loadNotes(file);
129
+ if (message) return message;
86
130
  continue;
87
131
  }
88
132
  // Anything else, including absolute paths that start with "/", is an answer.
@@ -149,6 +193,15 @@ export async function runInterview({ session, router, terminal, client, cwd = pr
149
193
  }
150
194
  }
151
195
 
196
+ // an observations file given on the command line (--notes) is the contributor's first answer
197
+ if (notes) {
198
+ const message = await loadNotes(notes);
199
+ if (message) {
200
+ transcript.push({ role: "user", content: message, at: new Date().toISOString() });
201
+ messages.push({ role: "user", content: message });
202
+ }
203
+ }
204
+
152
205
  /** @param {string} line */
153
206
  const announce = (line) => terminal.print(style.green(` ✓ ${line}`));
154
207
  /** @param {string} line */
@@ -204,15 +204,22 @@ export async function reviewOutcome(args, ctx, announce) {
204
204
  * @param {Record<string, unknown>} args
205
205
  * @param {ToolContext} ctx
206
206
  * @param {(line: string) => void} announce
207
- * @returns {ToolOutcome}
207
+ * @returns {Promise<ToolOutcome>}
208
208
  */
209
- export function confirmOutcome(args, ctx, announce) {
209
+ export async function confirmOutcome(args, ctx, announce) {
210
210
  const o = findOutcome(ctx.stage, args.key);
211
211
  if (!o) return fail(`Unknown outcome. Use one of: ${(ctx.stage.outcomes ?? []).map((x) => x.key).join(", ") || "(this stage has none)"}.`);
212
212
  const verdict = typeof args.verdict === "string" ? args.verdict.trim().toLowerCase() : "";
213
213
  if (!VERDICTS.includes(/** @type {any} */ (verdict))) return fail(`verdict is one of: ${VERDICTS.join(", ")}.`);
214
+ // a model that skips review_outcome would otherwise be refused again and again: show the outcome here (its file is
215
+ // saved and its path printed, so the contributor can open what they are confirming), then record the verdict
216
+ let autoReviewed = false;
217
+ if (o.captured && o.output && !ctx.record.reviewed[o.key]) {
218
+ const shown = await reviewOutcome({ key: o.key }, ctx, announce);
219
+ if (!shown.result.ok) return shown;
220
+ autoReviewed = true;
221
+ }
214
222
  const reviewed = ctx.record.reviewed[o.key];
215
- if (o.captured && o.output && !reviewed) return fail(`Show ${o.label} to the contributor first: call review_outcome with key "${o.key}".`);
216
223
  if (verdict === "confirm" && !reviewed) return fail(`Nothing was captured for ${o.label}, so there is nothing to confirm. Record dispute or unsure, with what the contributor said.`);
217
224
  const raw = typeof args.observed === "string" && args.observed.trim() !== "" ? Number(args.observed) : args.observed;
218
225
  if (raw !== undefined && raw !== null && (typeof raw !== "number" || !Number.isFinite(raw))) return fail("observed must be a number: the value the contributor observed, or leave it out.");
@@ -227,7 +234,7 @@ export function confirmOutcome(args, ctx, announce) {
227
234
  else ctx.record.confirmations.push(c);
228
235
  const word = verdict === "confirm" ? "confirmed" : verdict === "dispute" ? "disputed" : "unsure";
229
236
  announce(`${o.label}: ${word}${c.observed !== undefined ? `, observed ${c.observed}${o.metric.unit ? ` ${o.metric.unit}` : ""}` : ""}${note ? ` (${note.length > 70 ? `${note.slice(0, 69)}…` : note})` : ""}`);
230
- return ok({ recorded: c, replacedEarlierVerdict: index >= 0, outcomesToReview: pendingOutcomes(ctx.stage, ctx.record).map((x) => x.key) });
237
+ return ok({ recorded: c, replacedEarlierVerdict: index >= 0, ...(autoReviewed ? { reviewedNow: true } : {}), outcomesToReview: pendingOutcomes(ctx.stage, ctx.record).map((x) => x.key) });
231
238
  }
232
239
 
233
240
  /**
package/src/prompt.js CHANGED
@@ -105,3 +105,19 @@ export function buildSystemPrompt(session) {
105
105
 
106
106
  /** The first user turn, so models that need one have it. Not part of the transcript. */
107
107
  export const KICKOFF = "The contributor has opened the terminal and is ready. Start the interview.";
108
+
109
+ /**
110
+ * The contributor's observations file, sent as their answer. The model records what it states and asks only about the rest.
111
+ * @param {{ name: string, sha256: string, text: string }} notes
112
+ */
113
+ export function notesMessage(notes) {
114
+ return `My observations for this stage are in the file ${notes.name} (sha256 ${notes.sha256.slice(0, 16)}…, attached as evidence). Use them as my answers:
115
+ - record every measurement it gives with record_measurement;
116
+ - review each outcome the machine captured and record my verdict from the file with confirm_outcome (confirm, or dispute with the value I observed);
117
+ - add what else it says as notes.
118
+ Do not ask me again for anything the file answers. Ask only about what it leaves out or contradicts, then give me your summary and call finish.
119
+
120
+ --- ${notes.name} ---
121
+ ${notes.text}
122
+ --- end of ${notes.name} ---`;
123
+ }
package/src/token.js CHANGED
@@ -99,6 +99,7 @@ export function isLoopback(url) {
99
99
  * @property {boolean} open
100
100
  * @property {ParsedToken | null} token
101
101
  * @property {"argument" | "env" | null} tokenSource
102
+ * @property {string | null} notes An observations file to answer from (--notes)
102
103
  */
103
104
 
104
105
  /**
@@ -116,6 +117,7 @@ export function parseInvocation(argv, env) {
116
117
  strict: true,
117
118
  options: {
118
119
  api: { type: "string" },
120
+ notes: { type: "string" },
119
121
  "no-open": { type: "boolean" },
120
122
  help: { type: "boolean", short: "h" },
121
123
  version: { type: "boolean", short: "v" },
@@ -127,7 +129,7 @@ export function parseInvocation(argv, env) {
127
129
  throw new UsageError(`Unknown option. Run with --help to see the options.`);
128
130
  }
129
131
  if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE") {
130
- throw new UsageError(`An option has a missing or unexpected value (--api needs a URL, for example --api https://ordo.example.com).`);
132
+ throw new UsageError(`An option has a missing or unexpected value (--api needs a URL, for example --api https://ordo.example.com; --notes needs a file).`);
131
133
  }
132
134
  throw new UsageError(`Could not read the arguments. Run with --help to see the options.`);
133
135
  }
@@ -135,7 +137,8 @@ export function parseInvocation(argv, env) {
135
137
  const help = Boolean(values.help);
136
138
  const version = Boolean(values.version);
137
139
  const open = !values["no-open"] && !isTruthy(env.ORDO_POW_NO_OPEN);
138
- if (help || version) return { help, version, open, token: null, tokenSource: null };
140
+ const notes = typeof values.notes === "string" && values.notes.trim() ? values.notes : null;
141
+ if (help || version) return { help, version, open, token: null, tokenSource: null, notes };
139
142
 
140
143
  if (positionals.length > 1) {
141
144
  throw new UsageError(`Expected one token, got ${positionals.length} arguments.`);
@@ -156,7 +159,7 @@ export function parseInvocation(argv, env) {
156
159
  }
157
160
  const token = parseToken(raw);
158
161
  if (values.api !== undefined) token.apiBaseUrl = normalizeBaseUrl(values.api, "--api");
159
- return { help, version, open, token, tokenSource };
162
+ return { help, version, open, token, tokenSource, notes };
160
163
  }
161
164
 
162
165
  /** @param {string | undefined} value */