pi-roundtable-web 0.26.0 → 0.26.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.
- package/CHANGELOG.md +39 -0
- package/README.md +23 -7
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/{chunk-action-menu-rm5cyqkj.js → chunk-action-menu-me0g9qsc.js} +1 -1
- package/dist/{chunk-agent-b017vswv.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-hrm06nx8.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/{chunk-conversations-e26rrf01.js → chunk-conversations-0e0vbxwr.js} +1 -1
- package/dist/{chunk-drag-v41gx2mc.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-jxxb3ath.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/{chunk-gate-8k61stsc.js → chunk-gate-hc7dd0fx.js} +1 -1
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-2d0kg096.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/{chunk-index-d1prgfbp.js → chunk-index-b6ca02e8.js} +2 -2
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-014rjv9k.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-y8rnq8zf.js → chunk-index-dt9775ka.js} +3 -3
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/{chunk-index-xgkd15n3.js → chunk-index-gr6e0hse.js} +1 -1
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-wnjp6wv0.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/{chunk-index-c07p8k0x.js → chunk-index-kwcx62wb.js} +3 -3
- package/dist/{chunk-index-hby0ap91.js → chunk-index-m7fvw92f.js} +2 -2
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-rhtvcbsq.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-2stynxw6.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-km40bhsz.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/{chunk-index-hssjbr1k.js → chunk-index-snzzfhk3.js} +2 -2
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/{chunk-index-awx6nnta.js → chunk-index-vcyzwtk2.js} +1 -1
- package/dist/{chunk-index-nqfkwkfp.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/{chunk-index-e5sd30xy.js → chunk-index-w31c4yjv.js} +2 -2
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/{chunk-index-dhpt8qr9.js → chunk-index-yzsgsvnp.js} +2 -2
- package/dist/{chunk-index-66nskrdj.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-hrav55t7.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-x9ahv6e5.js → chunk-lang-go-wjwgay4v.js} +2 -2
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-hsemyd0r.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/{chunk-lang-markdown-zkwb4c9n.js → chunk-lang-markdown-b6qbe8bt.js} +5 -5
- package/dist/{chunk-lang-python-tewx7006.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-0ydx84s1.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-gq1tmdz4.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-xt1qyycg.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-0hamc5fc.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-wf6h5pt2.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-7vk521h1.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-1n9qargb.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/{chunk-notes-t15bh04m.js → chunk-notes-yb8f057c.js} +1 -1
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/{chunk-overview-km5vq375.js → chunk-overview-avc2d30t.js} +1 -1
- package/dist/{chunk-palette-a8txdgd0.js → chunk-palette-9nhp95bz.js} +1 -1
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/{chunk-prompt-history-wm9dsy6s.js → chunk-prompt-history-7enzp2p3.js} +1 -1
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/{chunk-room-menu-2tm2xtfx.js → chunk-room-menu-rr6nzap1.js} +1 -1
- package/dist/{chunk-rooms-menu-button-wxnsxjwc.js → chunk-rooms-menu-button-zbtwj47d.js} +1 -1
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-d3npdtx1.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/{chunk-skills-shkdwes8.js → chunk-skills-tnr1h16h.js} +1 -1
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-n26b7gd0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-8aysyf56.js → chunk-table3d-4x2n2b00.js} +20 -20
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +2 -2
- package/src/api-types.ts +58 -3
- package/src/app/app.ts +5 -0
- package/src/app/room-log.ts +16 -4
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +44 -2
- package/src/app/socket.ts +45 -9
- package/src/app/threads.ts +31 -2
- package/src/auth/authenticator.ts +10 -4
- package/src/auth/login-page.ts +4 -2
- package/src/auth/oidc-pages.ts +2 -0
- package/src/auth/oidc.ts +39 -1
- package/src/authz.ts +2 -1
- package/src/console-server.ts +32 -6
- package/src/host/cards.ts +18 -5
- package/src/host/context.ts +72 -25
- package/src/host/forms.ts +3 -0
- package/src/host/frames.ts +8 -1
- package/src/host/host-app.ts +6 -4
- package/src/host/host-routes.ts +36 -3
- package/src/host/room-views.ts +6 -6
- package/src/host/rooms-service.ts +16 -1
- package/src/host/views.ts +3 -0
- package/src/i18n/zh-tw.ts +9 -2
- package/src/index.ts +15 -1
- package/src/options.ts +36 -0
- package/src/page-presentation.ts +47 -2
- package/src/pwa.ts +32 -4
- package/src/web-app.ts +47 -3
- package/src/web-plugin.ts +20 -9
- package/dist/chunk-account-hw49gd0d.js +0 -1
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +0 -1
- package/dist/chunk-character-creator-5dxcvd16.js +0 -1
- package/dist/chunk-codeeditor-pyt09fwg.js +0 -2
- package/dist/chunk-confirm-521yqmta.js +0 -1
- package/dist/chunk-folder-list-b97xk12b.js +0 -1
- package/dist/chunk-folds-b8q7eags.js +0 -1
- package/dist/chunk-group-scene-dx6wt19m.js +0 -1
- package/dist/chunk-group-table-e4jr8r3a.js +0 -1
- package/dist/chunk-host-state-g7e7c0k5.js +0 -1
- package/dist/chunk-host-ui-x7k6n07v.js +0 -939
- package/dist/chunk-index-2qd81wbx.js +0 -2
- package/dist/chunk-index-459drsft.js +0 -2
- package/dist/chunk-index-4mfj596y.js +0 -2
- package/dist/chunk-index-bbsytycb.js +0 -2
- package/dist/chunk-index-cq1e9xdz.js +0 -2
- package/dist/chunk-index-g351921r.js +0 -1
- package/dist/chunk-index-k2qq1gdj.js +0 -2
- package/dist/chunk-index-ke9ynm0n.js +0 -2
- package/dist/chunk-index-kpscvg85.js +0 -2
- package/dist/chunk-index-m0d100mg.js +0 -2
- package/dist/chunk-index-mb72snk9.js +0 -2
- package/dist/chunk-index-mjqqver5.js +0 -4
- package/dist/chunk-index-nwqk447w.js +0 -2
- package/dist/chunk-index-pcfhpm8v.js +0 -302
- package/dist/chunk-index-q8h8xzbm.js +0 -2
- package/dist/chunk-index-r5vszgfs.js +0 -2
- package/dist/chunk-index-rjv7vrwd.js +0 -13
- package/dist/chunk-index-v23m38p6.js +0 -50
- package/dist/chunk-index-va0g7d88.js +0 -1
- package/dist/chunk-index-z3k18atc.js +0 -2
- package/dist/chunk-lang-css-6bepqpnx.js +0 -1
- package/dist/chunk-lang-html-8jc72zt4.js +0 -1
- package/dist/chunk-lang-javascript-2p2qtbnf.js +0 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +0 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +0 -1
- package/dist/chunk-notice-bell-w3y0h0sr.js +0 -1
- package/dist/chunk-notices-fa3g7qze.js +0 -1
- package/dist/chunk-onboarding-34v3nr17.js +0 -1
- package/dist/chunk-placement-items-wgaxsbvs.js +0 -1
- package/dist/chunk-profile-ypxdbsk1.js +0 -1
- package/dist/chunk-prompts-7mhhq7zs.js +0 -1
- package/dist/chunk-room-confirms-t11a1gsw.js +0 -1
- package/dist/chunk-schedules-2ft6xxyw.js +0 -1
- package/dist/chunk-sheet-hosts-vngvp7wv.js +0 -1
- package/dist/chunk-skill-editor-65n2hsq4.js +0 -8
- package/dist/chunk-switcher-list-g6hhmr1t.js +0 -1
- package/dist/chunk-table-1b6bsq2y.js +0 -1
- package/dist/chunk-table-seats-vy1qhdks.js +0 -1
- package/dist/chunk-transcript-355faceq.js +0 -1
- package/dist/chunk-where-j5qdt8a4.js +0 -1
- package/dist/chunk-workspace-vrbs6zx1.js +0 -1
- package/dist/chunk-zh-tw-zeg600bk.js +0 -1
package/src/app/routes.ts
CHANGED
|
@@ -19,11 +19,13 @@ import type {
|
|
|
19
19
|
CharacterRefusal,
|
|
20
20
|
MessagesView,
|
|
21
21
|
ModelsView,
|
|
22
|
+
RoomOneView,
|
|
22
23
|
RoomsView,
|
|
23
24
|
SchedulesView,
|
|
24
25
|
ScheduleView,
|
|
25
26
|
SessionView,
|
|
26
27
|
SkillView,
|
|
28
|
+
ThreadsDismissed,
|
|
27
29
|
WorkspaceConflict,
|
|
28
30
|
WorkspaceNew,
|
|
29
31
|
WorkspaceRefusalBody,
|
|
@@ -48,7 +50,7 @@ import type { LayoutStore } from "./layout.ts";
|
|
|
48
50
|
import { LayoutRoutes } from "./layout-routes.ts";
|
|
49
51
|
import { PromptApi, type PromptApiOptions } from "./prompt-routes.ts";
|
|
50
52
|
import { PAGE_LIMIT, type RoomLog } from "./room-log.ts";
|
|
51
|
-
import type { Rooms } from "./rooms.ts";
|
|
53
|
+
import type { ResolvedRoom, Rooms } from "./rooms.ts";
|
|
52
54
|
import { SkillDocApi } from "./skill-doc-routes.ts";
|
|
53
55
|
import type { AppSurface } from "./surface.ts";
|
|
54
56
|
import { TeamApi } from "./team-routes.ts";
|
|
@@ -75,7 +77,10 @@ export interface AppApiOptions {
|
|
|
75
77
|
/** The rooms whose push the owners muted. */
|
|
76
78
|
mutes: Pick<PushMutes, "set" | "setMany">;
|
|
77
79
|
/** The dispatch threads, which go with the room they hang under. */
|
|
78
|
-
threads: Pick<
|
|
80
|
+
threads: Pick<
|
|
81
|
+
WebThreads,
|
|
82
|
+
"under" | "removeUnder" | "dismiss" | "dismissFinished"
|
|
83
|
+
>;
|
|
79
84
|
/** The owners' shared arrangement of the room list: pins and folders. */
|
|
80
85
|
layout: LayoutStore;
|
|
81
86
|
conversations: Pick<ConversationPort, "startFresh">;
|
|
@@ -496,6 +501,16 @@ export class AppApi {
|
|
|
496
501
|
}
|
|
497
502
|
const room = await rooms.resolve(key);
|
|
498
503
|
if (!room) throw notFound();
|
|
504
|
+
if (!action && method === "GET") {
|
|
505
|
+
// A thread dismissed from the list still opens by its address; the page asks for its view.
|
|
506
|
+
const view = await rooms.threadView(room.key);
|
|
507
|
+
if (!view) throw notFound();
|
|
508
|
+
return json({ room: view } satisfies RoomOneView);
|
|
509
|
+
}
|
|
510
|
+
if (action === "dismiss" && method === "POST" && parts.length === 4)
|
|
511
|
+
return this.#dismiss(room, admission);
|
|
512
|
+
if (action === "dismiss-threads" && method === "POST" && parts.length === 4)
|
|
513
|
+
return this.#dismissFinished(room, admission);
|
|
499
514
|
if (action === "messages" && method === "GET" && parts.length === 4) {
|
|
500
515
|
const params = new URL(request.url).searchParams;
|
|
501
516
|
const before = params.get("before");
|
|
@@ -573,6 +588,33 @@ export class AppApi {
|
|
|
573
588
|
throw new HttpError(404, "There is no such API.");
|
|
574
589
|
}
|
|
575
590
|
|
|
591
|
+
/** Takes one finished thread off the room list for every owner; an open one is refused. */
|
|
592
|
+
async #dismiss(
|
|
593
|
+
room: ResolvedRoom,
|
|
594
|
+
admission: Authenticated,
|
|
595
|
+
): Promise<Response> {
|
|
596
|
+
if (room.kind !== "thread") throw notFound();
|
|
597
|
+
await this.#owner(admission);
|
|
598
|
+
const outcome = await this.#options.threads.dismiss(room.name);
|
|
599
|
+
if (outcome === "missing") throw notFound();
|
|
600
|
+
if (outcome === "open")
|
|
601
|
+
throw new HttpError(409, "The thread is still open.");
|
|
602
|
+
this.#options.changed();
|
|
603
|
+
return json({ dismissed: 1 } satisfies ThreadsDismissed);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Takes every finished thread of a room off the room list; the open ones stay. */
|
|
607
|
+
async #dismissFinished(
|
|
608
|
+
room: ResolvedRoom,
|
|
609
|
+
admission: Authenticated,
|
|
610
|
+
): Promise<Response> {
|
|
611
|
+
if (room.kind === "thread") throw notFound();
|
|
612
|
+
await this.#owner(admission);
|
|
613
|
+
const dismissed = await this.#options.threads.dismissFinished(room.key);
|
|
614
|
+
if (dismissed) this.#options.changed();
|
|
615
|
+
return json({ dismissed } satisfies ThreadsDismissed);
|
|
616
|
+
}
|
|
617
|
+
|
|
576
618
|
async #file(id: string, admission: Authenticated): Promise<Response> {
|
|
577
619
|
if (!isFileId(id)) throw notFound();
|
|
578
620
|
const { files, host } = this.#options;
|
package/src/app/socket.ts
CHANGED
|
@@ -559,7 +559,9 @@ export class SocketRoute {
|
|
|
559
559
|
this.#refuse(connection, code, frame.id);
|
|
560
560
|
// The agent rooms are the owners', and they take only turns.
|
|
561
561
|
if (who.tier !== "owner") return refuse("forbidden");
|
|
562
|
-
|
|
562
|
+
// A note is for the other people who read the rooms; where nobody else does, it is no frame.
|
|
563
|
+
const note = frame.mode === "note";
|
|
564
|
+
if (note && !(await rooms.notesOn())) return refuse("bad_frame");
|
|
563
565
|
const room = await rooms.resolve(frame.room);
|
|
564
566
|
if (!room) return refuse("unknown_room");
|
|
565
567
|
if (room.readOnly) return refuse("read_only");
|
|
@@ -567,18 +569,22 @@ export class SocketRoute {
|
|
|
567
569
|
if (text.length > MAX_MESSAGE_CHARS) return refuse("too_long");
|
|
568
570
|
const ids = frame.uploads ?? [];
|
|
569
571
|
if (text === "" && ids.length === 0) return refuse("empty");
|
|
570
|
-
// Without the actor the credential vouched for, the router cannot tell whose message it is
|
|
571
|
-
|
|
572
|
+
// Without the actor the credential vouched for, the router cannot tell whose message it is;
|
|
573
|
+
// a note goes to no router.
|
|
574
|
+
if (!note && !who.actor) return refuse("forbidden");
|
|
572
575
|
const files = uploads.claim(ids, who.principalId);
|
|
573
576
|
if (!files) return refuse("unknown_upload");
|
|
574
577
|
if (!surface.started) return refuse("not_accepted");
|
|
575
578
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
579
|
+
if (note)
|
|
580
|
+
await this.#note(connection, who, room.key, frame.id, text, files);
|
|
581
|
+
else
|
|
582
|
+
await this.say(room.key, who, text, files, connection, (message) =>
|
|
583
|
+
sockets.deliver(
|
|
584
|
+
{ type: "accepted", id: frame.id, room: room.key, message },
|
|
585
|
+
{ connection },
|
|
586
|
+
),
|
|
587
|
+
);
|
|
582
588
|
} catch (error) {
|
|
583
589
|
logger.warn(
|
|
584
590
|
{ room: room.key, err: error },
|
|
@@ -588,6 +594,36 @@ export class SocketRoute {
|
|
|
588
594
|
}
|
|
589
595
|
}
|
|
590
596
|
|
|
597
|
+
/**
|
|
598
|
+
* Keeps an owner's internal note in a room: stored and shown to the other readers, and given to
|
|
599
|
+
* no one else. It does not reach the host, so the agent runs no turn, the note is not in its
|
|
600
|
+
* conversation, and nothing about the room reads as asked.
|
|
601
|
+
*/
|
|
602
|
+
async #note(
|
|
603
|
+
connection: AppSocket,
|
|
604
|
+
who: Answerer,
|
|
605
|
+
room: ChannelKey,
|
|
606
|
+
id: string,
|
|
607
|
+
text: string,
|
|
608
|
+
files: readonly RoomFile[],
|
|
609
|
+
): Promise<void> {
|
|
610
|
+
const { surface, uploads, sockets } = this.#options;
|
|
611
|
+
const message = await surface.publish(
|
|
612
|
+
{
|
|
613
|
+
room,
|
|
614
|
+
kind: "owner",
|
|
615
|
+
principalId: who.principalId,
|
|
616
|
+
author: who.name,
|
|
617
|
+
text,
|
|
618
|
+
files: [...files],
|
|
619
|
+
note: true,
|
|
620
|
+
},
|
|
621
|
+
connection,
|
|
622
|
+
);
|
|
623
|
+
uploads.use(files.map((file) => file.id));
|
|
624
|
+
sockets.deliver({ type: "accepted", id, room, message }, { connection });
|
|
625
|
+
}
|
|
626
|
+
|
|
591
627
|
/**
|
|
592
628
|
* Takes a message of the owner into a room: stores it, shows it to the other sockets, and
|
|
593
629
|
* hands it to the host as the owner's. `files` are uploads the sender claimed, which move to
|
package/src/app/threads.ts
CHANGED
|
@@ -60,6 +60,15 @@ export class WebThreads {
|
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
/** The mark of a finished thread the owners took off the room list; the thread itself stays. */
|
|
64
|
+
static readonly dismissedMigration: Migration = {
|
|
65
|
+
name: "web-threads-dismissed",
|
|
66
|
+
up: async (sql) => {
|
|
67
|
+
await sql`
|
|
68
|
+
ALTER TABLE web_threads ADD COLUMN IF NOT EXISTS dismissed_at timestamptz`;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
63
72
|
/** Starts a thread under `parent`. */
|
|
64
73
|
async open(parent: string, title: string): Promise<WebThread> {
|
|
65
74
|
const id = `thread.${crypto.randomUUID()}`;
|
|
@@ -76,6 +85,26 @@ export class WebThreads {
|
|
|
76
85
|
await this.#sql`UPDATE web_threads SET open = false WHERE id = ${id}`;
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Takes a finished thread off the room list. It stays readable by its address, and asking again
|
|
90
|
+
* changes nothing. An open thread is refused: `open` says so, `missing` that there is none.
|
|
91
|
+
*/
|
|
92
|
+
async dismiss(id: string): Promise<"dismissed" | "open" | "missing"> {
|
|
93
|
+
const rows: unknown[] = await this.#sql`
|
|
94
|
+
UPDATE web_threads SET dismissed_at = COALESCE(dismissed_at, now())
|
|
95
|
+
WHERE id = ${id} AND NOT open RETURNING 1`;
|
|
96
|
+
if (rows.length) return "dismissed";
|
|
97
|
+
return (await this.get(id)) ? "open" : "missing";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Dismisses every finished thread under `parent` that is still listed; returns how many. */
|
|
101
|
+
async dismissFinished(parent: string): Promise<number> {
|
|
102
|
+
const rows: unknown[] = await this.#sql`
|
|
103
|
+
UPDATE web_threads SET dismissed_at = now()
|
|
104
|
+
WHERE parent = ${parent} AND NOT open AND dismissed_at IS NULL RETURNING 1`;
|
|
105
|
+
return rows.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
async get(id: string): Promise<WebThread | undefined> {
|
|
80
109
|
const [row]: Row[] = await this
|
|
81
110
|
.#sql`SELECT * FROM web_threads WHERE id = ${id}`;
|
|
@@ -98,14 +127,14 @@ export class WebThreads {
|
|
|
98
127
|
|
|
99
128
|
/**
|
|
100
129
|
* The threads the room list shows, newest first: every open one, and the `closedLimit` newest
|
|
101
|
-
* closed ones
|
|
130
|
+
* closed ones that were not dismissed. The rest stay readable but are not kept in view.
|
|
102
131
|
*/
|
|
103
132
|
async list(closedLimit = CLOSED_LISTED): Promise<WebThread[]> {
|
|
104
133
|
const rows: Row[] = await this.#sql`
|
|
105
134
|
SELECT * FROM (
|
|
106
135
|
SELECT * FROM web_threads WHERE open
|
|
107
136
|
UNION ALL
|
|
108
|
-
(SELECT * FROM web_threads WHERE NOT open
|
|
137
|
+
(SELECT * FROM web_threads WHERE NOT open AND dismissed_at IS NULL
|
|
109
138
|
ORDER BY created_at DESC, id LIMIT ${closedLimit})
|
|
110
139
|
) shown ORDER BY created_at DESC, id`;
|
|
111
140
|
return rows.map(threadOf);
|
|
@@ -78,7 +78,7 @@ export interface AuthenticatorOptions {
|
|
|
78
78
|
listener: { id: string; address: Readonly<ListenerAddress> | undefined };
|
|
79
79
|
/** The sign-in flow and the session store, for `oidc`. */
|
|
80
80
|
oidc?: {
|
|
81
|
-
login: Pick<OidcLogin, "open" | "credential">;
|
|
81
|
+
login: Pick<OidcLogin, "open" | "credential" | "verified">;
|
|
82
82
|
sessions: Pick<WebSessions, "alive">;
|
|
83
83
|
};
|
|
84
84
|
/** Where the password is read from; default `process.env`. */
|
|
@@ -205,6 +205,8 @@ export function createAuthenticator(
|
|
|
205
205
|
)
|
|
206
206
|
return denied("foreign origin");
|
|
207
207
|
const { session } = checked as Admitted;
|
|
208
|
+
if (session)
|
|
209
|
+
options.oidc?.login.verified(session, identified.visitor.principal.id);
|
|
208
210
|
return {
|
|
209
211
|
visitor: identified.visitor,
|
|
210
212
|
...(checked.actor ? { actor: checked.actor } : {}),
|
|
@@ -222,9 +224,13 @@ export function createAuthenticator(
|
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
226
|
const identified = await identify(held.actor);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
if ("refusal" in identified) return denied(String(identified.refusal));
|
|
228
|
+
if (held.session)
|
|
229
|
+
options.oidc?.login.verified(
|
|
230
|
+
held.session,
|
|
231
|
+
identified.visitor.principal.id,
|
|
232
|
+
);
|
|
233
|
+
return { visitor: identified.visitor };
|
|
228
234
|
},
|
|
229
235
|
};
|
|
230
236
|
}
|
package/src/auth/login-page.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type ConsolePresentation, message } from "../features.ts";
|
|
2
2
|
import { escapeHtml } from "../http.ts";
|
|
3
|
+
import { iconLinks } from "../page-presentation.ts";
|
|
3
4
|
|
|
4
|
-
/** The login page's CSP: no script at all, one inline style, and the form posts only to this origin. */
|
|
5
|
+
/** The login page's CSP: no script at all, one inline style, its own icon, and the form posts only to this origin. */
|
|
5
6
|
export const LOGIN_CSP =
|
|
6
|
-
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
7
|
+
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
|
|
7
8
|
|
|
8
9
|
/** The login page, as one server-rendered document with no script; `note` is a line above the field. */
|
|
9
10
|
export function loginPage(
|
|
@@ -19,6 +20,7 @@ export function loginPage(
|
|
|
19
20
|
<meta charset="utf-8">
|
|
20
21
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
21
22
|
<meta name="robots" content="noindex">
|
|
23
|
+
${iconLinks(mount, false)}
|
|
22
24
|
<title>${escapeHtml(title)}</title>
|
|
23
25
|
<style>
|
|
24
26
|
body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
|
package/src/auth/oidc-pages.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ConsolePresentation, message } from "../features.ts";
|
|
2
2
|
import { escapeHtml } from "../http.ts";
|
|
3
|
+
import { iconLinks } from "../page-presentation.ts";
|
|
3
4
|
import { LOGIN_CSP } from "./login-page.ts";
|
|
4
5
|
|
|
5
6
|
/** What a page of the sign-in flow tells, and the link it offers. */
|
|
@@ -30,6 +31,7 @@ export function oidcPage(
|
|
|
30
31
|
<meta charset="utf-8">
|
|
31
32
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
32
33
|
<meta name="robots" content="noindex">
|
|
34
|
+
${iconLinks(mount, false)}
|
|
33
35
|
<title>${escapeHtml(title)}</title>
|
|
34
36
|
<style>
|
|
35
37
|
body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
|
package/src/auth/oidc.ts
CHANGED
|
@@ -27,6 +27,10 @@ const MAX_NAME_CHARS = 100;
|
|
|
27
27
|
const MAX_SUBJECT_CHARS = 256;
|
|
28
28
|
/** The most characters of one stored claim, as JSON. */
|
|
29
29
|
const MAX_CLAIM_CHARS = 2000;
|
|
30
|
+
/** How seldom a live session is reported again to `onSessionVerified`. */
|
|
31
|
+
const VERIFIED_EVERY_MS = 300_000;
|
|
32
|
+
/** The most sessions whose last report is remembered before the lapsed ones are dropped. */
|
|
33
|
+
const VERIFIED_PRUNE_AT = 1024;
|
|
30
34
|
/** Answers to a silent request that mean "the person must be asked", not "something is wrong". */
|
|
31
35
|
const INTERACTION_NEEDED = new Set([
|
|
32
36
|
"login_required",
|
|
@@ -133,6 +137,8 @@ export interface OidcLoginOptions {
|
|
|
133
137
|
onSignIn?(info: SignedIn): void | Promise<void>;
|
|
134
138
|
/** A session ended (logout), so the sockets on it can close. */
|
|
135
139
|
onEnded?(sessionId: string): void;
|
|
140
|
+
/** A request or an open socket proved a live session of this person: the host's `onSessionVerified`. */
|
|
141
|
+
onSessionVerified?(principalId: string): void | Promise<void>;
|
|
136
142
|
logger: Logger;
|
|
137
143
|
/** The provider's signing keys; the published ones by default. */
|
|
138
144
|
keys?: JWTVerifyGetKey;
|
|
@@ -210,6 +216,8 @@ export class OidcLogin {
|
|
|
210
216
|
readonly #outCookie = "__Secure-rt_out";
|
|
211
217
|
/** States already used, until they would have lapsed. */
|
|
212
218
|
readonly #spent = new Map<string, number>();
|
|
219
|
+
/** When each session was last reported to `onSessionVerified`, by session id. */
|
|
220
|
+
readonly #verified = new Map<string, number>();
|
|
213
221
|
|
|
214
222
|
constructor(options: OidcLoginOptions) {
|
|
215
223
|
this.#options = options;
|
|
@@ -576,6 +584,33 @@ export class OidcLogin {
|
|
|
576
584
|
return { admitted: true, actor: facts, session: session.id };
|
|
577
585
|
};
|
|
578
586
|
|
|
587
|
+
/**
|
|
588
|
+
* Reports that a request or an open socket proved this session live, so the host may count the
|
|
589
|
+
* person as present. At most once per session every few minutes; never waits for the hook and
|
|
590
|
+
* never throws: a hook that fails is logged.
|
|
591
|
+
*/
|
|
592
|
+
verified(session: string, principalId: string): void {
|
|
593
|
+
const hook = this.#options.onSessionVerified;
|
|
594
|
+
if (!hook) return;
|
|
595
|
+
const now = this.#now();
|
|
596
|
+
const last = this.#verified.get(session);
|
|
597
|
+
if (last !== undefined && now - last < VERIFIED_EVERY_MS) return;
|
|
598
|
+
if (this.#verified.size >= VERIFIED_PRUNE_AT)
|
|
599
|
+
for (const [id, at] of this.#verified)
|
|
600
|
+
if (now - at >= VERIFIED_EVERY_MS) this.#verified.delete(id);
|
|
601
|
+
this.#verified.set(session, now);
|
|
602
|
+
const failed = (cause: unknown) =>
|
|
603
|
+
this.#options.logger.warn(
|
|
604
|
+
{ err: cause },
|
|
605
|
+
"web-console: the session-verified hook failed",
|
|
606
|
+
);
|
|
607
|
+
try {
|
|
608
|
+
Promise.resolve(hook(principalId)).catch(failed);
|
|
609
|
+
} catch (cause) {
|
|
610
|
+
failed(cause);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
579
614
|
async #logout(request: Request): Promise<Response> {
|
|
580
615
|
const { mount, origin, settings, metadata } = this.#options;
|
|
581
616
|
if (request.headers.get("origin") !== origin)
|
|
@@ -589,7 +624,10 @@ export class OidcLogin {
|
|
|
589
624
|
);
|
|
590
625
|
if (token) {
|
|
591
626
|
const id = await this.#options.sessions.end(token).catch(() => undefined);
|
|
592
|
-
if (id)
|
|
627
|
+
if (id) {
|
|
628
|
+
this.#verified.delete(id);
|
|
629
|
+
this.#options.onEnded?.(id);
|
|
630
|
+
}
|
|
593
631
|
}
|
|
594
632
|
const base = `${mount}/`;
|
|
595
633
|
let to = `${mount}/auth/signed-out`;
|
package/src/authz.ts
CHANGED
|
@@ -50,7 +50,8 @@ export const ROUTES: readonly Rule[] = [
|
|
|
50
50
|
row("*", "api/notes", "V"),
|
|
51
51
|
row("*", "api/notes/:id", "V"),
|
|
52
52
|
row("*", "api/principals", "V"),
|
|
53
|
-
// The agent rooms are the owners'. The route says 404, not 403, to anyone else.
|
|
53
|
+
// The agent rooms are the owners'. The route says 404, not 403, to anyone else. Their threads are under
|
|
54
|
+
// it too: a thread's view and `…/dismiss`, and `…/dismiss-threads` of its parent.
|
|
54
55
|
row("*", "api/rooms", "O", true),
|
|
55
56
|
row("*", "api/rooms/**", "O", true),
|
|
56
57
|
row("*", "api/folders/**", "O"),
|
package/src/console-server.ts
CHANGED
|
@@ -12,8 +12,12 @@ import type { ConsoleApi } from "./console-api.ts";
|
|
|
12
12
|
import { type ConsolePresentation, message } from "./features.ts";
|
|
13
13
|
import type { Gate } from "./gate.ts";
|
|
14
14
|
import { json } from "./http.ts";
|
|
15
|
-
import {
|
|
16
|
-
|
|
15
|
+
import {
|
|
16
|
+
defaultDescription,
|
|
17
|
+
type PageMeta,
|
|
18
|
+
presentPage,
|
|
19
|
+
} from "./page-presentation.ts";
|
|
20
|
+
import { hasMarkSvg, isPublicStatic, servePwa } from "./pwa.ts";
|
|
17
21
|
import type { Visitor } from "./visitors.ts";
|
|
18
22
|
import { viewerOf } from "./web-app.ts";
|
|
19
23
|
|
|
@@ -76,6 +80,10 @@ export interface ConsoleServerOptions {
|
|
|
76
80
|
/** Where the install icons come from; default the ones this package ships. */
|
|
77
81
|
iconsDir?: string;
|
|
78
82
|
look?: "modern" | "camelot";
|
|
83
|
+
/** The console's own origin, for the absolute `og:image` of a link preview. */
|
|
84
|
+
origin?: string;
|
|
85
|
+
/** What the page says about itself in a search result and a link preview; default derived from the title. */
|
|
86
|
+
description?: string;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
/**
|
|
@@ -89,6 +97,7 @@ export interface ConsoleServerOptions {
|
|
|
89
97
|
export class ConsoleServer {
|
|
90
98
|
readonly #options: ConsoleServerOptions;
|
|
91
99
|
readonly #base: string;
|
|
100
|
+
readonly #meta: PageMeta | undefined;
|
|
92
101
|
readonly #streams = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
93
102
|
#pending: ReturnType<typeof setTimeout> | undefined;
|
|
94
103
|
#ping: ReturnType<typeof setInterval> | undefined;
|
|
@@ -96,6 +105,16 @@ export class ConsoleServer {
|
|
|
96
105
|
constructor(options: ConsoleServerOptions) {
|
|
97
106
|
this.#options = options;
|
|
98
107
|
this.#base = `${options.mount}/`;
|
|
108
|
+
const title = options.title ?? "Roundtable";
|
|
109
|
+
this.#meta = options.origin
|
|
110
|
+
? {
|
|
111
|
+
title,
|
|
112
|
+
description: options.description ?? defaultDescription(title),
|
|
113
|
+
origin: options.origin,
|
|
114
|
+
mount: options.mount,
|
|
115
|
+
svg: hasMarkSvg(options.iconsDir),
|
|
116
|
+
}
|
|
117
|
+
: undefined;
|
|
99
118
|
options.subscribe(() => this.#changed());
|
|
100
119
|
}
|
|
101
120
|
|
|
@@ -157,6 +176,11 @@ export class ConsoleServer {
|
|
|
157
176
|
if (path !== this.#options.mount && !path.startsWith(this.#base))
|
|
158
177
|
return new Response("Not found", { status: 404 });
|
|
159
178
|
const rest = path.slice(this.#base.length);
|
|
179
|
+
// Static and the same for everyone: a sign-in page and a link preview show the icon without a session.
|
|
180
|
+
if (READ_METHODS.has(request.method) && isPublicStatic(rest)) {
|
|
181
|
+
const shown = servePwa(rest, this.#pwa());
|
|
182
|
+
if (shown) return shown;
|
|
183
|
+
}
|
|
160
184
|
const open = await this.#options.authenticator.open?.(request, rest);
|
|
161
185
|
if (open) return open;
|
|
162
186
|
const admission = await this.#options.authenticator.authenticate(request);
|
|
@@ -183,10 +207,7 @@ export class ConsoleServer {
|
|
|
183
207
|
);
|
|
184
208
|
if (!READ_METHODS.has(request.method))
|
|
185
209
|
return new Response("Method not allowed", { status: 405 });
|
|
186
|
-
const installable = servePwa(rest,
|
|
187
|
-
...this.#options,
|
|
188
|
-
title: this.#options.title ?? "Roundtable",
|
|
189
|
-
});
|
|
210
|
+
const installable = servePwa(rest, this.#pwa());
|
|
190
211
|
if (installable) return installable;
|
|
191
212
|
const asset = this.#options.assets.get(rest);
|
|
192
213
|
if (asset && rest !== "index.html" && rest !== "sw.js")
|
|
@@ -205,6 +226,7 @@ export class ConsoleServer {
|
|
|
205
226
|
this.#options.pathRouting ? this.#options.mount : undefined,
|
|
206
227
|
this.#options.presentation,
|
|
207
228
|
this.#options.look,
|
|
229
|
+
this.#meta,
|
|
208
230
|
),
|
|
209
231
|
{
|
|
210
232
|
headers: {
|
|
@@ -223,6 +245,10 @@ export class ConsoleServer {
|
|
|
223
245
|
return new Response("Not found", { status: 404 });
|
|
224
246
|
}
|
|
225
247
|
|
|
248
|
+
#pwa(): Parameters<typeof servePwa>[1] {
|
|
249
|
+
return { ...this.#options, title: this.#options.title ?? "Roundtable" };
|
|
250
|
+
}
|
|
251
|
+
|
|
226
252
|
async #gated(visitor: Visitor): Promise<boolean> {
|
|
227
253
|
const { gate } = this.#options;
|
|
228
254
|
if (!gate) return false;
|
package/src/host/cards.ts
CHANGED
|
@@ -49,11 +49,20 @@ export class HostCardLog {
|
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
/** The mark of a card the host keeps past its kind's retention (`HostCard.keep`). */
|
|
53
|
+
static readonly keepMigration: Migration = {
|
|
54
|
+
name: "web-host-cards-keep",
|
|
55
|
+
up: async (sql) => {
|
|
56
|
+
await sql`
|
|
57
|
+
ALTER TABLE web_host_cards ADD COLUMN IF NOT EXISTS keep boolean NOT NULL DEFAULT false`;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
52
61
|
async create(room: string, card: HostCard): Promise<StoredHostCard> {
|
|
53
62
|
const id = crypto.randomUUID();
|
|
54
63
|
const [row]: Row[] = await this.#sql`
|
|
55
|
-
INSERT INTO web_host_cards (id, room, card, state)
|
|
56
|
-
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null})
|
|
64
|
+
INSERT INTO web_host_cards (id, room, card, state, keep)
|
|
65
|
+
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
|
|
57
66
|
RETURNING *`;
|
|
58
67
|
if (!row) throw new Error("the card log stored nothing");
|
|
59
68
|
return cardOf(row);
|
|
@@ -67,7 +76,7 @@ export class HostCardLog {
|
|
|
67
76
|
): Promise<StoredHostCard | undefined> {
|
|
68
77
|
const [row]: Row[] = await this.#sql`
|
|
69
78
|
UPDATE web_host_cards
|
|
70
|
-
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, updated_at = now()
|
|
79
|
+
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
|
|
71
80
|
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
72
81
|
return row && cardOf(row);
|
|
73
82
|
}
|
|
@@ -85,11 +94,15 @@ export class HostCardLog {
|
|
|
85
94
|
return rows.map(cardOf);
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Deletes the cards of the rooms whose key starts with `prefix` that are older than `before`,
|
|
99
|
+
* except those the host marked `keep`; how many.
|
|
100
|
+
*/
|
|
89
101
|
async sweepKind(prefix: string, before: Date): Promise<number> {
|
|
90
102
|
const rows: unknown[] = await this.#sql`
|
|
91
103
|
DELETE FROM web_host_cards
|
|
92
|
-
WHERE starts_with(room, ${prefix}) AND created_at < ${before}
|
|
104
|
+
WHERE starts_with(room, ${prefix}) AND created_at < ${before}
|
|
105
|
+
AND NOT keep RETURNING 1`;
|
|
93
106
|
return rows.length;
|
|
94
107
|
}
|
|
95
108
|
|
package/src/host/context.ts
CHANGED
|
@@ -54,22 +54,46 @@ function sourceOf(message: RoomMessage): Source {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** What `WebApp.rooms.context` takes. */
|
|
58
|
+
export interface HostRoomContextOptions {
|
|
59
|
+
/** The most messages to return, newest kept; default 15, at most 100. */
|
|
60
|
+
limit?: number;
|
|
61
|
+
/** The most characters of one message before it is cut with `…`; default 500. */
|
|
62
|
+
messageChars?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The two ways a host room's recent messages are read. */
|
|
66
|
+
export interface HostRoomContext {
|
|
67
|
+
/** For a turn the room's message addressed: marks what it returns as handed over. */
|
|
68
|
+
read(
|
|
69
|
+
message: InboundMessage,
|
|
70
|
+
options?: ChannelContextOptions,
|
|
71
|
+
): Promise<ChannelContext | undefined>;
|
|
72
|
+
/** For a host with no addressing message: the same block, and nothing is marked handed over. */
|
|
73
|
+
peek(
|
|
74
|
+
room: string,
|
|
75
|
+
options?: HostRoomContextOptions,
|
|
76
|
+
): Promise<ChannelContext | undefined>;
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
/**
|
|
58
|
-
* The app surface's
|
|
80
|
+
* The app surface's channel context of a host room: what was said in it since the assistant
|
|
59
81
|
* last posted, before the message that addressed it, as the core's block of channel messages. An
|
|
60
82
|
* outsider's words read as `external`, a notice line is not in the channel as far as the context
|
|
61
83
|
* goes, and a message already handed to the conversation is not handed again. Anything but a host
|
|
62
|
-
* room has none: an agent room is one person's.
|
|
84
|
+
* room has none: an agent room is one person's. `peek` reads the same block for the end of the
|
|
85
|
+
* room and leaves the handed-over marks alone, so a retry gets the same.
|
|
63
86
|
*/
|
|
64
|
-
export function
|
|
87
|
+
export function hostRoomContext(
|
|
65
88
|
ctx: Pick<HostContext, "registry" | "log">,
|
|
66
|
-
):
|
|
67
|
-
message: InboundMessage,
|
|
68
|
-
options?: ChannelContextOptions,
|
|
69
|
-
) => Promise<ChannelContext | undefined> {
|
|
89
|
+
): HostRoomContext {
|
|
70
90
|
const seen = new Map<string, Set<string>>();
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
const build = async (
|
|
92
|
+
room: string,
|
|
93
|
+
addressed: number | undefined,
|
|
94
|
+
options: ChannelContextOptions,
|
|
95
|
+
consume: boolean,
|
|
96
|
+
): Promise<ChannelContext | undefined> => {
|
|
73
97
|
if (!ctx.registry.isHostRoom(room)) return undefined;
|
|
74
98
|
const fetch = positive("fetch", options.fetch ?? DEFAULT_FETCH, PAGE_LIMIT);
|
|
75
99
|
const keep = positive("keep", options.keep ?? DEFAULT_KEEP, 100);
|
|
@@ -78,12 +102,7 @@ export function hostChannelContext(
|
|
|
78
102
|
options.messageChars ?? DEFAULT_CHARS,
|
|
79
103
|
20_000,
|
|
80
104
|
);
|
|
81
|
-
const
|
|
82
|
-
const page = await ctx.log.page(
|
|
83
|
-
room,
|
|
84
|
-
addressed === undefined ? undefined : Number(addressed),
|
|
85
|
-
fetch,
|
|
86
|
-
);
|
|
105
|
+
const page = await ctx.log.page(room, addressed, fetch);
|
|
87
106
|
const sources = page.messages.map((one) => ({
|
|
88
107
|
source: sourceOf(one),
|
|
89
108
|
notice: authorKindOf(one) === "notice",
|
|
@@ -99,24 +118,26 @@ export function hostChannelContext(
|
|
|
99
118
|
const lastEnd = considered.findLastIndex(
|
|
100
119
|
(entry) => verdict(entry) === "end",
|
|
101
120
|
);
|
|
102
|
-
const known = seen.get(room) ?? new Set<string>();
|
|
121
|
+
const known = consume ? (seen.get(room) ?? new Set<string>()) : undefined;
|
|
103
122
|
const fresh = considered
|
|
104
123
|
.slice(lastEnd + 1)
|
|
105
124
|
.filter(
|
|
106
125
|
(entry) =>
|
|
107
126
|
verdict(entry) === "keep" &&
|
|
108
|
-
!known
|
|
127
|
+
!known?.has(entry.source.id) &&
|
|
109
128
|
entry.source.text !== "",
|
|
110
129
|
)
|
|
111
130
|
.slice(-keep);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
131
|
+
if (known) {
|
|
132
|
+
// What was in the window is not handed over again, kept or not.
|
|
133
|
+
for (const entry of considered.slice(lastEnd + 1))
|
|
134
|
+
known.add(entry.source.id);
|
|
135
|
+
seen.delete(room);
|
|
136
|
+
seen.set(room, new Set([...known].slice(-SEEN_PER_ROOM)));
|
|
137
|
+
if (seen.size > SEEN_ROOMS) {
|
|
138
|
+
const oldest = seen.keys().next().value;
|
|
139
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
140
|
+
}
|
|
120
141
|
}
|
|
121
142
|
if (fresh.length === 0) return undefined;
|
|
122
143
|
const messages: ChannelContextMessage[] = fresh.map(({ source }) => ({
|
|
@@ -138,4 +159,30 @@ export function hostChannelContext(
|
|
|
138
159
|
}));
|
|
139
160
|
return { messages, text: formatChannelContext(messages) };
|
|
140
161
|
};
|
|
162
|
+
return {
|
|
163
|
+
read(message, options = {}) {
|
|
164
|
+
const addressed = /^app-(\d+)$/.exec(message.messageId)?.[1];
|
|
165
|
+
return build(
|
|
166
|
+
message.channel,
|
|
167
|
+
addressed === undefined ? undefined : Number(addressed),
|
|
168
|
+
options,
|
|
169
|
+
true,
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
peek(room, options = {}) {
|
|
173
|
+
const limit = options.limit ?? DEFAULT_KEEP;
|
|
174
|
+
return build(
|
|
175
|
+
room,
|
|
176
|
+
undefined,
|
|
177
|
+
{
|
|
178
|
+
keep: limit,
|
|
179
|
+
fetch: Math.min(PAGE_LIMIT, Math.max(DEFAULT_FETCH, limit * 3)),
|
|
180
|
+
...(options.messageChars === undefined
|
|
181
|
+
? {}
|
|
182
|
+
: { messageChars: options.messageChars }),
|
|
183
|
+
},
|
|
184
|
+
false,
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
141
188
|
}
|