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 +3 -2
- package/package.json +1 -1
- package/src/cli.js +7 -3
- package/src/interview.js +57 -4
- package/src/outcome-tools.js +11 -4
- package/src/prompt.js +16 -0
- package/src/token.js +6 -3
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.
|
|
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, /
|
|
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 {
|
|
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
|
|
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 */
|
package/src/outcome-tools.js
CHANGED
|
@@ -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
|
-
|
|
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 */
|