@zhin.js/harness 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ import { inboxMarkers } from './markers.js';
2
+ export function classifyMail(mail, options) {
3
+ const m = inboxMarkers(mail);
4
+ if (options.live && m.live)
5
+ return 'live';
6
+ if (options.merge && m.mergeable && m.plainText)
7
+ return 'merge';
8
+ return 'single';
9
+ }
10
+ export function groupInbox(entries, options) {
11
+ const maxSenders = Math.max(1, Math.floor(options.merge?.maxSenders ?? 1));
12
+ const drafts = [];
13
+ const draftOf = new Map();
14
+ const byMail = new Map();
15
+ const findLast = (predicate) => {
16
+ for (let i = drafts.length - 1; i >= 0; i -= 1)
17
+ if (predicate(drafts[i]))
18
+ return drafts[i];
19
+ return undefined;
20
+ };
21
+ const open = (kind, plainText) => {
22
+ const d = { kind, own: new Set(), plainText };
23
+ drafts.push(d);
24
+ return d;
25
+ };
26
+ for (const entry of entries) {
27
+ if (entry.carriedInto !== undefined)
28
+ continue;
29
+ const m = inboxMarkers(entry.mail);
30
+ const kind = classifyMail(entry.mail, options);
31
+ let d;
32
+ if (kind === 'live') {
33
+ d = findLast((g) => g.kind === 'live') ?? open('live', true);
34
+ }
35
+ else if (kind === 'merge') {
36
+ d = findLast((g) => g.kind === 'merge' && g.own.has(m.senderId))
37
+ ?? findLast((g) => g.kind === 'merge' && g.own.size < maxSenders)
38
+ ?? open('merge', true);
39
+ }
40
+ else {
41
+ const own = findLast((g) => g.own.has(m.senderId));
42
+ d = own && own.kind === 'single' && own.plainText && m.plainText ? own : open('single', m.plainText);
43
+ }
44
+ d.own.add(m.senderId);
45
+ draftOf.set(entry, d);
46
+ byMail.set(entry.mail.id, d);
47
+ }
48
+ for (const entry of entries) {
49
+ if (entry.carriedInto === undefined)
50
+ continue;
51
+ const target = byMail.get(entry.carriedInto);
52
+ if (target) {
53
+ draftOf.set(entry, target);
54
+ }
55
+ else {
56
+ const d = { kind: 'single', own: new Set([inboxMarkers(entry.mail).senderId]), plainText: inboxMarkers(entry.mail).plainText };
57
+ draftOf.set(entry, d);
58
+ }
59
+ }
60
+ const order = [];
61
+ const members = new Map();
62
+ for (const entry of entries) {
63
+ const d = draftOf.get(entry);
64
+ let list = members.get(d);
65
+ if (!list) {
66
+ list = [];
67
+ members.set(d, list);
68
+ order.push(d);
69
+ }
70
+ list.push(entry);
71
+ }
72
+ return order.map((d) => {
73
+ const list = members.get(d);
74
+ const own = list.filter((e) => e.carriedInto === undefined);
75
+ const times = (own.length > 0 ? own : list).map((e) => e.postedAt);
76
+ const senders = [];
77
+ for (const e of list) {
78
+ const s = inboxMarkers(e.mail).senderId;
79
+ if (!senders.includes(s))
80
+ senders.push(s);
81
+ }
82
+ return Object.freeze({
83
+ kind: d.kind,
84
+ entries: Object.freeze(list),
85
+ senders: Object.freeze(senders),
86
+ plainText: d.plainText,
87
+ openedAt: Math.min(...times),
88
+ lastJoinedAt: Math.max(...times),
89
+ });
90
+ });
91
+ }
@@ -0,0 +1,5 @@
1
+ export * from './markers.js';
2
+ export * from './groups.js';
3
+ export * from './merge.js';
4
+ export * from './live.js';
5
+ export * from './admission.js';
@@ -0,0 +1,5 @@
1
+ export * from './markers.js';
2
+ export * from './groups.js';
3
+ export * from './merge.js';
4
+ export * from './live.js';
5
+ export * from './admission.js';
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Danmaku window (ADR 0038 §5) as an inbox policy part.
3
+ *
4
+ * Every danmaku of a lane waits in one window that closes `windowMs` after
5
+ * its first mail, but never sooner than `minIntervalMs` after the lane's
6
+ * previous danmaku turn ended. No sender limit; the window keeps the newest
7
+ * `LIVE_WINDOW_MAX_MESSAGES` mails (older ones are evicted, counted in
8
+ * `InboxTurnFacts.evicted`), and one turn answers it.
9
+ */
10
+ import type { InboxEntry, MailId } from '../contracts.js';
11
+ import type { InboxGroup } from './groups.js';
12
+ export interface LiveWindowOptions {
13
+ /** Answer the window this long after its first danmaku. */
14
+ readonly windowMs: number;
15
+ /** Never start a danmaku turn sooner than this after the lane's previous one ended. */
16
+ readonly minIntervalMs: number;
17
+ }
18
+ /** Danmaku one window keeps for the model; older ones are evicted. */
19
+ export declare const LIVE_WINDOW_MAX_MESSAGES = 100;
20
+ export declare function normalizeLiveWindow(options: LiveWindowOptions): LiveWindowOptions;
21
+ /** When a live group may be answered; `lastLiveEndedAt` = end of the lane's previous danmaku turn. */
22
+ export declare function liveWindowDueAt(group: Pick<InboxGroup, 'openedAt'>, lastLiveEndedAt: number | undefined, options: LiveWindowOptions): number;
23
+ /**
24
+ * Mails to evict so a live group keeps at most `max` mails: the earliest
25
+ * sender's oldest mail goes first (as the former gate did), so memory stays
26
+ * bounded however busy the room is.
27
+ */
28
+ export declare function liveEvictions(entries: readonly InboxEntry[], max?: number): readonly MailId[];
@@ -0,0 +1,44 @@
1
+ import { inboxMarkers } from './markers.js';
2
+ /** Danmaku one window keeps for the model; older ones are evicted. */
3
+ export const LIVE_WINDOW_MAX_MESSAGES = 100;
4
+ export function normalizeLiveWindow(options) {
5
+ return Object.freeze({
6
+ windowMs: Math.max(0, options.windowMs),
7
+ minIntervalMs: Math.max(0, options.minIntervalMs),
8
+ });
9
+ }
10
+ /** When a live group may be answered; `lastLiveEndedAt` = end of the lane's previous danmaku turn. */
11
+ export function liveWindowDueAt(group, lastLiveEndedAt, options) {
12
+ return Math.max(group.openedAt + options.windowMs, lastLiveEndedAt === undefined ? 0 : lastLiveEndedAt + options.minIntervalMs);
13
+ }
14
+ /**
15
+ * Mails to evict so a live group keeps at most `max` mails: the earliest
16
+ * sender's oldest mail goes first (as the former gate did), so memory stays
17
+ * bounded however busy the room is.
18
+ */
19
+ export function liveEvictions(entries, max = LIVE_WINDOW_MAX_MESSAGES) {
20
+ const excess = entries.length - Math.max(0, max);
21
+ if (excess <= 0)
22
+ return [];
23
+ const order = [];
24
+ const by = new Map();
25
+ for (const e of entries) {
26
+ const s = inboxMarkers(e.mail).senderId;
27
+ let list = by.get(s);
28
+ if (!list) {
29
+ list = [];
30
+ by.set(s, list);
31
+ order.push(s);
32
+ }
33
+ list.push(e.mail.id);
34
+ }
35
+ const out = [];
36
+ for (const s of order) {
37
+ for (const id of by.get(s)) {
38
+ if (out.length >= excess)
39
+ return out;
40
+ out.push(id);
41
+ }
42
+ }
43
+ return out;
44
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Reads the inbox-policy markers of a mail (ADR 0048 H3).
3
+ *
4
+ * Only `human` mail carries markers (`MailBody.human`). Every other kind is
5
+ * an ordinary, non-plain, non-mergeable mail from its transport sender, so
6
+ * actor / timer / event mail is never merged into, superseded by, or used to
7
+ * stop a human's turn.
8
+ */
9
+ import { type Mail } from '../contracts.js';
10
+ export interface InboxMarkers {
11
+ readonly senderId: string;
12
+ readonly senderName?: string;
13
+ readonly text: string;
14
+ readonly messageId?: string;
15
+ readonly human: boolean;
16
+ readonly plainText: boolean;
17
+ readonly mergeable: boolean;
18
+ readonly live: boolean;
19
+ readonly isMaster: boolean;
20
+ readonly isStop: boolean;
21
+ readonly unaddressed: boolean;
22
+ }
23
+ export declare function inboxMarkers(mail: Mail): InboxMarkers;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Reads the inbox-policy markers of a mail (ADR 0048 H3).
3
+ *
4
+ * Only `human` mail carries markers (`MailBody.human`). Every other kind is
5
+ * an ordinary, non-plain, non-mergeable mail from its transport sender, so
6
+ * actor / timer / event mail is never merged into, superseded by, or used to
7
+ * stop a human's turn.
8
+ */
9
+ import { senderOf } from '../contracts.js';
10
+ function textOf(mail) {
11
+ const body = mail.body;
12
+ switch (body.kind) {
13
+ case 'human': return body.text;
14
+ case 'ask': return body.question;
15
+ case 'delegate': return body.brief;
16
+ case 'tell': return body.content;
17
+ case 'result': return body.content;
18
+ case 'timer': return body.content;
19
+ default: return '';
20
+ }
21
+ }
22
+ export function inboxMarkers(mail) {
23
+ const senderId = senderOf(mail);
24
+ const text = textOf(mail);
25
+ const body = mail.body;
26
+ if (body.kind !== 'human') {
27
+ return { senderId, text, human: false, plainText: false, mergeable: false, live: false, isMaster: false, isStop: false, unaddressed: false };
28
+ }
29
+ return {
30
+ senderId,
31
+ ...(body.senderName ? { senderName: body.senderName } : {}),
32
+ text,
33
+ ...(body.messageId ? { messageId: body.messageId } : {}),
34
+ human: true,
35
+ plainText: body.plainText === true,
36
+ mergeable: body.mergeable === true,
37
+ live: body.live === true,
38
+ isMaster: body.isMaster === true,
39
+ isStop: body.isStop === true,
40
+ unaddressed: body.unaddressed === true,
41
+ };
42
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Merge window (ADR 0038 §1) as an inbox policy part, plus the helpers a
3
+ * merged turn needs (ADR 0048 H3, Q2).
4
+ *
5
+ * A merge group is answered once it went quiet for `quietMs` or, at the
6
+ * latest, `maxWaitMs` after it opened. Q2 (accepted): a merged batch runs in
7
+ * its first mail's engagement; its principal is the first human sender; its
8
+ * authority is the intersection of every sender's authority. These helpers
9
+ * are exposed now; wiring them into the IM turn is PR-2.
10
+ */
11
+ import { type Authority, type Mail, type Principal } from '../contracts.js';
12
+ import type { InboxGroup } from './groups.js';
13
+ export interface MergeWindowOptions {
14
+ /** Flush once no new mail joined the window for this long. */
15
+ readonly quietMs: number;
16
+ /** Flush at the latest this long after the window opened. */
17
+ readonly maxWaitMs: number;
18
+ /** Senders one window may hold; further senders open the next window. */
19
+ readonly maxSenders: number;
20
+ }
21
+ export declare function normalizeMergeWindow(options: MergeWindowOptions): MergeWindowOptions;
22
+ /** When a merge group may be answered. */
23
+ export declare function mergeWindowDueAt(group: Pick<InboxGroup, 'openedAt' | 'lastJoinedAt'>, options: MergeWindowOptions): number;
24
+ /** One sender's mails in a batch, in arrival order. */
25
+ export interface InboxParticipant {
26
+ readonly senderId: string;
27
+ readonly senderName?: string;
28
+ readonly texts: readonly string[];
29
+ /** Platform message ids behind `texts`, when known. */
30
+ readonly messageIds?: readonly string[];
31
+ readonly mails: readonly Mail[];
32
+ }
33
+ /** Groups a batch by sender, senders in order of their first mail. */
34
+ export declare function participantsOf(batch: readonly Mail[]): readonly InboxParticipant[];
35
+ /**
36
+ * One sender: their texts joined by newlines. Several senders: one
37
+ * `name: text` line per sender so the model can tell who asked what.
38
+ */
39
+ export declare function renderInboxText(participants: readonly InboxParticipant[]): string;
40
+ /** Q2: the principal of a merged batch is its first human sender. */
41
+ export declare function firstHumanPrincipal(batch: readonly Mail[]): Principal | undefined;
42
+ /**
43
+ * Q2: the authority of a merged batch is the intersection of its mails'
44
+ * authorities (trust = lowest, tools / contacts / egress hosts = common,
45
+ * network only when everyone has it). Principal, approvers and chain stay
46
+ * those of the first human mail (approvals go to that principal), so the
47
+ * result never exceeds any sender's authority.
48
+ */
49
+ export declare function mergedAuthority(batch: readonly Mail[]): Authority | undefined;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Merge window (ADR 0038 §1) as an inbox policy part, plus the helpers a
3
+ * merged turn needs (ADR 0048 H3, Q2).
4
+ *
5
+ * A merge group is answered once it went quiet for `quietMs` or, at the
6
+ * latest, `maxWaitMs` after it opened. Q2 (accepted): a merged batch runs in
7
+ * its first mail's engagement; its principal is the first human sender; its
8
+ * authority is the intersection of every sender's authority. These helpers
9
+ * are exposed now; wiring them into the IM turn is PR-2.
10
+ */
11
+ import { intersectScopes } from '../contracts.js';
12
+ import { minTrust } from '../authority.js';
13
+ import { inboxMarkers } from './markers.js';
14
+ export function normalizeMergeWindow(options) {
15
+ return Object.freeze({
16
+ quietMs: Math.max(0, options.quietMs),
17
+ maxWaitMs: Math.max(0, options.maxWaitMs),
18
+ maxSenders: Math.max(1, Math.floor(options.maxSenders)),
19
+ });
20
+ }
21
+ /** When a merge group may be answered. */
22
+ export function mergeWindowDueAt(group, options) {
23
+ return Math.min(group.lastJoinedAt + options.quietMs, group.openedAt + options.maxWaitMs);
24
+ }
25
+ /** Groups a batch by sender, senders in order of their first mail. */
26
+ export function participantsOf(batch) {
27
+ const order = [];
28
+ const by = new Map();
29
+ for (const mail of batch) {
30
+ const m = inboxMarkers(mail);
31
+ let p = by.get(m.senderId);
32
+ if (!p) {
33
+ p = { ...(m.senderName ? { senderName: m.senderName } : {}), texts: [], messageIds: [], mails: [] };
34
+ by.set(m.senderId, p);
35
+ order.push(m.senderId);
36
+ }
37
+ if (!p.senderName && m.senderName)
38
+ p.senderName = m.senderName;
39
+ p.texts.push(m.text);
40
+ if (m.messageId)
41
+ p.messageIds.push(m.messageId);
42
+ p.mails.push(mail);
43
+ }
44
+ return Object.freeze(order.map((senderId) => {
45
+ const p = by.get(senderId);
46
+ return Object.freeze({
47
+ senderId,
48
+ ...(p.senderName ? { senderName: p.senderName } : {}),
49
+ texts: Object.freeze(p.texts),
50
+ ...(p.messageIds.length > 0 ? { messageIds: Object.freeze(p.messageIds) } : {}),
51
+ mails: Object.freeze(p.mails),
52
+ });
53
+ }));
54
+ }
55
+ /**
56
+ * One sender: their texts joined by newlines. Several senders: one
57
+ * `name: text` line per sender so the model can tell who asked what.
58
+ */
59
+ export function renderInboxText(participants) {
60
+ const lines = (p) => p.texts.filter((text) => text.trim());
61
+ if (participants.length === 1)
62
+ return lines(participants[0]).join('\n');
63
+ return participants
64
+ .map((p) => ({ name: p.senderName?.trim() || p.senderId, texts: lines(p) }))
65
+ .filter((p) => p.texts.length > 0)
66
+ .map((p) => `${p.name}: ${p.texts.join('\n')}`)
67
+ .join('\n');
68
+ }
69
+ /** Q2: the principal of a merged batch is its first human sender. */
70
+ export function firstHumanPrincipal(batch) {
71
+ for (const mail of batch)
72
+ if (mail.from.kind === 'human')
73
+ return mail.from.principal;
74
+ return undefined;
75
+ }
76
+ /**
77
+ * Q2: the authority of a merged batch is the intersection of its mails'
78
+ * authorities (trust = lowest, tools / contacts / egress hosts = common,
79
+ * network only when everyone has it). Principal, approvers and chain stay
80
+ * those of the first human mail (approvals go to that principal), so the
81
+ * result never exceeds any sender's authority.
82
+ */
83
+ export function mergedAuthority(batch) {
84
+ if (batch.length === 0)
85
+ return undefined;
86
+ const lead = batch.find((mail) => mail.from.kind === 'human') ?? batch[0];
87
+ const all = batch.map((mail) => mail.authority);
88
+ const base = lead.authority;
89
+ return Object.freeze({
90
+ principal: base.principal,
91
+ trust: minTrust(...all.map((a) => a.trust)),
92
+ tools: all.reduce((scope, a) => intersectScopes(scope, a.tools), base.tools),
93
+ contacts: all.reduce((scope, a) => intersectScopes(scope, a.contacts), base.contacts),
94
+ egress: Object.freeze({
95
+ network: all.every((a) => a.egress.network),
96
+ hosts: all.reduce((scope, a) => intersectScopes(scope, a.egress.hosts), base.egress.hosts),
97
+ }),
98
+ approvers: base.approvers,
99
+ chain: base.chain,
100
+ });
101
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@zhin.js/harness",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.1",
4
+ "description": "ADR 0048 Agent Harness: actors, mail, engagements, attenuated authority, budgets and the guard kernel",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "import": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "README.md"
17
+ ],
18
+ "devDependencies": {
19
+ "@types/node": "^26.1.2",
20
+ "typescript": "^6.0.3",
21
+ "@zhin.js/database": "1.1.7"
22
+ },
23
+ "engines": {
24
+ "node": ">=22.19.0"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/zhinjs/zhin.git",
29
+ "directory": "packages/im/harness"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org"
34
+ },
35
+ "license": "MIT",
36
+ "private": false,
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "clean": "rimraf lib",
40
+ "test": "vitest run --root ../../.. packages/im/harness/tests"
41
+ }
6
42
  }