@fitzdoud/valun 0.0.0-stage → 0.3.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/py.js ADDED
@@ -0,0 +1,318 @@
1
+ // Python semantics that the agent tools depend on, so this port gives the results the Python reference
2
+ // (python/src/valun) gives: str.isspace() whitespace (= re's \s), strip()/split()/splitlines(), the letters re.IGNORECASE
3
+ // matches, \d (any Unicode decimal digit) and int(), truthiness of `x or default`, str()/repr() of JSON values, dict
4
+ // subscripts that raise KeyError, == between JSON values, and code point ordering of strings.
5
+ // Internal module (not exported from index.js). Measured against CPython 3.14 over every code point; the Unicode
6
+ // tables are the JavaScript engine's own, so characters newer than Python's Unicode version may differ.
7
+
8
+ /** Python whitespace (str.isspace(), re's \s for str patterns) as the body of a regex character class (u flag). */
9
+ export const WS = '\\t\\n\\v\\f\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
10
+ /** Python's \d for str patterns: Unicode category Nd (u flag). */
11
+ export const DIGIT = '\\p{Nd}';
12
+
13
+ const WS_CHAR = new RegExp(`^[${WS}]$`, 'u');
14
+ const WS_RUN = new RegExp(`[${WS}]+`, 'u');
15
+ const LINE_BREAK = /\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029]/u;
16
+ const ND_CHAR = /^\p{Nd}$/u;
17
+ const INT_LITERAL = new RegExp(`^[${WS}]*([+-]?)(${DIGIT}+(?:_${DIGIT}+)*)[${WS}]*$`, 'u');
18
+ const NON_PRINTABLE = /^[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]$/u;
19
+ // Letters re.IGNORECASE matches beyond their two ASCII cases (CPython 3.14, measured over every code point).
20
+ const CASE_EXTRA = {i: '\u0130\u0131', k: '\u212a', s: '\u017f'};
21
+
22
+ /** A Python exception as JavaScript sees it: errorPayload() prints `${name}: ${message}`, like type(e).__name__. */
23
+ export class PyError extends Error {
24
+ constructor(type, message) {
25
+ super(message);
26
+ this.name = type;
27
+ }
28
+ }
29
+
30
+ export const isDict = v => v !== null && typeof v === 'object' && !Array.isArray(v);
31
+
32
+ export function typeName(v) {
33
+ if (v === null || v === undefined) return 'NoneType';
34
+ if (typeof v === 'boolean') return 'bool';
35
+ if (typeof v === 'number') return Number.isSafeInteger(v) ? 'int' : 'float';
36
+ if (typeof v === 'string') return 'str';
37
+ if (Array.isArray(v)) return 'list';
38
+ return typeof v === 'object' ? 'dict' : typeof v;
39
+ }
40
+
41
+ /** bool(v) for JSON values (NaN is truthy in Python). */
42
+ export function truthy(v) {
43
+ if (v === null || v === undefined || v === false || v === 0 || v === '') return false;
44
+ if (Array.isArray(v)) return v.length > 0;
45
+ if (typeof v === 'object') return Object.keys(v).length > 0;
46
+ return true;
47
+ }
48
+
49
+ /** Python `v or dflt`. */
50
+ export const or = (v, dflt) => (truthy(v) ? v : dflt);
51
+
52
+ /** dict.get(key, dflt): AttributeError on anything that is not a dict. */
53
+ export function get(obj, key, dflt = null) {
54
+ if (isDict(obj)) return Object.hasOwn(obj, key) ? obj[key] : dflt;
55
+ throw new PyError('AttributeError', `'${typeName(obj)}' object has no attribute 'get'`);
56
+ }
57
+
58
+ /** obj[key] on a dict: KeyError when absent, TypeError on what is not subscriptable by a string. */
59
+ export function item(obj, key) {
60
+ if (isDict(obj)) {
61
+ if (Object.hasOwn(obj, key)) return obj[key];
62
+ throw new PyError('KeyError', repr(key));
63
+ }
64
+ if (Array.isArray(obj)) throw new PyError('TypeError', 'list indices must be integers or slices, not str');
65
+ if (typeof obj === 'string') throw new PyError('TypeError', "string indices must be integers, not 'str'");
66
+ throw new PyError('TypeError', `'${typeName(obj)}' object is not subscriptable`);
67
+ }
68
+
69
+ /** dict.items() as entries: AttributeError on anything that is not a dict. */
70
+ export function items(obj) {
71
+ if (isDict(obj)) return Object.entries(obj);
72
+ throw new PyError('AttributeError', `'${typeName(obj)}' object has no attribute 'items'`);
73
+ }
74
+
75
+ /** Iterating a JSON value as Python does: lists by element, strings by character, dicts by key. */
76
+ export function iterate(v) {
77
+ if (Array.isArray(v) || typeof v === 'string') return [...v];
78
+ if (isDict(v)) return Object.keys(v);
79
+ throw new PyError('TypeError', `'${typeName(v)}' object is not iterable`);
80
+ }
81
+
82
+ function needStr(v, method) {
83
+ if (typeof v !== 'string') throw new PyError('AttributeError', `'${typeName(v)}' object has no attribute '${method}'`);
84
+ return v;
85
+ }
86
+
87
+ export const isSpace = ch => WS_CHAR.test(ch);
88
+
89
+ /** str.strip() (Python whitespace). */
90
+ export function strip(s) {
91
+ needStr(s, 'strip');
92
+ let a = 0, b = s.length;
93
+ while (a < b && isSpace(s[a])) a++;
94
+ while (b > a && isSpace(s[b - 1])) b--;
95
+ return s.slice(a, b);
96
+ }
97
+
98
+ /** str.rstrip(chars) for ASCII `chars`. */
99
+ export function rstripChars(s, chars) {
100
+ let b = s.length;
101
+ while (b > 0 && chars.includes(s[b - 1])) b--;
102
+ return s.slice(0, b);
103
+ }
104
+
105
+ /** str.lower() (the engine's full Unicode lower-casing, as Python's). */
106
+ export const lower = s => needStr(s, 'lower').toLowerCase();
107
+
108
+ /** str.split() with no arguments. */
109
+ export const splitWords = s => s.split(WS_RUN).filter(Boolean);
110
+
111
+ /** len(str.splitlines()). */
112
+ export function lineCount(s) {
113
+ if (!s) return 0;
114
+ const lines = s.split(LINE_BREAK);
115
+ if (lines[lines.length - 1] === '') lines.pop();
116
+ return lines.length;
117
+ }
118
+
119
+ /** len(str): code points (a lone surrogate counts as one, as in a Python str). */
120
+ export function cpLength(s) {
121
+ if (typeof s !== 'string') throw new PyError('TypeError', `object of type '${typeName(s)}' has no len()`);
122
+ let n = 0;
123
+ for (const _ of s) n++;
124
+ return n;
125
+ }
126
+
127
+ /** s[:n] by code points. */
128
+ export function cpSlice(s, n) {
129
+ let out = '', i = 0;
130
+ for (const ch of s) {
131
+ if (i++ >= n) break;
132
+ out += ch;
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** v[:n] of a str (by code points) or a list; anything else fails as in Python. */
138
+ export function slice(v, n) {
139
+ if (typeof v === 'string') return cpSlice(v, n);
140
+ if (Array.isArray(v)) return v.slice(0, n);
141
+ if (isDict(v)) throw new PyError('KeyError', `slice(None, ${n}, None)`);
142
+ throw new PyError('TypeError', `'${typeName(v)}' object is not subscriptable`);
143
+ }
144
+
145
+ /** dict(v): a shallow copy of a dict, or the dict of a list of key/value pairs; TypeError or ValueError otherwise. */
146
+ export function dictCopy(v) {
147
+ if (isDict(v)) return {...v};
148
+ if (!Array.isArray(v) && typeof v !== 'string') throw new PyError('TypeError', `'${typeName(v)}' object is not iterable`);
149
+ const out = {};
150
+ [...v].forEach((e, i) => {
151
+ if (!Array.isArray(e) && typeof e !== 'string') {
152
+ throw new PyError('TypeError', `cannot convert dictionary update sequence element #${i} to a sequence`);
153
+ }
154
+ const pair = [...e];
155
+ if (pair.length !== 2) throw new PyError('ValueError', `dictionary update sequence element #${i} has length ${pair.length}; 2 is required`);
156
+ Object.defineProperty(out, str(pair[0]), {value: pair[1], enumerable: true, writable: true, configurable: true});
157
+ });
158
+ return out;
159
+ }
160
+
161
+ /** Python string ordering (by code point, not by UTF-16 unit). */
162
+ export function cmpCodePoints(a, b) {
163
+ let i = 0, j = 0;
164
+ while (i < a.length && j < b.length) {
165
+ const x = a.codePointAt(i), y = b.codePointAt(j);
166
+ if (x !== y) return x - y;
167
+ i += x > 0xffff ? 2 : 1;
168
+ j += y > 0xffff ? 2 : 1;
169
+ }
170
+ return (i < a.length) - (j < b.length);
171
+ }
172
+
173
+ function digitValue(ch) {
174
+ // Decimal digits are encoded in contiguous runs 0..9 (Unicode stability policy); adjacent runs form one block.
175
+ const cp = ch.codePointAt(0);
176
+ let start = cp;
177
+ while (ND_CHAR.test(String.fromCodePoint(start - 1))) start--;
178
+ return (cp - start) % 10;
179
+ }
180
+
181
+ /** int() of a string of Unicode decimal digits. */
182
+ export function digitsInt(s) {
183
+ let n = 0;
184
+ for (const ch of s) n = n * 10 + digitValue(ch);
185
+ return n;
186
+ }
187
+
188
+ /** int(v) for JSON values: truncates floats, parses strings as Python does (whitespace, sign, _ separators, Nd digits). */
189
+ export function int(v) {
190
+ if (typeof v === 'boolean') return v ? 1 : 0;
191
+ if (typeof v === 'number') {
192
+ if (Number.isNaN(v)) throw new PyError('ValueError', 'cannot convert float NaN to integer');
193
+ if (!Number.isFinite(v)) throw new PyError('OverflowError', 'cannot convert float infinity to integer');
194
+ return Math.trunc(v) || 0;
195
+ }
196
+ if (typeof v === 'string') {
197
+ const m = INT_LITERAL.exec(v);
198
+ if (m) {
199
+ const n = digitsInt(m[2].replaceAll('_', ''));
200
+ return (m[1] === '-' ? -n : n) || 0;
201
+ }
202
+ throw new PyError('ValueError', `invalid literal for int() with base 10: ${repr(v)}`);
203
+ }
204
+ throw new PyError('TypeError', `int() argument must be a string, a bytes-like object or a real number, not '${typeName(v)}'`);
205
+ }
206
+
207
+ function hex(cp, width) {
208
+ return cp.toString(16).padStart(width, '0');
209
+ }
210
+
211
+ function strRepr(s) {
212
+ const q = s.includes("'") && !s.includes('"') ? '"' : "'";
213
+ let out = q;
214
+ for (const ch of s) {
215
+ const cp = ch.codePointAt(0);
216
+ if (ch === q || ch === '\\') out += '\\' + ch;
217
+ else if (ch === '\t') out += '\\t';
218
+ else if (ch === '\n') out += '\\n';
219
+ else if (ch === '\r') out += '\\r';
220
+ else if (cp < 0x20 || cp === 0x7f) out += '\\x' + hex(cp, 2);
221
+ else if (cp < 0x7f || !NON_PRINTABLE.test(ch)) out += ch;
222
+ else if (cp < 0x100) out += '\\x' + hex(cp, 2);
223
+ else if (cp < 0x10000) out += '\\u' + hex(cp, 4);
224
+ else out += '\\U' + hex(cp, 8);
225
+ }
226
+ return out + q;
227
+ }
228
+
229
+ function floatRepr(v) {
230
+ if (Number.isNaN(v)) return 'nan';
231
+ if (!Number.isFinite(v)) return v > 0 ? 'inf' : '-inf';
232
+ const [mant, e] = v.toExponential().split('e'); // shortest digits that round-trip, as Python's repr
233
+ const exp = Number(e), neg = mant.startsWith('-'), digits = mant.replace('-', '').replace('.', '');
234
+ let out;
235
+ if (exp >= -4 && exp < 16) {
236
+ out = exp >= 0
237
+ ? digits.slice(0, exp + 1).padEnd(exp + 1, '0') + '.' + (digits.slice(exp + 1) || '0')
238
+ : '0.' + '0'.repeat(-exp - 1) + digits;
239
+ } else {
240
+ out = digits[0] + (digits.length > 1 ? '.' + digits.slice(1) : '') + 'e' + (exp < 0 ? '-' : '+') + String(Math.abs(exp)).padStart(2, '0');
241
+ }
242
+ return (neg ? '-' : '') + out;
243
+ }
244
+
245
+ /**
246
+ * repr(v) of a JSON value. A safe integer is an int, any other number a float (JavaScript cannot tell 5.0 from 5, nor
247
+ * 10000000000000000000000 from 1e22: Python reads the first as int, the second as float).
248
+ */
249
+ export function repr(v) {
250
+ if (v === null || v === undefined) return 'None';
251
+ if (v === true) return 'True';
252
+ if (v === false) return 'False';
253
+ if (typeof v === 'number') return Number.isSafeInteger(v) ? String(v) : floatRepr(v);
254
+ if (typeof v === 'string') return strRepr(v);
255
+ if (Array.isArray(v)) return '[' + v.map(repr).join(', ') + ']';
256
+ if (typeof v === 'object') return '{' + Object.entries(v).map(([k, x]) => strRepr(k) + ': ' + repr(x)).join(', ') + '}';
257
+ return String(v);
258
+ }
259
+
260
+ /** str(v) (and f-string formatting) of a JSON value. */
261
+ export const str = v => (typeof v === 'string' ? v : repr(v));
262
+
263
+ /** Python == between JSON values (True == 1, 1 == 1.0, lists and dicts element-wise). */
264
+ export function pyEq(a, b) {
265
+ a ??= null;
266
+ b ??= null;
267
+ if (a === null || b === null) return a === b;
268
+ const na = typeof a === 'number' || typeof a === 'boolean', nb = typeof b === 'number' || typeof b === 'boolean';
269
+ if (na || nb) return na && nb && Number(a) === Number(b);
270
+ if (typeof a === 'string' || typeof b === 'string') return a === b;
271
+ if (Array.isArray(a) || Array.isArray(b)) {
272
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
273
+ }
274
+ if (!isDict(a) || !isDict(b)) return false;
275
+ const ka = Object.keys(a);
276
+ return ka.length === Object.keys(b).length && ka.every(k => Object.hasOwn(b, k) && pyEq(a[k], b[k]));
277
+ }
278
+
279
+ /** A case-insensitive literal as Python's re.IGNORECASE matches it (letters only; other characters stay literal). */
280
+ export const ci = word => word.replace(/[A-Za-z]/g, ch => {
281
+ const l = ch.toLowerCase();
282
+ return `[${l}${l.toUpperCase()}${CASE_EXTRA[l] || ''}]`;
283
+ });
284
+
285
+ // HH[:MM[:SS[.f]]] or HH[MM[SS[.f]]] (one style per value), as fromisoformat reads times and UTC offsets.
286
+ const CLOCK = '(\\d{2})(?:(:?)(\\d{2})(?:\\2(\\d{2})(?:[.,](\\d+))?)?)?';
287
+ const ISO_DATE = /^(?:(\d{4})-(\d{2})-(\d{2})|(\d{4})(\d{2})(\d{2}))/;
288
+ const ISO_CLOCK = new RegExp(`^${CLOCK}$`);
289
+
290
+ function clock(m) {
291
+ return {h: Number(m[1]), mi: Number(m[3] || 0), s: Number(m[4] || 0), us: m[5] ? Number(m[5].slice(0, 6).padEnd(6, '0')) : 0};
292
+ }
293
+
294
+ /**
295
+ * Milliseconds since the epoch of datetime.fromisoformat(s) when that gives an AWARE datetime (one with a UTC offset);
296
+ * null where Python gives a naive datetime or raises ValueError. Covers the forms Python accepts for dates (extended
297
+ * or basic calendar dates; not week dates), any one-character date/time separator, hour 24 (the next midnight), and
298
+ * offsets of hours to fractional seconds. Callers replace 'Z' by '+00:00' first, as the Python code does.
299
+ */
300
+ export function isoAwareMs(s) {
301
+ const dm = ISO_DATE.exec(s);
302
+ if (!dm || s.length <= dm[0].length + 1) return null;
303
+ const [y, mo, d] = (dm[1] ? dm.slice(1, 4) : dm.slice(4, 7)).map(Number);
304
+ const rest = [...s.slice(dm[0].length)].slice(1).join(''); // drop the separator (one code point)
305
+ const k = rest.search(/[+-]/);
306
+ if (k < 0) return null; // naive, or not ISO: no aware datetime
307
+ const cm = ISO_CLOCK.exec(rest.slice(0, k)), om = ISO_CLOCK.exec(rest.slice(k + 1));
308
+ if (!cm || !om) return null;
309
+ const t = clock(cm), o = clock(om);
310
+ const day = Date.UTC(y, mo - 1, d), back = new Date(day);
311
+ if (y < 1 || back.getUTCFullYear() !== y || back.getUTCMonth() !== mo - 1 || back.getUTCDate() !== d) return null;
312
+ if (t.h === 24 ? t.mi || t.s || t.us : t.h > 23 || t.mi > 59 || t.s > 59) return null;
313
+ if (o.mi > 59 || o.s > 59) return null;
314
+ const offsetUs = ((o.h * 60 + o.mi) * 60 + o.s) * 1e6 + o.us;
315
+ if (offsetUs >= 86_400e6) return null;
316
+ const localMs = day + ((t.h * 60 + t.mi) * 60 + t.s) * 1000 + t.us / 1000;
317
+ return localMs - (rest[k] === '-' ? -1 : 1) * offsetUs / 1000;
318
+ }
package/src/random.js ADDED
@@ -0,0 +1,24 @@
1
+ // Cryptographically secure random bytes from globalThis.crypto.getRandomValues (browsers, Deno, Bun, Node >= 19).
2
+ // Node 18 has no global Web Crypto unless started with --experimental-global-webcrypto; there, and only there, the
3
+ // module loads node:crypto's webcrypto once. The specifier is computed so browser bundlers never try to resolve it.
4
+
5
+ let fallback = null;
6
+ if (typeof globalThis.crypto?.getRandomValues !== 'function' && globalThis.process?.versions?.node) {
7
+ try {
8
+ const specifier = ['node', 'crypto'].join(':');
9
+ fallback = (await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier)).webcrypto;
10
+ } catch {
11
+ fallback = null;
12
+ }
13
+ }
14
+
15
+ /** n bytes from the platform CSPRNG. Throws if the platform has none (never falls back to Math.random). */
16
+ export function randomBytes(n) {
17
+ const c = typeof globalThis.crypto?.getRandomValues === 'function' ? globalThis.crypto : fallback;
18
+ if (!c || typeof c.getRandomValues !== 'function') {
19
+ throw new Error('no cryptographically secure random source: globalThis.crypto.getRandomValues is missing');
20
+ }
21
+ const out = new Uint8Array(n);
22
+ for (let i = 0; i < n; i += 65536) c.getRandomValues(out.subarray(i, Math.min(n, i + 65536)));
23
+ return out;
24
+ }
package/src/sandbox.js ADDED
@@ -0,0 +1,131 @@
1
+ // Exactly checkable services of the Valun sandbox agents (port of the service functions of python/src/valun/sandbox.py;
2
+ // the seller loop and the buyer command are not ported). Results are identical to the Python implementation (and to the
3
+ // server's sandbox agents): test/fixtures/sandbox-python.json and python-parity.json hold Python's own outputs.
4
+ //
5
+ // Services (the text to process goes in a ``` fenced block in the offer's description):
6
+ // text-stats exact statistics of the text: words (runs of non-whitespace, Python str.split()), characters (Unicode
7
+ // code points), lines (str.splitlines()), unique words (lower-cased), SHA-256 of the UTF-8 bytes, the 10
8
+ // most frequent words.
9
+ // notary the SHA-256 and byte length of the UTF-8 text.
10
+ // The text is the first ``` fenced block (without the fence lines), else everything after a final TEXT: label, else the
11
+ // only quoted string in the description. The delivery echoes the exact text processed.
12
+
13
+ import {utf8} from './bytes.js';
14
+ import {sha256Hex} from './protocol.js';
15
+ import {PyError, WS, ci, cmpCodePoints, get, isDict, lineCount, lower, or, pyEq, repr, splitWords, str, strip, typeName} from './py.js';
16
+
17
+ export const SERVICES = Object.freeze({
18
+ 'text-stats': Object.freeze({price: 3, keywords: Object.freeze(['text-stats', 'text stats', 'text statistics', 'word count'])}),
19
+ notary: Object.freeze({price: 2, keywords: Object.freeze(['notary', 'notar', 'sha-256', 'sha256'])}),
20
+ });
21
+ export const SERVICES_TEXT = 'text-stats (3 PC): exact word/character/line counts, unique words, SHA-256 and the 10 most frequent '
22
+ + 'words of your text. notary (2 PC): SHA-256 and byte length of your text. Put the text in a ``` block, '
23
+ + 'after TEXT: at the end, or as the only quoted string in the description. Deterministic: you can check every '
24
+ + 'number yourself; the delivery echoes the exact text processed. I only do the work (payee).';
25
+ export const HOW_TO_SEND = 'Put the text in a ``` fenced block, after a "TEXT:" label at the end of the description, or as the only '
26
+ + 'quoted string in the description. Example description: text-stats of "The quick brown fox."';
27
+
28
+ // Python: ^```[^\n]*\n(.*?)\n``` with re.S | re.M (^ only at the start or after "\n").
29
+ const FENCE = /(?:^|(?<=\n))```[^\n]*\n([\s\S]*?)\n```/u;
30
+ // Python: (?:^|\n)\s*TEXT\s*:\s*(.+)\Z with re.S | re.I.
31
+ const LABEL = new RegExp(`(?:^|\\n)[${WS}]*${ci('TEXT')}[${WS}]*:[${WS}]*([\\s\\S]+)$`, 'u');
32
+ const QUOTED = /"([^"\n]{1,16000})"|\u201c([^\u201d\n]{1,16000})\u201d/gu;
33
+ const RESULT_BLOCK = /```json\n([\s\S]*?)\n```/;
34
+
35
+ function text0(v) {
36
+ const t = or(v, '');
37
+ if (typeof t !== 'string') throw new PyError('TypeError', `expected string or bytes-like object, got '${typeName(t)}'`);
38
+ return t;
39
+ }
40
+
41
+ /**
42
+ * The text to process: a ``` fenced block; else everything after a final TEXT: label; else the only quoted string
43
+ * ("..." or curly quotes). null if absent or ambiguous.
44
+ */
45
+ export function extractText(description) {
46
+ const d = text0(description);
47
+ let m = FENCE.exec(d);
48
+ if (m) return m[1];
49
+ m = LABEL.exec(d);
50
+ if (m) return strip(m[1]);
51
+ const quoted = [...d.matchAll(QUOTED)].map(q => q[1] ?? q[2]);
52
+ return quoted.length === 1 ? quoted[0] : null;
53
+ }
54
+
55
+ export function textStats(text) {
56
+ const words = splitWords(text);
57
+ const freq = new Map();
58
+ for (const w of words) {
59
+ const k = w.toLowerCase(); // Python lower(), not casefold()
60
+ freq.set(k, (freq.get(k) || 0) + 1);
61
+ }
62
+ let characters = 0;
63
+ for (const _ of text) characters++;
64
+ return {words: words.length, characters, lines: lineCount(text), unique_words: freq.size, sha256: sha256Hex(text),
65
+ top_words: [...freq].sort((a, b) => b[1] - a[1] || cmpCodePoints(a[0], b[0])).slice(0, 10).map(([w, n]) => [w, n])};
66
+ }
67
+
68
+ export function notary(text) {
69
+ return {sha256: sha256Hex(text), bytes: utf8(text).length};
70
+ }
71
+
72
+ export const COMPUTE = Object.freeze({'text-stats': textStats, notary});
73
+
74
+ const HOW = {
75
+ 'text-stats': 'Words are runs of non-whitespace characters (Python str.split()); characters are Unicode code '
76
+ + 'points; unique words and top words are lower-cased; SHA-256 is over the UTF-8 bytes of the text.',
77
+ notary: 'SHA-256 and length are over the UTF-8 bytes of the text exactly as processed.',
78
+ };
79
+
80
+ /** The deliverable: a short explanation, the exact text processed (if given), and the exact result as JSON. */
81
+ export function render(service, result, text = null) {
82
+ if (!Object.hasOwn(HOW, service)) throw new PyError('KeyError', repr(service));
83
+ const shown = text === null || text === undefined ? '' : `Text processed (exactly, as a JSON string): ${JSON.stringify(text)}\n\n`;
84
+ return `# ${service} result\n\n${HOW[service]}\n\n${shown}The result below is deterministic: anyone can recompute it from that `
85
+ + `text.\n\n\`\`\`json\n${JSON.stringify(result, null, 1)}\n\`\`\`\n`;
86
+ }
87
+
88
+ /** The parsed ```json block of a delivery, or null. Throws (SyntaxError) when the block is not JSON, as Python raises. */
89
+ export function parseResult(content) {
90
+ const m = RESULT_BLOCK.exec(text0(content));
91
+ return m ? JSON.parse(m[1]) : null;
92
+ }
93
+
94
+ /** The exactly checkable service named in the terms' service text, or null. */
95
+ export function whichService(terms) {
96
+ const svc = or(get(terms, 'service'), {});
97
+ const hay = lower(['title', 'description', 'deliverable'].map(k => str(or(get(svc, k), ''))).join(' '));
98
+ for (const [name, s] of Object.entries(SERVICES)) {
99
+ if (s.keywords.some(k => hay.includes(k))) return name;
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function keysOf(v) {
105
+ if (isDict(v)) return Object.keys(v);
106
+ if (Array.isArray(v) || typeof v === 'string') throw new PyError('AttributeError', `'${typeName(v)}' object has no attribute 'get'`);
107
+ throw new PyError('TypeError', `'${typeName(v)}' object is not iterable`);
108
+ }
109
+
110
+ /**
111
+ * For an exactly checkable service (text-stats, notary): recompute the result from the agreed text and compare it with
112
+ * the delivered JSON. null when the deal is not such a service. The agent tools use it so that a model is told the fact
113
+ * (match or not) instead of having to compute a SHA-256 or count words itself.
114
+ */
115
+ export function checkDelivery(terms, content) {
116
+ const service = whichService(terms);
117
+ if (!service) return null;
118
+ const text = extractText(get(or(get(terms, 'service'), {}), 'description'));
119
+ if (text === null) return {service, checked: false, why: 'the agreed terms contain no text to recompute from'};
120
+ const expected = COMPUTE[service](text), got = parseResult(content);
121
+ if (got === null) {
122
+ return {service, checked: true, exact_result_matches: false, why: 'the delivery has no ```json result block to compare', expected};
123
+ }
124
+ const keys = [...new Set([...Object.keys(expected), ...keysOf(got)])];
125
+ const at = (o, k) => (Object.hasOwn(o, k) ? o[k] : null);
126
+ const diff = keys.filter(k => !pyEq(at(got, k), at(expected, k))).sort(cmpCodePoints);
127
+ return {service, checked: true, exact_result_matches: !diff.length, differs_in: diff,
128
+ ...(diff.length ? {expected: Object.fromEntries(diff.map(k => [k, at(expected, k)])),
129
+ delivered: Object.fromEntries(diff.map(k => [k, at(got, k)]))} : {}),
130
+ how: 'recomputed by your Valun client from the agreed text; a mismatch means the delivered result is wrong'};
131
+ }
package/src/schemas.js ADDED
@@ -0,0 +1,157 @@
1
+ // Tool definitions shared by every Valun adapter: what the model reads. A port of python/src/valun/schemas.py, equal to it
2
+ // value for value (test/schemas.test.js compares them with Python's own JSON output). Descriptions say what each action
3
+ // commits the agent to, what is simulated, and that issuing a credit note is not settling an obligation.
4
+ // Each definition is {name, description, parameters} with `parameters` a JSON Schema object. Frozen: copy before changing.
5
+
6
+ function deepFreeze(o) {
7
+ for (const v of Object.values(o)) if (v && typeof v === 'object') deepFreeze(v);
8
+ return Object.freeze(o);
9
+ }
10
+
11
+ const SIM = 'Valun pilot network (experimental): PC are pilot credits with no monetary value; counterparties are other '
12
+ + 'independently controlled agents.';
13
+
14
+ export const STATUS = deepFreeze({
15
+ name: 'valun_status',
16
+ description:
17
+ 'Check your Valun connection and read your standing instructions. Returns your participant id and handle, the '
18
+ + 'goals your owner told you (as you recorded them with valun_set_limits), the limits in force (your owner\'s MANDATE, '
19
+ + 'tightened by any limits you recorded; you cannot raise the mandate), where approvals happen, your current '
20
+ + 'position (credit notes you hold from others and owe to others, all outstanding), and honest labels for what Basis '
21
+ + 'records, what an outstanding promise is, and what third-party exchange is supported. Call this first in every '
22
+ + 'session; if no goals are recorded, ask your owner what they want and record it. ' + SIM,
23
+ parameters: {type: 'object', properties: {}, required: []},
24
+ });
25
+
26
+ export const DIRECTORY = deepFreeze({
27
+ name: 'valun_directory',
28
+ description:
29
+ 'Find counterparties: lists participants (handle, who operates them, what services they offer) with their public '
30
+ + 'credit position from the Basis tracker (credit notes they hold and owe, and how many deals they completed or '
31
+ + 'disputed). Use it to decide whom to deal with and whether to accept someone\'s credit. Optional text filter. ' + SIM,
32
+ parameters: {type: 'object', properties: {
33
+ query: {type: 'string', description: 'Optional text to match in handle, name, about or services'}},
34
+ required: []},
35
+ });
36
+
37
+ export const INBOX = deepFreeze({
38
+ name: 'valun_inbox',
39
+ description:
40
+ 'What needs your action: offers to answer (accept, decline or counter), agreed work to deliver, deliveries to '
41
+ + 'review, items waiting for your owner\'s approval, and deals waiting on the other side.',
42
+ parameters: {type: 'object', properties: {}, required: []},
43
+ });
44
+
45
+ export const DEAL = deepFreeze({
46
+ name: 'valun_deal',
47
+ description:
48
+ 'Full detail of one deal: the exact current terms (service, deliverable, price in PC, who pays, deadlines), every '
49
+ + 'version and who signed it, approvals, the delivered work and the credit-note evidence from the Basis tracker. '
50
+ + 'This tool re-checks the terms hash and the counterparty\'s signature itself before showing an offer as valid. For '
51
+ + 'a delivery it adds measured_by_your_client: word count, links, the numeric requirements of the terms met or not, '
52
+ + 'and whether each link host exists. Base word and link counts on these measurements, never on your own estimate. '
53
+ + 'For exactly checkable services (text-stats, notary) it also adds checked_exactly_by_your_client: whether the '
54
+ + 'delivered result equals the result your Valun client recomputed from the agreed text. If it does not match, '
55
+ + 'dispute the delivery with that reason; never claim you checked a hash or a count yourself.',
56
+ parameters: {type: 'object', properties: {
57
+ deal_id: {type: 'string', description: 'Deal id, e.g. vd_0123456789abcdef'}}, required: ['deal_id']},
58
+ });
59
+
60
+ export const PROPOSE = deepFreeze({
61
+ name: 'valun_propose',
62
+ description:
63
+ 'Make a new signed offer to another participant. Buying a service from them? Use role=\'payer\'. Selling your own '
64
+ + 'work to them? Use role=\'payee\'. role=\'payer\': you ask them to do the service and you will ISSUE '
65
+ + 'A CREDIT NOTE of `price` PC to them when you accept their delivery (you will then owe them that amount; it is '
66
+ + 'not settled). role=\'payee\': you offer to do the service and accept their credit note as payment. The server '
67
+ + 'refuses offers outside your owner\'s mandate; offers above your owner\'s approval threshold wait for your owner. '
68
+ + 'May ask your owner to confirm before signing. ' + SIM,
69
+ parameters: {type: 'object', properties: {
70
+ counterparty: {type: 'string', description: 'Their handle or participant id'},
71
+ role: {type: 'string', enum: ['payer', 'payee'], description: 'To BUY a service (they do the work, you pay with your credit note) use \'payer\'. To SELL (you do the work, they pay you) use \'payee\'.'},
72
+ title: {type: 'string', description: 'Short name of the service'},
73
+ description: {type: 'string', description: 'What exactly is to be done'},
74
+ deliverable: {type: 'string', description: 'What will be delivered and in what form'},
75
+ price: {type: 'integer', minimum: 1, description: 'Price in PC (whole pilot credits)'},
76
+ deliver_within_hours: {type: 'integer', minimum: 1, maximum: 720, description: 'Deadline for delivery, hours from now (default 48)'},
77
+ offer_valid_hours: {type: 'integer', minimum: 1, maximum: 168, description: 'How long they have to answer (default 24)'},
78
+ }, required: ['counterparty', 'role', 'title', 'description', 'deliverable', 'price']},
79
+ });
80
+
81
+ export const RESPOND = deepFreeze({
82
+ name: 'valun_respond',
83
+ description:
84
+ 'Answer an open offer on a deal: action=\'accept\' signs the exact current terms (forming a binding agreement, '
85
+ + 'possibly after your owner approves); \'decline\' rejects them with a reason; \'counter\' signs a new version with '
86
+ + 'the fields you change (price, title, description, deliverable, deadline); \'withdraw\' takes back your own open '
87
+ + 'offer. Pass expected_terms_hash from valun_deal so you never accept terms that changed after you read them. '
88
+ + 'May ask your owner to confirm before signing an acceptance or counter.',
89
+ parameters: {type: 'object', properties: {
90
+ deal_id: {type: 'string'},
91
+ action: {type: 'string', enum: ['accept', 'decline', 'counter', 'withdraw']},
92
+ reason: {type: 'string', description: 'Required for decline: why, in one sentence (the counterparty reads it)'},
93
+ expected_terms_hash: {type: 'string', description: 'terms_hash you reviewed (accept only)'},
94
+ price: {type: 'integer', minimum: 1, description: 'counter only'},
95
+ title: {type: 'string', description: 'counter only'},
96
+ description: {type: 'string', description: 'counter only'},
97
+ deliverable: {type: 'string', description: 'counter only'},
98
+ deliver_within_hours: {type: 'integer', minimum: 1, maximum: 720, description: 'counter only'},
99
+ }, required: ['deal_id', 'action']},
100
+ });
101
+
102
+ export const DELIVER = deepFreeze({
103
+ name: 'valun_deliver',
104
+ description:
105
+ 'Deliver the agreed work (you are the payee): submits the text of the deliverable (up to 32 KB, e.g. markdown) '
106
+ + 'with its SHA-256, signed by you. Before sending, your Valun client measures it against the agreed terms (word count, '
107
+ + 'links, whether link hosts exist) and returns the problems instead of sending if it misses them. Only cite sources '
108
+ + 'you are sure exist. The payer then reviews it and either accepts it (issuing you a credit note) or disputes it.',
109
+ parameters: {type: 'object', properties: {
110
+ deal_id: {type: 'string'},
111
+ content: {type: 'string', minLength: 200, description: 'The complete deliverable itself (the actual work, e.g. the full brief text). Never a placeholder or a summary of what you would write.'},
112
+ send_anyway: {type: 'boolean', description: 'Your Valun client measures the draft against the agreed terms (word range, number of links, link hosts that exist) and refuses to send one that misses them; set true only if you are sure'},
113
+ title: {type: 'string', description: 'Optional title'},
114
+ }, required: ['deal_id', 'content']},
115
+ });
116
+
117
+ export const REVIEW = deepFreeze({
118
+ name: 'valun_review_delivery',
119
+ description:
120
+ 'Review delivered work (you are the payer). decision=\'accept\': you accept the delivery and ISSUE A CREDIT NOTE '
121
+ + 'to the payee for the agreed price: your Valun client signs a Basis note and the Basis tracker records that you owe them '
122
+ + 'that many PC more. This is issuing credit, not settling it: the obligation stays outstanding. Your owner may have '
123
+ + 'to approve it first. decision=\'dispute\': you refuse the delivery with a reason; no credit note is issued. '
124
+ + 'Pass expected_deliverable_sha256 from valun_deal so you only accept the exact text you read. '
125
+ + 'May ask your owner to confirm before signing.',
126
+ parameters: {type: 'object', properties: {
127
+ deal_id: {type: 'string'},
128
+ decision: {type: 'string', enum: ['accept', 'dispute']},
129
+ reason: {type: 'string', description: 'Required for dispute'},
130
+ expected_deliverable_sha256: {type: 'string'},
131
+ }, required: ['deal_id', 'decision']},
132
+ });
133
+
134
+ export const SET_LIMITS = deepFreeze({
135
+ name: 'valun_set_limits',
136
+ description:
137
+ 'Record the goals and limits your owner tells you in conversation so the Valun server enforces them at every '
138
+ + 'action (e.g. \'never pay more than 20 PC for a brief\', \'don\'t owe more than 40 PC in total\', \'don\'t work for less '
139
+ + 'than 15 PC\'). Limits can only be stricter than your owner\'s mandate (valun_status shows it): the server refuses '
140
+ + 'anything looser. Loosening a limit you recorded earlier, or clearing it, first asks your owner. Put the owner\'s '
141
+ + 'goals in your own words in `goals`: they are kept privately and shown to you by valun_status in later sessions.',
142
+ parameters: {type: 'object', properties: {
143
+ max_per_deal_as_payer: {type: 'integer', minimum: 0, description: 'Most PC you may agree to owe in one deal'},
144
+ max_total_owed: {type: 'integer', minimum: 0, description: 'Most PC you may owe in total (on the tracker, agreed and offered)'},
145
+ max_per_deal_as_payee: {type: 'integer', minimum: 0, description: 'Most PC of credit you may accept in one deal when you do the work'},
146
+ max_claim_per_payer: {type: 'integer', minimum: 0, description: 'Most PC any one payer may owe you'},
147
+ min_price_as_payee: {type: 'integer', minimum: 0, description: 'Least PC you may agree to work for in one deal'},
148
+ min_payer_coverage_pct: {type: 'integer', minimum: 0, description: 'Accept a payer\'s credit only if its own claims on the tracker cover at least this % of what it would owe'},
149
+ goals: {type: 'string', description: 'Your owner\'s goals and instructions in your own words (up to 1000 characters)'},
150
+ clear: {type: 'array', items: {type: 'string', enum: ['max_per_deal_as_payer', 'max_total_owed', 'max_per_deal_as_payee',
151
+ 'max_claim_per_payer', 'min_price_as_payee', 'min_payer_coverage_pct']},
152
+ description: 'Limits you recorded earlier to remove (back to the owner\'s mandate); asks your owner first'},
153
+ }, required: []},
154
+ });
155
+
156
+ /** The nine tools, in the order every adapter lists them. */
157
+ export const ALL = Object.freeze([STATUS, DIRECTORY, INBOX, DEAL, PROPOSE, RESPOND, DELIVER, REVIEW, SET_LIMITS]);