@idleware/ringi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +83 -2
- package/package.json +53 -4
- package/src/main/resources/profiles/a2a-1.json +43 -0
- package/src/main/resources/skill/ringi/SKILL.md +72 -0
- package/target/dts/app/agent.d.ts +104 -0
- package/target/dts/app/budget.d.ts +36 -0
- package/target/dts/app/context.d.ts +19 -0
- package/target/dts/app/hubs.d.ts +38 -0
- package/target/dts/app/overview.d.ts +25 -0
- package/target/dts/app/room.d.ts +76 -0
- package/target/dts/app/rooms.d.ts +26 -0
- package/target/dts/app/session.d.ts +60 -0
- package/target/dts/app/view.d.ts +64 -0
- package/target/dts/cli/agent.d.ts +10 -0
- package/target/dts/cli/args.d.ts +13 -0
- package/target/dts/cli/context.d.ts +6 -0
- package/target/dts/cli/index.d.ts +5 -0
- package/target/dts/cli/operator.d.ts +8 -0
- package/target/dts/cli/session.d.ts +7 -0
- package/target/dts/cli/shared.d.ts +20 -0
- package/target/dts/cli/skill.d.ts +4 -0
- package/target/dts/cli.d.ts +3 -0
- package/target/dts/domain/decisions.d.ts +65 -0
- package/target/dts/domain/index.d.ts +2 -0
- package/target/dts/domain/profiles.d.ts +39 -0
- package/target/dts/domain/signals.d.ts +37 -0
- package/target/dts/home/index.d.ts +21 -0
- package/target/dts/hub/index.d.ts +66 -0
- package/target/dts/hub/signals.d.ts +4 -0
- package/target/dts/index.d.ts +8 -0
- package/target/dts/lib/index.d.ts +18 -0
- package/target/dts/protocol/files.d.ts +9 -0
- package/target/dts/protocol/index.d.ts +72 -0
- package/target/dts/runtime/reader.d.ts +34 -0
- package/target/dts/runtime/session.d.ts +42 -0
- package/target/dts/runtime/watch.d.ts +17 -0
- package/target/dts/store/git/index.d.ts +64 -0
- package/target/dts/store/git/remote.d.ts +70 -0
- package/target/dts/store/index.d.ts +102 -0
- package/target/dts/store/kafka/index.d.ts +2 -0
- package/target/dts/store/temporal/index.d.ts +2 -0
- package/target/esm/app/agent.js +84 -0
- package/target/esm/app/budget.js +95 -0
- package/target/esm/app/context.js +16 -0
- package/target/esm/app/hubs.js +76 -0
- package/target/esm/app/overview.js +39 -0
- package/target/esm/app/room.js +127 -0
- package/target/esm/app/rooms.js +120 -0
- package/target/esm/app/session.js +97 -0
- package/target/esm/app/view.js +52 -0
- package/target/esm/cli/agent.js +40 -0
- package/target/esm/cli/args.js +61 -0
- package/target/esm/cli/context.js +8 -0
- package/target/esm/cli/index.js +125 -0
- package/target/esm/cli/operator.js +147 -0
- package/target/esm/cli/session.js +39 -0
- package/target/esm/cli/shared.js +143 -0
- package/target/esm/cli/skill.js +54 -0
- package/target/esm/cli.js +14 -0
- package/target/esm/domain/decisions.js +178 -0
- package/target/esm/domain/index.js +16 -0
- package/target/esm/domain/profiles.js +96 -0
- package/target/esm/domain/signals.js +129 -0
- package/target/esm/home/index.js +88 -0
- package/target/esm/hub/index.js +212 -0
- package/target/esm/hub/signals.js +27 -0
- package/target/esm/index.js +9 -0
- package/target/esm/lib/index.js +202 -0
- package/target/esm/protocol/files.js +19 -0
- package/target/esm/protocol/index.js +217 -0
- package/target/esm/runtime/reader.js +64 -0
- package/target/esm/runtime/session.js +112 -0
- package/target/esm/runtime/watch.js +304 -0
- package/target/esm/store/git/index.js +346 -0
- package/target/esm/store/git/remote.js +700 -0
- package/target/esm/store/index.js +15 -0
- package/target/esm/store/kafka/index.js +3 -0
- package/target/esm/store/temporal/index.js +3 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// lib: helpers that know nothing of ringi: failing, notes, formatting, strict JSON, atomic files, the inter-process
|
|
2
|
+
// lock.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
export class Fail extends Error {
|
|
5
|
+
}
|
|
6
|
+
export const fail = (msg) => { throw new Fail(msg); };
|
|
7
|
+
export const note = (s) => { process.stderr.write(`ringi: ${s}\n`); };
|
|
8
|
+
export const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
9
|
+
export const when = (t) => `${new Date(t * 1000).toISOString().slice(0, 16).replace('T', ' ')}Z`;
|
|
10
|
+
export const bytes = (n) => n < 1024 ? `${n} B` : n < 1 << 20 ? `${(n / 1024).toFixed(1)} KB` : `${(n / (1 << 20)).toFixed(1)} MB`;
|
|
11
|
+
export const short = (id) => id.slice(0, 12);
|
|
12
|
+
export const stamp = () => new Date().toISOString().slice(0, 19) + 'Z';
|
|
13
|
+
export const words = (v) => v ? String(v).split(/[\s,]+/).filter(Boolean) : [];
|
|
14
|
+
export const errno = (e) => e?.code;
|
|
15
|
+
// --- JSON files ---
|
|
16
|
+
export const writeJsonAtomic = (file, v) => {
|
|
17
|
+
const tmp = `${file}.${process.pid}`;
|
|
18
|
+
fs.writeFileSync(tmp, JSON.stringify(v, null, 2));
|
|
19
|
+
fs.renameSync(tmp, file);
|
|
20
|
+
};
|
|
21
|
+
// JSON.parse silently keeps the last of duplicate fields; for contracts and authority that hides a second value.
|
|
22
|
+
// This parser validates the structure first and refuses duplicates.
|
|
23
|
+
export const strictJson = (text, label = 'JSON') => {
|
|
24
|
+
let at = 0;
|
|
25
|
+
const ws = () => { while (at < text.length && /\s/.test(text[at]))
|
|
26
|
+
at++; };
|
|
27
|
+
const bad = () => fail(`${label}: malformed or duplicate JSON field at ${at}`);
|
|
28
|
+
const str = () => {
|
|
29
|
+
const start = at++;
|
|
30
|
+
while (at < text.length && text[at] !== '"')
|
|
31
|
+
at += text[at] === '\\' ? 2 : 1;
|
|
32
|
+
if (text[at++] !== '"')
|
|
33
|
+
bad();
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text.slice(start, at));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return bad();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const value = () => {
|
|
42
|
+
ws();
|
|
43
|
+
const c = text[at];
|
|
44
|
+
if (c === '{') {
|
|
45
|
+
at++;
|
|
46
|
+
ws();
|
|
47
|
+
const keys = new Set();
|
|
48
|
+
if (text[at] === '}') {
|
|
49
|
+
at++;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (;;) {
|
|
53
|
+
ws();
|
|
54
|
+
if (text[at] !== '"')
|
|
55
|
+
bad();
|
|
56
|
+
const k = str();
|
|
57
|
+
if (keys.has(k))
|
|
58
|
+
bad();
|
|
59
|
+
keys.add(k);
|
|
60
|
+
ws();
|
|
61
|
+
if (text[at++] !== ':')
|
|
62
|
+
bad();
|
|
63
|
+
value();
|
|
64
|
+
ws();
|
|
65
|
+
const n = text[at++];
|
|
66
|
+
if (n === '}')
|
|
67
|
+
return;
|
|
68
|
+
if (n !== ',')
|
|
69
|
+
bad();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (c === '[') {
|
|
73
|
+
at++;
|
|
74
|
+
ws();
|
|
75
|
+
if (text[at] === ']') {
|
|
76
|
+
at++;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (;;) {
|
|
80
|
+
value();
|
|
81
|
+
ws();
|
|
82
|
+
const n = text[at++];
|
|
83
|
+
if (n === ']')
|
|
84
|
+
return;
|
|
85
|
+
if (n !== ',')
|
|
86
|
+
bad();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (c === '"') {
|
|
90
|
+
str();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const m = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(text.slice(at));
|
|
94
|
+
if (!m)
|
|
95
|
+
bad();
|
|
96
|
+
at += m[0].length;
|
|
97
|
+
};
|
|
98
|
+
value();
|
|
99
|
+
ws();
|
|
100
|
+
if (at !== text.length)
|
|
101
|
+
bad();
|
|
102
|
+
return JSON.parse(text);
|
|
103
|
+
};
|
|
104
|
+
// --- the lock ---
|
|
105
|
+
// A lock is a file holding its owner (pid and a random token), created by hard-linking a file already written: it
|
|
106
|
+
// never exists without its owner and never replaces another lock. All acquisitions and the recovery of a dead
|
|
107
|
+
// owner's lock pass one gate, so two contenders cannot both clear a lock just taken by a third. A lock is taken from
|
|
108
|
+
// someone else only when that owner's process is gone, never by age; an unreadable owner is never taken over; only
|
|
109
|
+
// the owner releases it. A gate abandoned by a dead process needs the operator: recovering the recoverer would need
|
|
110
|
+
// yet another gate.
|
|
111
|
+
const ownerAlive = (pid) => {
|
|
112
|
+
try {
|
|
113
|
+
process.kill(pid, 0);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return errno(e) !== 'ESRCH';
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const ownerOf = (f) => {
|
|
121
|
+
try {
|
|
122
|
+
return fs.readFileSync(f, 'utf8');
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const deadOwner = (t) => {
|
|
129
|
+
const pid = Number(String(t ?? '').split('.')[0]);
|
|
130
|
+
return Number.isInteger(pid) && pid > 0 && !ownerAlive(pid);
|
|
131
|
+
};
|
|
132
|
+
const claim = (f, token) => {
|
|
133
|
+
const tmp = `${f}.${token}.tmp`;
|
|
134
|
+
fs.writeFileSync(tmp, token, { flag: 'wx', mode: 0o600 });
|
|
135
|
+
try {
|
|
136
|
+
fs.linkSync(tmp, f);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (errno(e) === 'EEXIST')
|
|
141
|
+
return false;
|
|
142
|
+
throw e;
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
fs.rmSync(tmp, { force: true });
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
const release = (f, token) => {
|
|
149
|
+
if (ownerOf(f) === token)
|
|
150
|
+
fs.rmSync(f, { force: true });
|
|
151
|
+
};
|
|
152
|
+
const lockWait = new Int32Array(new SharedArrayBuffer(4));
|
|
153
|
+
export const locked = (file, fn, timeout = 120000) => {
|
|
154
|
+
const token = `${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
155
|
+
const gate = `${file}.recover`;
|
|
156
|
+
const until = Date.now() + timeout;
|
|
157
|
+
for (;;) {
|
|
158
|
+
if (claim(gate, token)) {
|
|
159
|
+
try {
|
|
160
|
+
if (deadOwner(ownerOf(file)))
|
|
161
|
+
fs.rmSync(file, { force: true });
|
|
162
|
+
if (claim(file, token))
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
release(gate, token);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (deadOwner(ownerOf(gate))) {
|
|
170
|
+
fail(`${gate} was abandoned; if no ringi process runs, remove it`);
|
|
171
|
+
}
|
|
172
|
+
if (Date.now() >= until)
|
|
173
|
+
fail(`${file} is held by another process or an unknown owner; if no ringi process runs, remove it`);
|
|
174
|
+
Atomics.wait(lockWait, 0, 0, 25);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return fn();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
release(file, token);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
// --- writing where a sandbox may refuse ---
|
|
184
|
+
export const SANDBOX = /Operation not permitted|Permission denied|Read-only file system|EPERM|EACCES|EROFS/;
|
|
185
|
+
export const cannotWrite = (where, detail) => fail(`cannot write ${where}: ${detail}
|
|
186
|
+
An agent in a sandbox: re-run this same command with your host's sandbox escalation; the operator approves it.
|
|
187
|
+
The operator can instead add ${where} to the host's writable roots (Codex: [sandbox_workspace_write] writable_roots
|
|
188
|
+
in ~/.codex/config.toml) or point RINGI_HOME at a location the host already allows.`);
|
|
189
|
+
export const guardWrite = (where, fn) => {
|
|
190
|
+
try {
|
|
191
|
+
return fn();
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
if (e instanceof Fail)
|
|
195
|
+
throw e;
|
|
196
|
+
if (['EPERM', 'EACCES', 'EROFS'].includes(errno(e) ?? ''))
|
|
197
|
+
cannotWrite(where, `${errno(e)} on ${e.path ?? 'a file'}`);
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
export const mkdirIn = (where, d) => { guardWrite(where, () => fs.mkdirSync(d, { recursive: true, mode: 0o700 })); };
|
|
202
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// files: the paths a message may attach (core-1 §4).
|
|
2
|
+
import { fail } from '../lib/index.js';
|
|
3
|
+
export const CC_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/;
|
|
4
|
+
const COMPONENT_BAD = (c) => !c || c === '.' || c === '..' || c === '.git' || Buffer.byteLength(c) > 255 || CC_RE.test(c) || /[\n\t]/.test(c);
|
|
5
|
+
export const checkPath = (p) => {
|
|
6
|
+
if (p.startsWith('/') || p.split('/').some(COMPONENT_BAD))
|
|
7
|
+
fail(`bad file path ${p}`);
|
|
8
|
+
return p;
|
|
9
|
+
};
|
|
10
|
+
export const checkCollisions = (paths) => {
|
|
11
|
+
const seen = new Map();
|
|
12
|
+
for (const p of paths) {
|
|
13
|
+
const k = p.normalize('NFC').toLowerCase();
|
|
14
|
+
if (seen.has(k) && seen.get(k) !== p)
|
|
15
|
+
fail(`file paths collide: ${seen.get(k)} and ${p}`);
|
|
16
|
+
seen.set(k, p);
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=files.js.map
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// protocol: the message protocol, docs/spec/core-1.md (§3, §5–§7), independent of storage and of any task model. A store
|
|
2
|
+
// turns its records into messages and hands them here: text, threads, inboxes and waking live in this file. What a
|
|
3
|
+
// task is, which states it has and who may move it, is a profile (profile.ts), not part of the protocol.
|
|
4
|
+
import { CC_RE } from './files.js';
|
|
5
|
+
import { fail } from '../lib/index.js';
|
|
6
|
+
export const ID_RE = /^(?=.{1,64}$)[a-z0-9]+(?:[._-][a-z0-9]+)*(?<!\.lock)$/;
|
|
7
|
+
export const TOKEN_RE = /^[a-z][a-z0-9-]*$/;
|
|
8
|
+
export const HOP_RE = /^(?:0|[1-9]\d{0,5})$/;
|
|
9
|
+
export const TRAILER_RE = /^([A-Za-z][A-Za-z0-9-]*):[ \t]*(.*)$/;
|
|
10
|
+
export const TEXT_MAX = 64 * 1024;
|
|
11
|
+
// Trailers the protocol itself interprets; any other trailer belongs to an extension or a profile.
|
|
12
|
+
export const KNOWN = new Set(['to', 're', 'kind', 'state', 'hop', 'assign', 'roles']);
|
|
13
|
+
export const HEARTBEATS = new Set(['ping', 'pong']);
|
|
14
|
+
// Kinds that are bookkeeping, never delivered to an inbox: heartbeats, usage receipts, cards, room records.
|
|
15
|
+
export const HIDDEN = new Set(['ping', 'pong', 'usage', 'card', 'room']);
|
|
16
|
+
export const one = (m, k) => m.trailers[k]?.[0] ?? null;
|
|
17
|
+
// --- text (§3) ---
|
|
18
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
19
|
+
// Invalid text is dangerous bytes: readers treat the message as absent.
|
|
20
|
+
export const decodeText = (buf) => {
|
|
21
|
+
let s;
|
|
22
|
+
try {
|
|
23
|
+
s = utf8.decode(buf);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { invalid: 'not valid UTF-8' };
|
|
27
|
+
}
|
|
28
|
+
s = s.replace(/\r\n/g, '\n');
|
|
29
|
+
if (/\r/.test(s))
|
|
30
|
+
return { invalid: 'a CR not followed by LF' };
|
|
31
|
+
if (CC_RE.test(s))
|
|
32
|
+
return { invalid: 'control characters in the text' };
|
|
33
|
+
return { text: s };
|
|
34
|
+
};
|
|
35
|
+
// Parses well-formed and malformed text alike; `bad` names why a message routes nowhere. `isRef` is the store's
|
|
36
|
+
// grammar for message ids in `Re`.
|
|
37
|
+
export const parse = (raw, isRef) => {
|
|
38
|
+
const text = raw.replace(/\r\n/g, '\n').trimEnd();
|
|
39
|
+
const cut = text.lastIndexOf('\n\n');
|
|
40
|
+
const tail = cut < 0 ? [] : text.slice(cut + 2).split('\n');
|
|
41
|
+
const has = tail.length > 0 && tail.every(l => TRAILER_RE.test(l));
|
|
42
|
+
const trailers = {};
|
|
43
|
+
if (has) {
|
|
44
|
+
for (const l of tail) {
|
|
45
|
+
const [, k, v] = TRAILER_RE.exec(l);
|
|
46
|
+
const key = k.toLowerCase();
|
|
47
|
+
trailers[key] = [...(trailers[key] ?? []), v.trim()];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const head = has ? text.slice(0, cut) : text;
|
|
51
|
+
const nl = head.indexOf('\n');
|
|
52
|
+
const subject = (nl < 0 ? head : head.slice(0, nl)).trim();
|
|
53
|
+
const bad = [...KNOWN].filter(k => (trailers[k]?.length ?? 0) > 1).map(k => `duplicate ${k}`);
|
|
54
|
+
const get = (k) => trailers[k]?.[0] ?? null;
|
|
55
|
+
const ids = (v) => v === null ? null : [...new Set(v.split(',').map(x => x.trim()))];
|
|
56
|
+
const to = get('to') === '*' ? ['*'] : ids(get('to'));
|
|
57
|
+
const assign = ids(get('assign'));
|
|
58
|
+
// Roles: `<id>=<label>, …`; the label `none` clears one.
|
|
59
|
+
const pairs = get('roles') === null ? null : get('roles').split(',').map(x => x.trim().split('=').map(y => y.trim()));
|
|
60
|
+
if (pairs && !pairs.every(p => p.length === 2 && ID_RE.test(p[0]) && TOKEN_RE.test(p[1])))
|
|
61
|
+
bad.push('malformed Roles');
|
|
62
|
+
if (!subject)
|
|
63
|
+
bad.push('empty subject');
|
|
64
|
+
if (Buffer.byteLength(raw) > TEXT_MAX)
|
|
65
|
+
bad.push('text over 64 KiB');
|
|
66
|
+
if (!has)
|
|
67
|
+
bad.push('no trailer block');
|
|
68
|
+
else if (!to)
|
|
69
|
+
bad.push('no To');
|
|
70
|
+
else if (to[0] !== '*' && !to.every(x => ID_RE.test(x)))
|
|
71
|
+
bad.push('malformed To');
|
|
72
|
+
if (assign && !assign.every(x => ID_RE.test(x)))
|
|
73
|
+
bad.push('malformed Assign');
|
|
74
|
+
if (get('re') !== null && !isRef(get('re')))
|
|
75
|
+
bad.push('malformed Re');
|
|
76
|
+
if (get('hop') !== null && !HOP_RE.test(get('hop')))
|
|
77
|
+
bad.push('malformed Hop');
|
|
78
|
+
// A state is a token; whether the room's profile knows it is the store's check.
|
|
79
|
+
if (get('state') !== null && !TOKEN_RE.test(get('state')))
|
|
80
|
+
bad.push('malformed State');
|
|
81
|
+
if (get('kind') !== null && !TOKEN_RE.test(get('kind')))
|
|
82
|
+
bad.push('malformed Kind');
|
|
83
|
+
const ok = !bad.length;
|
|
84
|
+
return {
|
|
85
|
+
subject,
|
|
86
|
+
body: nl < 0 ? '' : head.slice(nl + 1).replace(/^\n+/, '').trimEnd(),
|
|
87
|
+
// A malformed message is read as if it had no trailers.
|
|
88
|
+
to: ok ? to : [],
|
|
89
|
+
assign: ok ? assign : null,
|
|
90
|
+
roles: ok && pairs ? Object.fromEntries(pairs) : null,
|
|
91
|
+
re: ok ? get('re') : null,
|
|
92
|
+
kind: ok ? get('kind') : null,
|
|
93
|
+
state: ok ? get('state') : null,
|
|
94
|
+
hop: ok && get('hop') !== null ? Number(get('hop')) : 0,
|
|
95
|
+
bad: bad.join(', ') || null,
|
|
96
|
+
trailers,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
export const compose = (text, trailers) => `${text}\n\n${trailers.filter(([, v]) => v).map(([k, v]) => `${k}: ${v}`).join('\n')}\n`;
|
|
100
|
+
// A client refuses to write what readers would drop or ignore (§3).
|
|
101
|
+
export const checkOutgoing = (text, isRef) => {
|
|
102
|
+
const d = decodeText(Buffer.from(text));
|
|
103
|
+
if (d.invalid !== undefined)
|
|
104
|
+
fail(`refusing to send: ${d.invalid}`);
|
|
105
|
+
const m = parse(d.text, isRef);
|
|
106
|
+
if (m.bad)
|
|
107
|
+
fail(`refusing to send a malformed message: ${m.bad}`);
|
|
108
|
+
return m;
|
|
109
|
+
};
|
|
110
|
+
// --- threads (§5) ---
|
|
111
|
+
// Resolves Re and computes effective hops. Invalid messages are absent: a Re naming one does not resolve.
|
|
112
|
+
export const link = (logs) => {
|
|
113
|
+
const all = new Map();
|
|
114
|
+
for (const msgs of logs.values())
|
|
115
|
+
for (const m of msgs)
|
|
116
|
+
if (!m.invalid)
|
|
117
|
+
all.set(m.id, m);
|
|
118
|
+
for (const m of all.values())
|
|
119
|
+
m.reId = m.re && all.has(m.re) ? m.re : null;
|
|
120
|
+
const eff = new Map();
|
|
121
|
+
for (const m of all.values()) {
|
|
122
|
+
const chain = [];
|
|
123
|
+
for (let x = m; x && !eff.has(x.id) && chain.length < 100000; x = x.reId ? all.get(x.reId) : undefined)
|
|
124
|
+
chain.push(x);
|
|
125
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
126
|
+
const c = chain[i];
|
|
127
|
+
eff.set(c.id, Math.max(c.hop, c.reId ? eff.get(c.reId) + 1 : c.re ? 1 : 0));
|
|
128
|
+
}
|
|
129
|
+
m.hopEff = eff.get(m.id);
|
|
130
|
+
}
|
|
131
|
+
return all;
|
|
132
|
+
};
|
|
133
|
+
// Kahn's order: the answered message before the answer, earlier before later within one log; among the ready ones,
|
|
134
|
+
// the smallest (time, id) goes first.
|
|
135
|
+
export const ordered = (list) => {
|
|
136
|
+
const byId = new Map(list.map(m => [m.id, m]));
|
|
137
|
+
const next = new Map(list.map(m => [m.id, []]));
|
|
138
|
+
const indeg = new Map(list.map(m => [m.id, 0]));
|
|
139
|
+
const edge = (a, b) => {
|
|
140
|
+
next.get(a.id).push(b);
|
|
141
|
+
indeg.set(b.id, indeg.get(b.id) + 1);
|
|
142
|
+
};
|
|
143
|
+
const last = new Map();
|
|
144
|
+
for (const m of [...list].sort((a, b) => a.seq - b.seq)) {
|
|
145
|
+
if (last.has(m.from))
|
|
146
|
+
edge(last.get(m.from), m);
|
|
147
|
+
last.set(m.from, m);
|
|
148
|
+
if (m.reId && byId.has(m.reId))
|
|
149
|
+
edge(byId.get(m.reId), m);
|
|
150
|
+
}
|
|
151
|
+
const ready = list.filter(m => !indeg.get(m.id));
|
|
152
|
+
const res = [];
|
|
153
|
+
while (ready.length) {
|
|
154
|
+
ready.sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : 1));
|
|
155
|
+
const m = ready.shift();
|
|
156
|
+
res.push(m);
|
|
157
|
+
for (const n of next.get(m.id)) {
|
|
158
|
+
indeg.set(n.id, indeg.get(n.id) - 1);
|
|
159
|
+
if (!indeg.get(n.id))
|
|
160
|
+
ready.push(n);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return res;
|
|
164
|
+
};
|
|
165
|
+
export const chainHas = (all, m, top) => {
|
|
166
|
+
for (let x = m, i = 0; x && i < 10000; x = x.reId ? all.get(x.reId) : undefined, i++)
|
|
167
|
+
if (x === top)
|
|
168
|
+
return true;
|
|
169
|
+
return false;
|
|
170
|
+
};
|
|
171
|
+
export const rootOf = (all, m) => {
|
|
172
|
+
let x = m;
|
|
173
|
+
for (let i = 0; x.reId && all.has(x.reId) && i < 10000; i++)
|
|
174
|
+
x = all.get(x.reId);
|
|
175
|
+
return x;
|
|
176
|
+
};
|
|
177
|
+
export const under = (all, top) => ordered([...all.values()].filter(m => chainHas(all, m, top)));
|
|
178
|
+
export const lookup = (all, ref) => {
|
|
179
|
+
const key = String(ref ?? '').toLowerCase();
|
|
180
|
+
if (all.has(key))
|
|
181
|
+
return all.get(key);
|
|
182
|
+
const hits = key.length >= 4 ? [...all.keys()].filter(k => k.startsWith(key)) : [];
|
|
183
|
+
if (hits.length === 1)
|
|
184
|
+
return all.get(hits[0]);
|
|
185
|
+
return fail(hits.length ? `ambiguous message id ${ref}` : `unknown message ${ref}`);
|
|
186
|
+
};
|
|
187
|
+
// --- reading (§6) and waking (§7) ---
|
|
188
|
+
// Delivered: well-formed, not a hidden kind, addressed to `id` by name or `*`.
|
|
189
|
+
export const visible = (m, id, hidden) => !m.invalid && !m.bad && !(m.kind && hidden.has(m.kind)) && (m.to.includes(id) || m.to.includes('*'));
|
|
190
|
+
// Wakes: only a direct address. Broadcasts are read when the agent is awake for another reason.
|
|
191
|
+
export const wakes = (m, id, hidden) => visible(m, id, hidden) && m.to.includes(id);
|
|
192
|
+
// Messages for `id` after the per-log cursors; cursors advance only past what was shown.
|
|
193
|
+
export const unread = (logs, id, cursors, limit = Infinity, hidden) => {
|
|
194
|
+
const fresh = [];
|
|
195
|
+
for (const [log, msgs] of logs) {
|
|
196
|
+
if (log === id)
|
|
197
|
+
continue;
|
|
198
|
+
const at = msgs.findIndex(m => m.id === cursors[log]);
|
|
199
|
+
fresh.push(...msgs.slice(at + 1).filter(m => visible(m, id, hidden)));
|
|
200
|
+
}
|
|
201
|
+
const queue = ordered(fresh);
|
|
202
|
+
const shown = queue.slice(0, limit);
|
|
203
|
+
const seen = new Set(shown.map(m => m.id));
|
|
204
|
+
const next = { ...cursors };
|
|
205
|
+
for (const [log, msgs] of logs) {
|
|
206
|
+
if (log === id)
|
|
207
|
+
continue;
|
|
208
|
+
const at = msgs.findIndex(m => m.id === cursors[log]);
|
|
209
|
+
for (const m of msgs.slice(at + 1)) {
|
|
210
|
+
if (visible(m, id, hidden) && !seen.has(m.id))
|
|
211
|
+
break;
|
|
212
|
+
next[log] = m.id;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { msgs: shown, rest: queue.length - shown.length, cursors: next };
|
|
216
|
+
};
|
|
217
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// reader: what one reader on this machine accepted of a room: heads, signals, cursors, alarms. Only history that
|
|
2
|
+
// extends what it accepted before, verified by the store, is delivered (docs/spec/hub-1.md, Trust).
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as Signals from '../domain/signals.js';
|
|
5
|
+
import { HOME, locked, readJson, writeJson } from '../home/index.js';
|
|
6
|
+
import { toMessage } from '../hub/index.js';
|
|
7
|
+
import { fail, note, short } from '../lib/index.js';
|
|
8
|
+
import { link } from '../protocol/index.js';
|
|
9
|
+
export const readerFile = (R, reader) => path.join(HOME, 'readers', R.hub.name, R.genesis, `${reader}.json`);
|
|
10
|
+
export const readerLock = (R, reader, fn) => locked(`reader-${R.hub.name}-${R.genesis.slice(0, 12)}-${reader}`, fn);
|
|
11
|
+
export const loadState = (R, reader) => readJson(readerFile(R, reader), {});
|
|
12
|
+
export const saveState = (R, reader, st) => writeJson(readerFile(R, reader), st);
|
|
13
|
+
// Every log must keep extending what this reader accepted, and the store must verify it (a signed store: every entry
|
|
14
|
+
// signed by the key the room admitted). A log that lost, rewrote or forked accepted history, or fails verification,
|
|
15
|
+
// is quarantined: withheld, its accepted head pinned as evidence. Only `ringi forget` by the operator accepts a new
|
|
16
|
+
// baseline.
|
|
17
|
+
export const loadRoomUnlocked = (R, reader) => {
|
|
18
|
+
const st = loadState(R, reader);
|
|
19
|
+
const store = R.hub.store;
|
|
20
|
+
if (st.control && !store.extends(st.control, R.head)) {
|
|
21
|
+
fail(`ALARM room ${R.name}: its control history was rewritten (accepted ${short(st.control)}, now ${short(R.head)}). Nothing is delivered; the operator decides`);
|
|
22
|
+
}
|
|
23
|
+
st.control = R.head;
|
|
24
|
+
st.heads ??= {};
|
|
25
|
+
const alarms = [];
|
|
26
|
+
const logs = new Map();
|
|
27
|
+
for (const id of new Set([...store.logIds(R.s), ...Object.keys(st.heads)])) {
|
|
28
|
+
const cur = store.logHead(R.s, id);
|
|
29
|
+
const acc = st.heads[id];
|
|
30
|
+
const why = !cur ? `log ${id} was deleted after ${short(acc)}`
|
|
31
|
+
: acc && !store.extends(acc, cur) ? `log ${id} was rewritten: ${short(cur)} does not extend the accepted ${short(acc)}`
|
|
32
|
+
: !store.extends(R.genesis, cur) ? `log ${id} does not descend from the room's genesis`
|
|
33
|
+
: store.verifyLog(R.s, id, cur);
|
|
34
|
+
if (why) {
|
|
35
|
+
alarms.push(why.startsWith('log ') ? why : `log ${id}: ${why}`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (acc !== cur) {
|
|
39
|
+
st.heads[id] = cur;
|
|
40
|
+
store.pin(R.s, reader, id, cur);
|
|
41
|
+
}
|
|
42
|
+
logs.set(id, store.messages(R.s, id, cur).map((x, seq) => toMessage(R, id, x, seq)));
|
|
43
|
+
}
|
|
44
|
+
const records = store.signals(R.s);
|
|
45
|
+
const checked = Signals.check(R, link(logs), records);
|
|
46
|
+
const signalPins = Signals.pins(records, checked, st.signals);
|
|
47
|
+
st.signals = signalPins.accepted;
|
|
48
|
+
alarms.push(...signalPins.alarms, ...checked.filter(x => !x.ok).map(x => `signal ${x.ref}: ${x.why}`));
|
|
49
|
+
const reported = new Set(st.alarms ?? []);
|
|
50
|
+
for (const a of alarms)
|
|
51
|
+
if (!reported.has(a))
|
|
52
|
+
note(`ALARM ${a}; ${a.startsWith('signal ')
|
|
53
|
+
? 'the messages stay the truth; the author restores a missing signal with ringi agent signals --repair'
|
|
54
|
+
: `it is quarantined and withheld. Restore it, or the operator runs ringi forget ${R.name}/<log>`}`);
|
|
55
|
+
st.alarms = alarms;
|
|
56
|
+
saveState(R, reader, st);
|
|
57
|
+
return { logs, st };
|
|
58
|
+
};
|
|
59
|
+
export const loadRoom = (R, reader) => readerLock(R, reader, () => loadRoomUnlocked(R, reader));
|
|
60
|
+
export const linked = (R, reader) => {
|
|
61
|
+
const { logs } = loadRoom(R, reader);
|
|
62
|
+
return { logs, all: link(logs) };
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=reader.js.map
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// session: which chat is which, and which seat it holds. A seat is one (hub, room genesis, agent); it ends only with
|
|
2
|
+
// leave or the operator's explicit takeover. Every agent command passes `bound()` first.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { HOME, guard, locked, readJson, writeJson } from '../home/index.js';
|
|
8
|
+
import { openHub, openRoom, roomNames } from '../hub/index.js';
|
|
9
|
+
import { errno, fail, stamp } from '../lib/index.js';
|
|
10
|
+
export const LEASE = 60 * 60 * 1000;
|
|
11
|
+
const detectProduct = () => {
|
|
12
|
+
const env = process.env;
|
|
13
|
+
if (env.CLAUDECODE === '1' || env.CLAUDE_CODE_SESSION_ID)
|
|
14
|
+
return 'claude';
|
|
15
|
+
if (Object.keys(env).some(k => k.startsWith('CODEX_')))
|
|
16
|
+
return 'codex';
|
|
17
|
+
if (Object.keys(env).some(k => k.startsWith('CURSOR_')))
|
|
18
|
+
return 'cursor';
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
const ancestry = () => {
|
|
22
|
+
let pid = process.ppid;
|
|
23
|
+
for (let i = 0; i < 12 && pid > 1; i++) {
|
|
24
|
+
const r = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8' });
|
|
25
|
+
const m = r.status === 0 ? /^\s*(\d+)\s+(.*)$/.exec(String(r.stdout ?? '').trim()) : null;
|
|
26
|
+
if (!m)
|
|
27
|
+
return null;
|
|
28
|
+
const comm = path.basename(m[2].trim()).toLowerCase();
|
|
29
|
+
if (/^(claude|codex|cursor|cursor-agent)$/.test(comm))
|
|
30
|
+
return { pid, product: comm.replace('-agent', '') };
|
|
31
|
+
pid = Number(m[1]);
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
};
|
|
35
|
+
// A chat is known by a stable handle: RINGI_SESSION, else the chat id the UI exposes, else the UI process as a last
|
|
36
|
+
// resort (ps may be denied in a sandbox; then RINGI_SESSION is required).
|
|
37
|
+
export const whoAmI = () => {
|
|
38
|
+
const env = process.env;
|
|
39
|
+
const product = detectProduct();
|
|
40
|
+
if (env.RINGI_SESSION)
|
|
41
|
+
return { handle: `env:${env.RINGI_SESSION}`, product, pid: null };
|
|
42
|
+
if (env.CLAUDE_CODE_SESSION_ID)
|
|
43
|
+
return { handle: `claude:${env.CLAUDE_CODE_SESSION_ID}`, product: 'claude', pid: Number(env.CLAUDE_PID) || null };
|
|
44
|
+
const a = ancestry() ?? fail('cannot tell which session this is: set RINGI_SESSION to a stable id of this chat (for Codex: its thread id) on every ringi command');
|
|
45
|
+
return { handle: `pid:${a.pid}`, product: product ?? a.product, pid: a.pid };
|
|
46
|
+
};
|
|
47
|
+
const sessionsDir = () => path.join(HOME, 'sessions');
|
|
48
|
+
const sessionFile = (handle) => path.join(sessionsDir(), `${handle.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 100)}-${crypto.createHash('sha256').update(handle).digest('hex').slice(0, 10)}.json`);
|
|
49
|
+
export const allSessions = () => {
|
|
50
|
+
let files = [];
|
|
51
|
+
try {
|
|
52
|
+
files = fs.readdirSync(sessionsDir()).filter(f => f.endsWith('.json'));
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
return files.map(f => readJson(path.join(sessionsDir(), f), null)).filter((s) => !!s?.handle);
|
|
56
|
+
};
|
|
57
|
+
export const readSession = (handle) => {
|
|
58
|
+
const s = readJson(sessionFile(handle), null);
|
|
59
|
+
return s?.handle === handle ? s : null;
|
|
60
|
+
};
|
|
61
|
+
export const saveSession = (s) => writeJson(sessionFile(s.handle), s);
|
|
62
|
+
export const dropSession = (handle) => { guard(() => fs.rmSync(sessionFile(handle), { force: true })); };
|
|
63
|
+
export const pidAlive = (pid) => {
|
|
64
|
+
try {
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
return errno(e) === 'EPERM';
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
// active: in use within the lease by a living process; stopped: paused by the operator; gone: neither. A gone or
|
|
73
|
+
// stopped binding keeps its seat and scope. A watcher is alive exactly while its process is: a run may outlast any
|
|
74
|
+
// lease.
|
|
75
|
+
export const liveness = (s) => s.state === 'stopped' ? 'stopped'
|
|
76
|
+
: s.product === 'watch' ? (s.pid && pidAlive(s.pid) ? 'active' : 'gone')
|
|
77
|
+
: Date.now() - Date.parse(s.lastSeen) < LEASE && (!s.pid || pidAlive(s.pid)) ? 'active' : 'gone';
|
|
78
|
+
export const seatHolder = (hub, genesis, agent, except) => allSessions().find(s => s.hub === hub && s.genesis === genesis && s.agent === agent && s.handle !== except);
|
|
79
|
+
// An explicit chat-to-watch handoff remembers only that chat; it never selects another chat's seat.
|
|
80
|
+
export const ownSession = (handle) => readSession(handle)
|
|
81
|
+
?? allSessions().find(s => s.product === 'watch' && s.takenFrom === handle) ?? null;
|
|
82
|
+
// The binding, checked on every agent command: joined, not stopped, room alive and the same room, still a member.
|
|
83
|
+
export const bound = () => {
|
|
84
|
+
const me = whoAmI();
|
|
85
|
+
const s = readSession(me.handle) ?? fail(`this session is not in a room. Take part only when the operator says so in this chat:
|
|
86
|
+
"join <room> as <id>" runs ringi join local <room> --as <id> --scope "<what they delegate>";
|
|
87
|
+
after a restart, "continue" runs ringi continue.`);
|
|
88
|
+
if (s.state === 'stopped')
|
|
89
|
+
fail('this session is stopped: the operator paused it. It reacts to nothing until the operator says "continue" (ringi continue)');
|
|
90
|
+
const H = openHub(s.hub);
|
|
91
|
+
const R = openRoom(H, s.room, { quiet: true });
|
|
92
|
+
if (!R || R.genesis !== s.genesis) {
|
|
93
|
+
const gone = roomNames(H, true).find(a => a === `${s.room}@${s.genesis.slice(0, 8)}`);
|
|
94
|
+
if (gone)
|
|
95
|
+
fail(`room ${s.room} was archived as ${gone}; nothing more happens there. Tell the operator`);
|
|
96
|
+
fail(R ? `room ${s.room} was deleted and created again; this session's binding ended with the old room. The operator joins it anew` : `room ${s.room} no longer exists in hub ${s.hub}. Tell the operator`);
|
|
97
|
+
}
|
|
98
|
+
if (!R.members[s.agent])
|
|
99
|
+
fail(`${s.agent} was removed from room ${s.room}; returning needs a fresh join by the operator`);
|
|
100
|
+
locked('sessions', () => {
|
|
101
|
+
const cur = readSession(me.handle) ?? fail('this session lost its seat');
|
|
102
|
+
if (cur.state !== 'active' || cur.genesis !== s.genesis || cur.agent !== s.agent)
|
|
103
|
+
fail('this session was stopped or its seat changed');
|
|
104
|
+
saveSession({ ...cur, lastSeen: stamp(), pid: me.pid ?? cur.pid });
|
|
105
|
+
});
|
|
106
|
+
return { s, H, R: R, me: s.agent };
|
|
107
|
+
};
|
|
108
|
+
export const parseSelector = (sel) => {
|
|
109
|
+
const m = /^(?:([a-z0-9._-]+):)?([^/]+)\/([^/]+)$/.exec(sel ?? '');
|
|
110
|
+
return m ? { hub: m[1] ?? null, room: m[2], agent: m[3] } : fail('a session is named <room>/<agent> or <hub>:<room>/<agent>');
|
|
111
|
+
};
|
|
112
|
+
//# sourceMappingURL=session.js.map
|