@sparkelf/dsh-group-chat 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/lib/client.js +239 -129
- package/lib/client.js.map +1 -1
- package/lib/index.js +431 -46
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -601,6 +601,173 @@ const textOfContent = (content) => {
|
|
|
601
601
|
if (!Array.isArray(content)) return "";
|
|
602
602
|
return content.filter((block) => typeof block === "object" && block !== null).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("");
|
|
603
603
|
};
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/session-id.ts
|
|
606
|
+
/**
|
|
607
|
+
* The room/session binding, shared by both halves of the plugin.
|
|
608
|
+
*
|
|
609
|
+
* The browser half needs it to open a room's Session; the host half needs it to create and
|
|
610
|
+
* recognize one. It lives in its own module because the two halves are built for different
|
|
611
|
+
* runtimes: importing it from the host entry would drag the whole server side into the client
|
|
612
|
+
* bundle, which the build's platform guard refuses — correctly, since none of it can run there.
|
|
613
|
+
*
|
|
614
|
+
* @module @sparkelf/dsh-group-chat/session-id
|
|
615
|
+
*/
|
|
616
|
+
/**
|
|
617
|
+
* Every room session id starts with this, so a room's session is recognizable after a restart
|
|
618
|
+
* and an ordinary session never matches.
|
|
619
|
+
*
|
|
620
|
+
* The binding lives in the id rather than in `SessionHeader` because the header carries no
|
|
621
|
+
* free-form metadata field.
|
|
622
|
+
*/
|
|
623
|
+
const ROOM_SESSION_PREFIX = "group-room-";
|
|
624
|
+
/** Deterministic session id for a room. */
|
|
625
|
+
const roomSessionId = (roomId) => `${ROOM_SESSION_PREFIX}${roomId}`;
|
|
626
|
+
/** Reads the room a session is bound to, or `undefined` for an ordinary session. */
|
|
627
|
+
const roomIdOfSession = (session) => {
|
|
628
|
+
if (typeof session !== "object" || session === null) return void 0;
|
|
629
|
+
const id = session.id;
|
|
630
|
+
if (typeof id !== "string" || !id.startsWith("group-room-")) return void 0;
|
|
631
|
+
const roomId = id.slice(11);
|
|
632
|
+
return roomId.length > 0 ? roomId : void 0;
|
|
633
|
+
};
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/session-maintenance.ts
|
|
636
|
+
/**
|
|
637
|
+
* Maintenance for the sessions a room owns in one workspace.
|
|
638
|
+
*
|
|
639
|
+
* A room has exactly one canonical Session, addressed by {@link roomSessionId}. Before
|
|
640
|
+
* this module existed, a host restart could instead leave behind throwaway
|
|
641
|
+
* `session-<uuid>` records in the room's project directory: the controller's
|
|
642
|
+
* `create({ sessionId })` mints a new Session whenever the named one has already
|
|
643
|
+
* started a turn, and the room code treated that minted identity as a failed open and
|
|
644
|
+
* retried on every discovery poll. The result was thousands of idle Session
|
|
645
|
+
* directories, all with the same `cwd`, which slowed every `session/list`.
|
|
646
|
+
*
|
|
647
|
+
* The cleanup endpoint therefore works on the *physical* project directory rather
|
|
648
|
+
* than on a plugin-owned index: legacy garbage was never indexed, and a workspace
|
|
649
|
+
* upgraded from an affected build must be able to find it. Candidate selection is
|
|
650
|
+
* intentionally narrow — only minted `session-<uuid>` records, only in the named
|
|
651
|
+
* directory, never the canonical room Session, never another room's Session, and
|
|
652
|
+
* never a subagent child. A cleanup run cannot delete an ordinary named Session or a
|
|
653
|
+
* conversation someone deliberately created.
|
|
654
|
+
*
|
|
655
|
+
* @module @sparkelf/dsh-group-chat/session-maintenance
|
|
656
|
+
*/
|
|
657
|
+
/**
|
|
658
|
+
* A Session identity minted by the controller for a conversation that asked for a
|
|
659
|
+
* different named id. Only this shape is ever a cleanup candidate.
|
|
660
|
+
*/
|
|
661
|
+
const isMintedSessionId = (sessionId) => /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(sessionId);
|
|
662
|
+
/** Whether a stored Session is a throwaway record this room's cleanup may touch. */
|
|
663
|
+
const isRoomSubSession = (record, directory, primarySessionId, protectedSessionIds = /* @__PURE__ */ new Set()) => {
|
|
664
|
+
const header = record.header;
|
|
665
|
+
if (header.cwd !== directory) return false;
|
|
666
|
+
if (header.id === primarySessionId) return false;
|
|
667
|
+
if (protectedSessionIds.has(header.id)) return false;
|
|
668
|
+
if (header.id.startsWith("group-room-")) return false;
|
|
669
|
+
if (header.origin === "subagent" || header.parentSession !== void 0) return false;
|
|
670
|
+
return isMintedSessionId(header.id);
|
|
671
|
+
};
|
|
672
|
+
/**
|
|
673
|
+
* The JSONL backend's readable project directory name.
|
|
674
|
+
*
|
|
675
|
+
* Mirrors the storage layout documented by `dsh-session-persistence-jsonl`: separators
|
|
676
|
+
* collapse to one `-`, unsafe code units use `~XXXX`, and the result is bounded for
|
|
677
|
+
* filesystem component limits. The plugin needs the physical directory only to purge;
|
|
678
|
+
* a backend whose layout differs must keep its `root` unset so purge is refused.
|
|
679
|
+
*/
|
|
680
|
+
const roomProjectKey = (cwd) => {
|
|
681
|
+
if (cwd.length === 0) throw new Error("cannot encode an empty project path");
|
|
682
|
+
let readable = "";
|
|
683
|
+
let separatorRun = false;
|
|
684
|
+
for (let index = 0; index < cwd.length; index += 1) {
|
|
685
|
+
const code = cwd.charCodeAt(index);
|
|
686
|
+
const character = String.fromCharCode(code);
|
|
687
|
+
if (character === "/" || character === "\\" || character === ":") {
|
|
688
|
+
if (!separatorRun) readable += "-";
|
|
689
|
+
separatorRun = true;
|
|
690
|
+
} else if (character !== "~" && /^[A-Za-z0-9._-]$/u.test(character)) {
|
|
691
|
+
readable += character;
|
|
692
|
+
separatorRun = false;
|
|
693
|
+
} else {
|
|
694
|
+
readable += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
695
|
+
separatorRun = false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return `--${(readable.replace(/^-+/u, "") || "root").slice(0, 251)}--`;
|
|
699
|
+
};
|
|
700
|
+
/** The JSONL backend's escaped single path segment. */
|
|
701
|
+
const sessionIdSegment = (sessionId) => {
|
|
702
|
+
if (sessionId.length === 0) throw new Error("cannot encode an empty session id");
|
|
703
|
+
if (sessionId === ".") return "~002E";
|
|
704
|
+
if (sessionId === "..") return "~002E~002E";
|
|
705
|
+
let out = "";
|
|
706
|
+
for (let index = 0; index < sessionId.length; index += 1) {
|
|
707
|
+
const code = sessionId.charCodeAt(index);
|
|
708
|
+
const character = String.fromCharCode(code);
|
|
709
|
+
if (character !== "~" && /^[A-Za-z0-9._-]$/u.test(character)) out += character;
|
|
710
|
+
else out += `~${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
711
|
+
}
|
|
712
|
+
return out;
|
|
713
|
+
};
|
|
714
|
+
/** The physical directory one stored Session owns, validated to stay under `root`. */
|
|
715
|
+
const roomSessionDirectory = (root, directory, sessionId) => {
|
|
716
|
+
const rootPath = resolve(root);
|
|
717
|
+
const target = resolve(join(rootPath, roomProjectKey(directory), sessionIdSegment(sessionId)));
|
|
718
|
+
if (target === rootPath || !target.startsWith(`${rootPath}${sep}`)) throw new Error(`session directory escapes the configured root: ${sessionId}`);
|
|
719
|
+
return target;
|
|
720
|
+
};
|
|
721
|
+
/**
|
|
722
|
+
* Find and, when asked, archive or delete a room's throwaway Sessions.
|
|
723
|
+
*
|
|
724
|
+
* `dryRun` defaults to true and `purge` to false, so the safe call reports before it
|
|
725
|
+
* changes anything. An archive run writes DSH's official archive marker, which removes
|
|
726
|
+
* the Sessions from grouping surfaces without touching their files; a purge run removes
|
|
727
|
+
* the physical directories and leaves the canonical room Session alone.
|
|
728
|
+
*/
|
|
729
|
+
const cleanupRoomSessions = async (input) => {
|
|
730
|
+
const dryRun = input.dryRun !== false;
|
|
731
|
+
const purge = !dryRun && input.purge === true;
|
|
732
|
+
const mode = dryRun ? "report" : purge ? "purge" : "archive";
|
|
733
|
+
if (purge && (input.persistence.root === void 0 || input.persistence.root.length === 0)) throw new Error("the session backend exposes no physical root; purge is unavailable");
|
|
734
|
+
const protectedIds = new Set(input.protectedSessionIds ?? []);
|
|
735
|
+
const records = await input.persistence.list();
|
|
736
|
+
const candidates = records.filter((record) => isRoomSubSession(record, input.directory, input.primarySessionId, protectedIds)).map((record) => record.header.id);
|
|
737
|
+
if (mode === "archive" && input.archive === void 0) throw new Error("the workspace registry exposes no archive marker; archive is unavailable");
|
|
738
|
+
const archived = [];
|
|
739
|
+
const removed = [];
|
|
740
|
+
const errors = [];
|
|
741
|
+
if (mode !== "report") for (const sessionId of candidates) try {
|
|
742
|
+
if (mode === "archive") {
|
|
743
|
+
await input.archive?.archiveSession(sessionId, { stopActivity: true });
|
|
744
|
+
archived.push(sessionId);
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const target = roomSessionDirectory(input.persistence.root, input.directory, sessionId);
|
|
748
|
+
await rm(target, {
|
|
749
|
+
recursive: true,
|
|
750
|
+
force: true
|
|
751
|
+
});
|
|
752
|
+
removed.push(sessionId);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
errors.push({
|
|
755
|
+
sessionId,
|
|
756
|
+
message: error instanceof Error ? error.message : String(error)
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
roomId: input.roomId,
|
|
761
|
+
directory: input.directory,
|
|
762
|
+
primarySessionId: input.primarySessionId,
|
|
763
|
+
mode,
|
|
764
|
+
scanned: records.length,
|
|
765
|
+
candidates,
|
|
766
|
+
archived,
|
|
767
|
+
removed,
|
|
768
|
+
errors
|
|
769
|
+
};
|
|
770
|
+
};
|
|
604
771
|
/**
|
|
605
772
|
* Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
|
|
606
773
|
*
|
|
@@ -8841,36 +9008,6 @@ const openRoomStream = (options) => {
|
|
|
8841
9008
|
};
|
|
8842
9009
|
};
|
|
8843
9010
|
//#endregion
|
|
8844
|
-
//#region src/session-id.ts
|
|
8845
|
-
/**
|
|
8846
|
-
* The room/session binding, shared by both halves of the plugin.
|
|
8847
|
-
*
|
|
8848
|
-
* The browser half needs it to open a room's Session; the host half needs it to create and
|
|
8849
|
-
* recognize one. It lives in its own module because the two halves are built for different
|
|
8850
|
-
* runtimes: importing it from the host entry would drag the whole server side into the client
|
|
8851
|
-
* bundle, which the build's platform guard refuses — correctly, since none of it can run there.
|
|
8852
|
-
*
|
|
8853
|
-
* @module @sparkelf/dsh-group-chat/session-id
|
|
8854
|
-
*/
|
|
8855
|
-
/**
|
|
8856
|
-
* Every room session id starts with this, so a room's session is recognizable after a restart
|
|
8857
|
-
* and an ordinary session never matches.
|
|
8858
|
-
*
|
|
8859
|
-
* The binding lives in the id rather than in `SessionHeader` because the header carries no
|
|
8860
|
-
* free-form metadata field.
|
|
8861
|
-
*/
|
|
8862
|
-
const ROOM_SESSION_PREFIX = "group-room-";
|
|
8863
|
-
/** Deterministic session id for a room. */
|
|
8864
|
-
const roomSessionId = (roomId) => `${ROOM_SESSION_PREFIX}${roomId}`;
|
|
8865
|
-
/** Reads the room a session is bound to, or `undefined` for an ordinary session. */
|
|
8866
|
-
const roomIdOfSession = (session) => {
|
|
8867
|
-
if (typeof session !== "object" || session === null) return void 0;
|
|
8868
|
-
const id = session.id;
|
|
8869
|
-
if (typeof id !== "string" || !id.startsWith("group-room-")) return void 0;
|
|
8870
|
-
const roomId = id.slice(11);
|
|
8871
|
-
return roomId.length > 0 ? roomId : void 0;
|
|
8872
|
-
};
|
|
8873
|
-
//#endregion
|
|
8874
9011
|
//#region src/index.ts
|
|
8875
9012
|
const name = "group-chat";
|
|
8876
9013
|
/**
|
|
@@ -8987,8 +9124,99 @@ const modelKeyOf = (room) => [
|
|
|
8987
9124
|
room.model ?? "",
|
|
8988
9125
|
room.reasoningEffort ?? ""
|
|
8989
9126
|
].join("|");
|
|
9127
|
+
/** Whether two selections address the same route and effort. */
|
|
9128
|
+
const sameModelSelection = (left, right) => left.provider === right.provider && left.model === right.model && (left.reasoningEffort ?? "") === (right.reasoningEffort ?? "");
|
|
9129
|
+
/**
|
|
9130
|
+
* Apply a room's model without changing the user's deployment default.
|
|
9131
|
+
*
|
|
9132
|
+
* `sessionController.selectModel` saves the selection as `agent-default-model` in the
|
|
9133
|
+
* background. That is right for a user choosing in the composer, but a room applying
|
|
9134
|
+
* its stored model (especially on startup) would otherwise overwrite the user's last
|
|
9135
|
+
* explicit choice. Capture the default first and restore it after the background save
|
|
9136
|
+
* has had a chance to land.
|
|
9137
|
+
*/
|
|
9138
|
+
const selectRoomModel = async (ctx, request) => {
|
|
9139
|
+
const defaultModel = ctx.agentDefaultModel;
|
|
9140
|
+
const before = defaultModel?.currentSelection();
|
|
9141
|
+
const changesDefault = before !== void 0 && !sameModelSelection(before, {
|
|
9142
|
+
provider: request.provider,
|
|
9143
|
+
model: request.model,
|
|
9144
|
+
...request.reasoningEffort === void 0 ? {} : { reasoningEffort: request.reasoningEffort }
|
|
9145
|
+
});
|
|
9146
|
+
await ctx.sessionController.selectModel(request);
|
|
9147
|
+
if (!changesDefault || before === void 0 || defaultModel === void 0) return;
|
|
9148
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
9149
|
+
try {
|
|
9150
|
+
await defaultModel.saveSelection(before);
|
|
9151
|
+
} catch {}
|
|
9152
|
+
};
|
|
9153
|
+
/** Whether a controller failure is the precise "this session is not stored" answer. */
|
|
9154
|
+
const isSessionNotFound = (error) => typeof error === "object" && error !== null && error.code === "session/not-found";
|
|
9155
|
+
/**
|
|
9156
|
+
* Read an optional host service without making it a required injection.
|
|
9157
|
+
*
|
|
9158
|
+
* The direct property is the usual path on a fully composed host. `ctx.get` is the fallback
|
|
9159
|
+
* for a host that exposes a lazily mounted service as a registry entry only; tests and
|
|
9160
|
+
* minimal hosts may provide neither, which must read as "service unavailable" rather than
|
|
9161
|
+
* as a crash while opening a room.
|
|
9162
|
+
*/
|
|
9163
|
+
const hostService = (ctx, key) => {
|
|
9164
|
+
try {
|
|
9165
|
+
const direct = ctx[key];
|
|
9166
|
+
if (direct !== void 0) return direct;
|
|
9167
|
+
} catch {}
|
|
9168
|
+
const get = ctx.get;
|
|
9169
|
+
if (typeof get !== "function") return void 0;
|
|
9170
|
+
try {
|
|
9171
|
+
return get.call(ctx, key);
|
|
9172
|
+
} catch {
|
|
9173
|
+
return;
|
|
9174
|
+
}
|
|
9175
|
+
};
|
|
9176
|
+
/**
|
|
9177
|
+
* The model route a Session's own log records, or `''` when it has never selected one.
|
|
9178
|
+
*
|
|
9179
|
+
* A resolved room Session keeps the selection it was created with; a restarted host must
|
|
9180
|
+
* not silently leave it on a room whose configured model has since changed. Reading the
|
|
9181
|
+
* last `model/selection` event avoids a write on every open.
|
|
9182
|
+
*/
|
|
9183
|
+
const sessionModelKey = (session) => {
|
|
9184
|
+
const events = session.snapshotEvents();
|
|
9185
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
9186
|
+
const event = events[index];
|
|
9187
|
+
if (event?.type !== "model/selection") continue;
|
|
9188
|
+
if (typeof event.data?.provider !== "string" || typeof event.data?.model !== "string") continue;
|
|
9189
|
+
return [
|
|
9190
|
+
event.data.provider,
|
|
9191
|
+
event.data.model,
|
|
9192
|
+
typeof event.data.reasoningEffort === "string" ? event.data.reasoningEffort : ""
|
|
9193
|
+
].join("|");
|
|
9194
|
+
}
|
|
9195
|
+
return "";
|
|
9196
|
+
};
|
|
9197
|
+
/**
|
|
9198
|
+
* Resolves the DSH session backing one room.
|
|
9199
|
+
*
|
|
9200
|
+
* A room has exactly one Session, addressed by {@link roomSessionId}. Opening it must
|
|
9201
|
+
* therefore be a resolve-or-create, never a create alone:
|
|
9202
|
+
*
|
|
9203
|
+
* - `sessionController.create({ sessionId })` reuses the named Session only while it is
|
|
9204
|
+
* **blank**. A room that has ever run a turn — every room does, because of the warm-up
|
|
9205
|
+
* turn — is no longer blank, so the controller mints a fresh `session-<uuid>` in the
|
|
9206
|
+
* same cwd instead of returning the room's Session.
|
|
9207
|
+
* - Checking only `ctx.sessions.get()` misses a persisted Session after a host restart,
|
|
9208
|
+
* because the registry is process-local. The first open would then mint a replacement;
|
|
9209
|
+
* the plugin's lookup by the deterministic id still returns nothing, the open fails, and
|
|
9210
|
+
* the three-second discovery poll repeats the mint forever. That is how one room grew to
|
|
9211
|
+
* 16,004 Session directories.
|
|
9212
|
+
*
|
|
9213
|
+
* Resolving first also fixes the archived case: it loads the real Session and lets the
|
|
9214
|
+
* room unarchive it before running a turn, instead of creating an unrelated replacement.
|
|
9215
|
+
*/
|
|
8990
9216
|
var RoomSessions = class {
|
|
8991
9217
|
ctx;
|
|
9218
|
+
/** One in-flight open per room, so two discovery passes cannot race the same identity. */
|
|
9219
|
+
pending = /* @__PURE__ */ new Map();
|
|
8992
9220
|
constructor(ctx) {
|
|
8993
9221
|
this.ctx = ctx;
|
|
8994
9222
|
}
|
|
@@ -8996,36 +9224,105 @@ var RoomSessions = class {
|
|
|
8996
9224
|
return this.ctx.sessions.get(roomSessionId(roomId));
|
|
8997
9225
|
}
|
|
8998
9226
|
/**
|
|
8999
|
-
* Open a room's
|
|
9227
|
+
* Open a room's persisted Session, or create it on the one occasion it is absent.
|
|
9000
9228
|
*
|
|
9001
|
-
*
|
|
9002
|
-
*
|
|
9003
|
-
*
|
|
9229
|
+
* @param roomId - the room the session belongs to.
|
|
9230
|
+
* @param room - the room's configured scope and model.
|
|
9231
|
+
* @returns the canonical room Session, with an agent bound to it.
|
|
9004
9232
|
*/
|
|
9005
9233
|
async ensure(roomId, room) {
|
|
9006
9234
|
const existing = this.existing(roomId);
|
|
9007
9235
|
if (existing !== void 0) return existing;
|
|
9236
|
+
const pending = this.pending.get(roomId);
|
|
9237
|
+
if (pending !== void 0) return pending;
|
|
9238
|
+
const opening = this.open(roomId, room).finally(() => {
|
|
9239
|
+
if (this.pending.get(roomId) === opening) this.pending.delete(roomId);
|
|
9240
|
+
});
|
|
9241
|
+
this.pending.set(roomId, opening);
|
|
9242
|
+
return opening;
|
|
9243
|
+
}
|
|
9244
|
+
async open(roomId, room) {
|
|
9008
9245
|
const sessionId = roomSessionId(roomId);
|
|
9009
9246
|
const directory = resolveRoomDirectory(roomId, room.cwd);
|
|
9010
9247
|
await mkdir(directory, { recursive: true });
|
|
9011
|
-
await this.
|
|
9248
|
+
const resumed = await this.resolvePersisted(sessionId, directory);
|
|
9249
|
+
if (resumed !== void 0) {
|
|
9250
|
+
await this.installRoomModel(resumed, room, false);
|
|
9251
|
+
return resumed;
|
|
9252
|
+
}
|
|
9253
|
+
const created = await this.ctx.sessionController.create({
|
|
9012
9254
|
sessionId,
|
|
9013
9255
|
cwd: directory,
|
|
9014
9256
|
...room.agentPreset === void 0 ? {} : { agentPreset: room.agentPreset }
|
|
9015
9257
|
});
|
|
9016
|
-
if (
|
|
9017
|
-
sessionId
|
|
9018
|
-
|
|
9258
|
+
if (created.sessionId !== sessionId) {
|
|
9259
|
+
await this.archiveReplacement(created.sessionId);
|
|
9260
|
+
throw new Error(`group-chat asked for room session ${sessionId} but the controller opened ${created.sessionId}`);
|
|
9261
|
+
}
|
|
9262
|
+
const session = this.existing(roomId);
|
|
9263
|
+
if (session === void 0) throw new Error(`group-chat could not open a session for room ${roomId}`);
|
|
9264
|
+
await this.installRoomModel(session, room, true);
|
|
9265
|
+
return session;
|
|
9266
|
+
}
|
|
9267
|
+
/** Load a persisted room Session, or `undefined` only when the controller denies it exists. */
|
|
9268
|
+
async resolvePersisted(sessionId, directory) {
|
|
9269
|
+
let resolved;
|
|
9270
|
+
try {
|
|
9271
|
+
resolved = await this.ctx.sessionController.resolveAgent(sessionId);
|
|
9272
|
+
} catch (error) {
|
|
9273
|
+
if (isSessionNotFound(error)) return void 0;
|
|
9274
|
+
throw error;
|
|
9275
|
+
}
|
|
9276
|
+
if ("agent" in resolved) {
|
|
9277
|
+
const session = resolved.agent.session;
|
|
9278
|
+
if (session.id !== sessionId) throw new Error(`group-chat resolved room session ${sessionId} as ${session.id}`);
|
|
9279
|
+
if (session.header.cwd !== void 0 && session.header.cwd !== directory) this.ctx.logger?.warn?.("[group-chat] room session directory differs from the room configuration; keeping the existing history", sessionId, session.header.cwd, directory);
|
|
9280
|
+
await this.unarchiveRoomSession(sessionId);
|
|
9281
|
+
return session;
|
|
9282
|
+
}
|
|
9283
|
+
if (isSessionNotFound(resolved.error)) return void 0;
|
|
9284
|
+
throw new Error(`group-chat could not resolve room session ${sessionId}: ${resolved.error instanceof Error ? resolved.error.message : String(resolved.error)}`);
|
|
9285
|
+
}
|
|
9286
|
+
/** Install the room's model when the Session's log does not already carry it. */
|
|
9287
|
+
async installRoomModel(session, room, strict) {
|
|
9288
|
+
if (room.modelProvider === void 0 || room.model === void 0) return;
|
|
9289
|
+
const target = {
|
|
9290
|
+
modelProvider: room.modelProvider,
|
|
9019
9291
|
model: room.model,
|
|
9020
9292
|
...room.reasoningEffort === void 0 ? {} : { reasoningEffort: room.reasoningEffort }
|
|
9021
|
-
}
|
|
9022
|
-
|
|
9023
|
-
|
|
9024
|
-
|
|
9293
|
+
};
|
|
9294
|
+
if (sessionModelKey(session) === modelKeyOf(target)) return;
|
|
9295
|
+
try {
|
|
9296
|
+
await selectRoomModel(this.ctx, {
|
|
9297
|
+
sessionId: session.id,
|
|
9298
|
+
provider: room.modelProvider,
|
|
9299
|
+
model: room.model,
|
|
9300
|
+
...room.reasoningEffort === void 0 ? {} : { reasoningEffort: room.reasoningEffort }
|
|
9301
|
+
});
|
|
9302
|
+
} catch (error) {
|
|
9303
|
+
if (strict) throw error;
|
|
9304
|
+
this.ctx.logger?.warn?.("[group-chat] keeping the room session on its existing model", session.id, error);
|
|
9305
|
+
}
|
|
9306
|
+
}
|
|
9307
|
+
/** The workspace registry's archive surface, looked up lazily because it is optional. */
|
|
9308
|
+
get archiveRegistry() {
|
|
9309
|
+
return hostService(this.ctx, "workspaceRegistry");
|
|
9310
|
+
}
|
|
9311
|
+
/** Hide a replacement the controller minted instead of honoring the room id. */
|
|
9312
|
+
async archiveReplacement(sessionId) {
|
|
9313
|
+
try {
|
|
9314
|
+
await this.archiveRegistry?.archiveSession(sessionId, { stopActivity: true });
|
|
9315
|
+
} catch {}
|
|
9316
|
+
}
|
|
9317
|
+
/** Lift an archive marker from the room's canonical Session before its first turn. */
|
|
9318
|
+
async unarchiveRoomSession(sessionId) {
|
|
9319
|
+
try {
|
|
9320
|
+
await this.archiveRegistry?.unarchiveSession?.(sessionId);
|
|
9321
|
+
} catch {}
|
|
9025
9322
|
}
|
|
9026
9323
|
};
|
|
9027
9324
|
/** The height bounds the region enforces, exported so the client and host agree on them. */
|
|
9028
|
-
const SIDEBAR_HEIGHT_MIN =
|
|
9325
|
+
const SIDEBAR_HEIGHT_MIN = 80;
|
|
9029
9326
|
const SIDEBAR_HEIGHT_MAX = 1200;
|
|
9030
9327
|
/** Reads the preferences file, treating every failure as "nothing saved yet". */
|
|
9031
9328
|
const readPreferences = async (env = process.env) => {
|
|
@@ -9045,7 +9342,7 @@ const readPreferences = async (env = process.env) => {
|
|
|
9045
9342
|
}
|
|
9046
9343
|
};
|
|
9047
9344
|
/** Clamps a requested height into the range the region can actually draw. */
|
|
9048
|
-
const clampSidebarHeight = (value) => Math.min(SIDEBAR_HEIGHT_MAX, Math.max(
|
|
9345
|
+
const clampSidebarHeight = (value) => Math.min(SIDEBAR_HEIGHT_MAX, Math.max(80, Math.round(value)));
|
|
9049
9346
|
/**
|
|
9050
9347
|
* Writes the preferences file atomically.
|
|
9051
9348
|
*
|
|
@@ -9336,7 +9633,7 @@ const activate = (ctx, target) => {
|
|
|
9336
9633
|
if (room.modelProvider === null || room.modelProvider === void 0) return;
|
|
9337
9634
|
if (room.model === null || room.model === void 0) return;
|
|
9338
9635
|
try {
|
|
9339
|
-
await ctx
|
|
9636
|
+
await selectRoomModel(ctx, {
|
|
9340
9637
|
sessionId: session.id,
|
|
9341
9638
|
provider: room.modelProvider,
|
|
9342
9639
|
model: room.model,
|
|
@@ -9560,6 +9857,56 @@ const activate = (ctx, target) => {
|
|
|
9560
9857
|
const url = new URL(request.url ?? "/", "http://dsh.internal");
|
|
9561
9858
|
const route = url.pathname.slice(19).replace(/^\/+/u, "");
|
|
9562
9859
|
try {
|
|
9860
|
+
if (request.method === "GET" && route === "attachment-materialize") {
|
|
9861
|
+
const attachmentId = url.searchParams.get("attachmentId") ?? "";
|
|
9862
|
+
const name = url.searchParams.get("name") ?? "attachment";
|
|
9863
|
+
const bytes = Number(url.searchParams.get("bytes") ?? "0");
|
|
9864
|
+
if (!attachmentId.startsWith("sha256:") || !Number.isSafeInteger(bytes) || bytes <= 0) throw new RouteRefusal("attachment-materialize requires a sha256 attachmentId and byte count", 400);
|
|
9865
|
+
const attachments = hostService(ctx, "attachments");
|
|
9866
|
+
if (attachments === void 0) throw new RouteRefusal("attachment store is unavailable", 501);
|
|
9867
|
+
const chunks = [];
|
|
9868
|
+
for await (const chunk of attachments.readFileStream({
|
|
9869
|
+
attachmentId,
|
|
9870
|
+
name,
|
|
9871
|
+
bytes
|
|
9872
|
+
})) chunks.push(chunk);
|
|
9873
|
+
const safeName = basename(name).replace(/[^\p{L}\p{N}._-]+/gu, "_") || "attachment";
|
|
9874
|
+
const directory = join(resolveWorkspaceRoot(), ".dsh-attachment-preview");
|
|
9875
|
+
await mkdir(directory, { recursive: true });
|
|
9876
|
+
const relativePath = ".dsh-attachment-preview/" + attachmentId.slice(7, 19) + "-" + safeName;
|
|
9877
|
+
await writeFile(join(resolveWorkspaceRoot(), relativePath), Buffer.concat(chunks));
|
|
9878
|
+
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
9879
|
+
response.end(JSON.stringify({
|
|
9880
|
+
path: relativePath,
|
|
9881
|
+
name: safeName
|
|
9882
|
+
}));
|
|
9883
|
+
return;
|
|
9884
|
+
}
|
|
9885
|
+
if (request.method === "GET" && route === "attachment-preview") {
|
|
9886
|
+
const attachmentId = url.searchParams.get("attachmentId") ?? "";
|
|
9887
|
+
const name = url.searchParams.get("name") ?? "attachment";
|
|
9888
|
+
const bytes = Number(url.searchParams.get("bytes") ?? "0");
|
|
9889
|
+
if (!attachmentId.startsWith("sha256:") || !Number.isSafeInteger(bytes) || bytes <= 0) throw new RouteRefusal("attachment-preview requires a sha256 attachmentId and byte count", 400);
|
|
9890
|
+
const attachments = hostService(ctx, "attachments");
|
|
9891
|
+
if (attachments === void 0) throw new RouteRefusal("attachment store is unavailable", 501);
|
|
9892
|
+
const chunks = [];
|
|
9893
|
+
for await (const chunk of attachments.readFileStream({
|
|
9894
|
+
attachmentId,
|
|
9895
|
+
name,
|
|
9896
|
+
bytes
|
|
9897
|
+
})) chunks.push(chunk);
|
|
9898
|
+
const body = Buffer.concat(chunks);
|
|
9899
|
+
const extension = name.toLowerCase().split(".").pop() ?? "";
|
|
9900
|
+
const contentType = extension === "png" ? "image/png" : extension === "jpg" || extension === "jpeg" ? "image/jpeg" : extension === "gif" ? "image/gif" : extension === "webp" ? "image/webp" : extension === "pdf" ? "application/pdf" : extension === "md" ? "text/markdown; charset=utf-8" : extension === "json" ? "application/json; charset=utf-8" : extension === "txt" || extension === "log" ? "text/plain; charset=utf-8" : "application/octet-stream";
|
|
9901
|
+
response.writeHead(200, {
|
|
9902
|
+
"content-type": contentType,
|
|
9903
|
+
"content-length": String(body.length),
|
|
9904
|
+
"content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
9905
|
+
"cache-control": "private, no-store"
|
|
9906
|
+
});
|
|
9907
|
+
response.end(body);
|
|
9908
|
+
return;
|
|
9909
|
+
}
|
|
9563
9910
|
const payload = await readJsonBody(request);
|
|
9564
9911
|
const body = await handleRoomRequest(route, request.method ?? "GET", payload, url, backend, runtimes, closeRoom, ctx, pendingQuotes);
|
|
9565
9912
|
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
@@ -9626,6 +9973,44 @@ const activate = (ctx, target) => {
|
|
|
9626
9973
|
const handleRoomRequest = async (route, httpMethod, payload, url, backend, runtimes, closeRoom, ctx, pendingQuotes) => {
|
|
9627
9974
|
const [resource, roomId, ...rest] = route.split("/").filter((part) => part.length > 0);
|
|
9628
9975
|
const action = rest.join("/");
|
|
9976
|
+
/**
|
|
9977
|
+
* Batch maintenance for the throwaway Sessions an older build minted under a room.
|
|
9978
|
+
*
|
|
9979
|
+
* Report is the default; archiving needs `dryRun: false`, and deleting additionally needs
|
|
9980
|
+
* `purge: true` plus `confirm` equal to the room id. The canonical room Session is never a
|
|
9981
|
+
* candidate, and neither is another room's Session or a subagent child.
|
|
9982
|
+
*/
|
|
9983
|
+
const cleanupMatch = /^rooms\/([^/]+)\/sessions\/cleanup$/u.exec(route);
|
|
9984
|
+
if (cleanupMatch !== null && cleanupMatch[1] !== void 0) {
|
|
9985
|
+
if (httpMethod !== "POST") throw new RouteRefusal("session cleanup accepts POST", 405);
|
|
9986
|
+
const targetRoomId = decodeURIComponent(cleanupMatch[1]);
|
|
9987
|
+
let room;
|
|
9988
|
+
try {
|
|
9989
|
+
room = (await backend.listRooms()).find((candidate) => candidate.id === targetRoomId);
|
|
9990
|
+
} catch {}
|
|
9991
|
+
const requestedDirectory = payload?.["directory"];
|
|
9992
|
+
if (requestedDirectory !== void 0 && (typeof requestedDirectory !== "string" || !isAbsolute(requestedDirectory))) throw new RouteRefusal("directory must be an absolute path", 400);
|
|
9993
|
+
const configuredDirectory = requestedDirectory ?? (room === void 0 ? void 0 : resolveRoomDirectory(targetRoomId, room.cwd));
|
|
9994
|
+
if (configuredDirectory === void 0) throw new RouteRefusal("room is no longer listed; pass the room directory explicitly to clean its session records", 404);
|
|
9995
|
+
const directory = await realpath(configuredDirectory).catch(() => configuredDirectory);
|
|
9996
|
+
if (resolve(directory) === resolve(resolveWorkspaceRoot())) throw new RouteRefusal("refusing to clean sessions whose cwd is the workspace root", 400);
|
|
9997
|
+
const persistence = hostService(ctx, "sessionPersistence");
|
|
9998
|
+
if (persistence === void 0) throw new RouteRefusal("session persistence is unavailable on this host", 501);
|
|
9999
|
+
const archive = hostService(ctx, "workspaceRegistry");
|
|
10000
|
+
const dryRun = payload?.["dryRun"] !== false;
|
|
10001
|
+
const purge = payload?.["purge"] === true;
|
|
10002
|
+
if (purge && payload?.["confirm"] !== targetRoomId) throw new RouteRefusal("purging session directories requires confirm set to the room id", 400);
|
|
10003
|
+
return cleanupRoomSessions({
|
|
10004
|
+
roomId: targetRoomId,
|
|
10005
|
+
directory,
|
|
10006
|
+
primarySessionId: roomSessionId(targetRoomId),
|
|
10007
|
+
persistence,
|
|
10008
|
+
...archive === void 0 ? {} : { archive },
|
|
10009
|
+
protectedSessionIds: [...runtimes.keys()].map((openRoomId) => roomSessionId(openRoomId)),
|
|
10010
|
+
dryRun,
|
|
10011
|
+
purge
|
|
10012
|
+
});
|
|
10013
|
+
}
|
|
9629
10014
|
if (resource === "rooms" && roomId === "messages" && action === "search") {
|
|
9630
10015
|
if (httpMethod !== "GET") throw new RouteRefusal("message search accepts GET", 405);
|
|
9631
10016
|
const query = url.searchParams.get("query") ?? "";
|
|
@@ -10194,4 +10579,4 @@ const readJsonBody = async (req) => {
|
|
|
10194
10579
|
*/
|
|
10195
10580
|
const MAX_UPLOAD_BYTES = 16777216;
|
|
10196
10581
|
//#endregion
|
|
10197
|
-
export { BackendError, Config, DEFAULT_CREDENTIAL_REF, GROUP_CHAT_ROUTE, GroupChatBackend, MANAGED_ENV, POLL_INTERVAL_MS, ROOM_SESSION_PREFIX, RoomEventTracker, SIDEBAR_HEIGHT_MAX, SIDEBAR_HEIGHT_MIN, activate, apply, clampSidebarHeight, inject, isAgentReply, isLocalUserMessage, isRuntimeInjectedData, isTransportFailure, isUserMessageData, lastRoomEvent, mentionedMemberIds, mentionsOf, messageId, name, publishableReply, readPreferences, resolveServerTarget, resolveWorkspaceRoot, roomIdOfSession, roomSessionId, senderId, shouldTriggerAgent, speakerPrefix, textOfContent, withSpeakerPrefix, writePreferences };
|
|
10582
|
+
export { BackendError, Config, DEFAULT_CREDENTIAL_REF, GROUP_CHAT_ROUTE, GroupChatBackend, MANAGED_ENV, POLL_INTERVAL_MS, ROOM_SESSION_PREFIX, RoomEventTracker, RoomSessions, SIDEBAR_HEIGHT_MAX, SIDEBAR_HEIGHT_MIN, activate, apply, clampSidebarHeight, handleRoomRequest, inject, isAgentReply, isLocalUserMessage, isRuntimeInjectedData, isTransportFailure, isUserMessageData, lastRoomEvent, mentionedMemberIds, mentionsOf, messageId, name, publishableReply, readPreferences, resolveServerTarget, resolveWorkspaceRoot, roomIdOfSession, roomSessionId, senderId, shouldTriggerAgent, speakerPrefix, textOfContent, withSpeakerPrefix, writePreferences };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sparkelf/dsh-group-chat",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Human-agent group chat for DataOps-managed DSH workspaces: rooms broadcast to every member, and only a member's own @mention wakes that member's agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|