baychat 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -15
- package/dist/api.js +19 -2
- package/dist/claude-context-flush.js +219 -0
- package/dist/claude-context-state.js +191 -0
- package/dist/claude-context.js +311 -0
- package/dist/claude-settings.js +503 -0
- package/dist/context-hooks-command.js +165 -0
- package/dist/context-report.js +307 -0
- package/dist/help-topics.js +2 -23
- package/dist/index.js +26 -499
- package/dist/main.js +511 -0
- package/dist/mcp-notes.js +110 -0
- package/dist/mcp.js +84 -5
- package/dist/protocol-content.js +1 -1
- package/dist/relay/acp/client.js +18 -0
- package/dist/relay/acp/prompt.js +54 -3
- package/dist/relay/acp/runner.js +100 -2
- package/dist/relay/acp/turn-queue.js +99 -3
- package/dist/relay/codex-context.js +242 -0
- package/dist/relay/compact-command.js +63 -0
- package/dist/relay/compact-focus.attacks.js +36 -0
- package/dist/relay/compact-focus.js +64 -0
- package/dist/relay/context-notice.js +144 -0
- package/dist/relay/daemon.js +287 -28
- package/dist/relay/fresh-start-verify.js +237 -0
- package/dist/relay/fresh-start.js +141 -0
- package/dist/relay/message-format.js +34 -15
- package/dist/relay/project-brief.js +195 -0
- package/dist/relay/project-notes.js +300 -0
- package/dist/relay/resume.js +25 -0
- package/dist/relay/session-commands.js +2 -4
- package/dist/relay/terminal-text.js +13 -0
- package/dist/relay/updates.js +42 -7
- package/dist/relay/ws.js +34 -2
- package/dist/runtimes.js +11 -0
- package/dist/tool-defs.js +93 -0
- package/package.json +1 -1
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FRESH_START_COMMAND = exports.FRESH_START_FEATURE = void 0;
|
|
4
|
+
exports.parseFreshStart = parseFreshStart;
|
|
5
|
+
exports.freshStartPlan = freshStartPlan;
|
|
6
|
+
exports.freshStartLine = freshStartLine;
|
|
7
|
+
exports.freshStartPrompt = freshStartPrompt;
|
|
8
|
+
exports.freshStartMessage = freshStartMessage;
|
|
9
|
+
exports.noteRequestAsChat = noteRequestAsChat;
|
|
10
|
+
exports.isNoteRequestFor = isNoteRequestFor;
|
|
11
|
+
exports.postHandoffReport = postHandoffReport;
|
|
12
|
+
const api_1 = require("../api");
|
|
13
|
+
/**
|
|
14
|
+
* "Fresh start with a handoff" — the relay's half (protocol 1.17; the server's half is
|
|
15
|
+
* apps/api/src/services/handoff.service.ts).
|
|
16
|
+
*
|
|
17
|
+
* The app asks; the server puts two directed commands on ONE session's feed, in turn:
|
|
18
|
+
*
|
|
19
|
+
* phase "note" — BayChat's own instruction to write a handoff note with
|
|
20
|
+
* `create_note { …, handoffId }`. The relay hands it to the session as an
|
|
21
|
+
* ordinary wake (the only way to make its agent act), after checking that it
|
|
22
|
+
* COULD clear this session afterwards — if it could not, it says so at once
|
|
23
|
+
* and the session is never asked to write anything.
|
|
24
|
+
* phase "clear" — sent only once the note is saved. The relay clears the session and starts
|
|
25
|
+
* the fresh one from the note, then reports `fresh` (or `failed` with a code)
|
|
26
|
+
* on `POST /api/device-api/handoff`.
|
|
27
|
+
*
|
|
28
|
+
* How each runtime is cleared — the same doors `/clear` and `/new` already use:
|
|
29
|
+
*
|
|
30
|
+
* Claude Code `/clear` typed into its tmux/screen pane. The fresh session's first prompt is
|
|
31
|
+
* typed after it: ONE fixed line naming the note (`get_note <id>`), never the
|
|
32
|
+
* note's text — the relay types nothing into a terminal that came from a chat
|
|
33
|
+
* (session-commands.ts). The session reads the note through BayChat's tools.
|
|
34
|
+
* Codex `/new` typed into its pane (a new thread), then the same line.
|
|
35
|
+
* ACP no terminal: the running turn is stopped and the ACP session forgotten (the next
|
|
36
|
+
* prompt opens `session/new`), and that next prompt IS the note, in full.
|
|
37
|
+
* anything else, or Claude Code / Codex outside a multiplexer: refused before the note is
|
|
38
|
+
* asked for (NO_TERMINAL / RUNTIME_UNSUPPORTED).
|
|
39
|
+
*/
|
|
40
|
+
exports.FRESH_START_FEATURE = "freshstart";
|
|
41
|
+
/**
|
|
42
|
+
* The row a fresh-start step is filed under among a batch's commands. It has no trigger anybody
|
|
43
|
+
* can type: it only ever arrives as a directed event from the app, never from chat text.
|
|
44
|
+
*/
|
|
45
|
+
exports.FRESH_START_COMMAND = {
|
|
46
|
+
id: "freshstart",
|
|
47
|
+
trigger: "",
|
|
48
|
+
summary: "Fresh start with a handoff — from the app's memory sheet only.",
|
|
49
|
+
};
|
|
50
|
+
/** Ids travel into a typed line; they are server-made (cuid), and anything else is refused. */
|
|
51
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
52
|
+
/** The fresh-start command a directed message carries, or undefined when it carries none. */
|
|
53
|
+
function parseFreshStart(message) {
|
|
54
|
+
const metadata = message.metadata;
|
|
55
|
+
if (!metadata || typeof metadata !== "object")
|
|
56
|
+
return undefined;
|
|
57
|
+
if (metadata.type !== "session.command" || metadata.command !== "freshstart")
|
|
58
|
+
return undefined;
|
|
59
|
+
const { handoffId, phase, note, expiresAt } = metadata;
|
|
60
|
+
if (typeof handoffId !== "string" || !ID.test(handoffId))
|
|
61
|
+
return undefined;
|
|
62
|
+
if (phase === "note")
|
|
63
|
+
return { handoffId, phase };
|
|
64
|
+
if (phase !== "clear")
|
|
65
|
+
return undefined;
|
|
66
|
+
const n = note;
|
|
67
|
+
if (!n || typeof n.id !== "string" || !ID.test(n.id) || typeof n.title !== "string" || typeof n.body !== "string") {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const expires = typeof expiresAt === "string" ? Date.parse(expiresAt) : NaN;
|
|
71
|
+
return { handoffId, phase, note: { id: n.id, title: n.title, body: n.body }, ...(Number.isFinite(expires) ? { expiresAt: expires } : {}) };
|
|
72
|
+
}
|
|
73
|
+
/** How this relay would clear this session — decided BEFORE the note is asked for. */
|
|
74
|
+
function freshStartPlan(target) {
|
|
75
|
+
if (target.acp)
|
|
76
|
+
return { kind: "acp" };
|
|
77
|
+
if (target.runtime === "claude" || target.runtime === "codex") {
|
|
78
|
+
if (!target.pane)
|
|
79
|
+
return { kind: "refuse", reason: "NO_TERMINAL" };
|
|
80
|
+
return { kind: "type", clearKeys: target.runtime === "codex" ? "/new" : "/clear" };
|
|
81
|
+
}
|
|
82
|
+
return { kind: "refuse", reason: "RUNTIME_UNSUPPORTED" };
|
|
83
|
+
}
|
|
84
|
+
/** A session name is typed only when it is plainly a name; otherwise the line leaves it out. */
|
|
85
|
+
const PLAIN_NAME = /^[\p{L}\p{N} ._-]{1,60}$/u;
|
|
86
|
+
/**
|
|
87
|
+
* The one line typed into a freshly cleared Claude Code / Codex session. Built from the note id
|
|
88
|
+
* (checked by `parseFreshStart`) and the session's own registered name (checked here), never from
|
|
89
|
+
* anything a chat message said.
|
|
90
|
+
*/
|
|
91
|
+
function freshStartLine(noteId, session) {
|
|
92
|
+
const who = PLAIN_NAME.test(session) ? ` You are the BayChat session "${session}".` : "";
|
|
93
|
+
return (`Fresh start from BayChat.${who} Your handoff note is note ${noteId}: read it with get_note, ` +
|
|
94
|
+
`then carry on from it. If BayChat messages have stopped reaching you, rejoin with the baychat skill first.`);
|
|
95
|
+
}
|
|
96
|
+
/** What an ACP session reads as the first prompt of its fresh conversation: the note itself. */
|
|
97
|
+
function freshStartPrompt(note) {
|
|
98
|
+
return `Fresh start. Your handoff note:\n\n# ${note.title.replace(/\s+/g, " ").trim()}\n\n${note.body}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A copy of the command without what rode on it: the brief, the notes delta and the notice were
|
|
102
|
+
* already carried to the next chat message (daemon `deliver`), so a copy that kept them would
|
|
103
|
+
* hand the session the same text twice.
|
|
104
|
+
*/
|
|
105
|
+
function bare(command) {
|
|
106
|
+
const { projectBrief: _brief, projectNotes: _notes, notice: _notice, ...rest } = command;
|
|
107
|
+
return rest;
|
|
108
|
+
}
|
|
109
|
+
/** The first message handed to a fresh ACP session, shaped like the command that asked for it. */
|
|
110
|
+
function freshStartMessage(command, fresh) {
|
|
111
|
+
return {
|
|
112
|
+
...bare(command),
|
|
113
|
+
id: `handoff_${fresh.handoffId}`,
|
|
114
|
+
content: freshStartPrompt(fresh.note),
|
|
115
|
+
metadata: { type: "handoff.fresh", handoffId: fresh.handoffId },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The note request as the session reads it: an ordinary chat message, no longer marked as a
|
|
120
|
+
* directed command (so nothing downstream mistakes it for keystrokes or drops it).
|
|
121
|
+
*/
|
|
122
|
+
function noteRequestAsChat(command, handoffId) {
|
|
123
|
+
return { ...bare(command), metadata: { type: "handoff.request", handoffId } };
|
|
124
|
+
}
|
|
125
|
+
/** Is this the note request of that fresh start (so a fresh session is never asked again)? */
|
|
126
|
+
function isNoteRequestFor(message, handoffId) {
|
|
127
|
+
const metadata = message.metadata;
|
|
128
|
+
return !!metadata && metadata.type === "handoff.request" && metadata.handoffId === handoffId;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Tell the server how a step went. NEVER THROWS: a report that cannot be made is logged and the
|
|
132
|
+
* server's own timeout fails the run five minutes later (nothing is cleared by a missing report).
|
|
133
|
+
*/
|
|
134
|
+
async function postHandoffReport(auth, session, report, log, request = api_1.apiRequest) {
|
|
135
|
+
try {
|
|
136
|
+
await request(auth, "POST", "/api/device-api/handoff", { session, ...report });
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
log(`could not report fresh start ${report.handoffId} for ${session}: ${err instanceof Error ? err.message : String(err)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cleanTerminalText = void 0;
|
|
3
4
|
exports.formatWakeAction = formatWakeAction;
|
|
4
|
-
exports.cleanTerminalText = cleanTerminalText;
|
|
5
5
|
exports.formatRelayMessage = formatRelayMessage;
|
|
6
6
|
const tool_defs_1 = require("../tool-defs");
|
|
7
7
|
/** Keep the pickup instruction on every wake transport, without acknowledging
|
|
@@ -15,21 +15,40 @@ function formatWakeAction(session, conversationId, messages) {
|
|
|
15
15
|
`WAKE action for session=${JSON.stringify(session)}, conversationId=${JSON.stringify(conversationId)}: ${tool_defs_1.MESSAGE_PICKUP_INSTRUCTION}`,
|
|
16
16
|
];
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
function
|
|
29
|
-
|
|
18
|
+
// Moved to ./terminal-text so project-brief.ts can use it without an import cycle; re-exported
|
|
19
|
+
// here because callers (session-command.ts) import it from this module.
|
|
20
|
+
var terminal_text_1 = require("./terminal-text");
|
|
21
|
+
Object.defineProperty(exports, "cleanTerminalText", { enumerable: true, get: function () { return terminal_text_1.cleanTerminalText; } });
|
|
22
|
+
const terminal_text_2 = require("./terminal-text");
|
|
23
|
+
const project_brief_1 = require("./project-brief");
|
|
24
|
+
const project_notes_1 = require("./project-notes");
|
|
25
|
+
const context_notice_1 = require("./context-notice");
|
|
26
|
+
/** A label (a name, a Bay's name) as one line of plain text: no escapes, no controls, no bidi
|
|
27
|
+
* marks, no line breaks — so it cannot start a row of its own or forge a relay line. */
|
|
28
|
+
function oneLine(text) {
|
|
29
|
+
return (0, terminal_text_2.cleanTerminalText)(text)
|
|
30
|
+
.replace(/[\u0080-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
|
30
31
|
.replace(/\s+/g, " ")
|
|
31
32
|
.trim();
|
|
33
|
+
}
|
|
34
|
+
function formatRelayMessage(message) {
|
|
35
|
+
const sender = oneLine(message.sender?.name || message.senderId);
|
|
36
|
+
// A sender visiting from another Bay says so (protocol v1.14): its name, and the Bay's and
|
|
37
|
+
// sponsor's names, were written in that other Bay — labels, not the room's own.
|
|
38
|
+
const visiting = message.sender?.visiting;
|
|
39
|
+
const origin = visiting
|
|
40
|
+
? ` [visiting from another Bay: ${oneLine(visiting.homeBay)}, brought by ${oneLine(visiting.sponsor.name)}]`
|
|
41
|
+
: "";
|
|
32
42
|
const flag = message.shouldRespond ? " [shouldRespond=true]" : "";
|
|
33
|
-
const content = cleanTerminalText(message.content).replace(/\n/g, "\n ");
|
|
34
|
-
|
|
43
|
+
const content = (0, terminal_text_2.cleanTerminalText)(message.content).replace(/\n/g, "\n ");
|
|
44
|
+
const line = ` (${message.id}) @${sender} [${message.senderType}]${origin}${flag}: ${content}`;
|
|
45
|
+
// BayChat's memory notice, the project brief and the notes delta this message arrived with go
|
|
46
|
+
// in front of it (./context-notice.ts, ./project-brief.ts, ./project-notes.ts): the server hands
|
|
47
|
+
// each once, so this is the one chance the session has to read them.
|
|
48
|
+
const preface = [
|
|
49
|
+
...(message.notice ? [(0, context_notice_1.formatContextNotice)(message.notice)] : []),
|
|
50
|
+
...(message.projectBrief ? [(0, project_brief_1.formatProjectBrief)(message.projectBrief)] : []),
|
|
51
|
+
...(message.projectNotes ? [(0, project_notes_1.formatNotesDelta)(message.projectNotes)] : []),
|
|
52
|
+
];
|
|
53
|
+
return preface.length > 0 ? `${preface.join("\n")}\n${line}` : line;
|
|
35
54
|
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CarriedBriefs = void 0;
|
|
37
|
+
exports.projectBriefFrom = projectBriefFrom;
|
|
38
|
+
exports.formatProjectBrief = formatProjectBrief;
|
|
39
|
+
exports.carryBrief = carryBrief;
|
|
40
|
+
exports.carriedBriefsPath = carriedBriefsPath;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const config_1 = require("../config");
|
|
44
|
+
const terminal_text_1 = require("./terminal-text");
|
|
45
|
+
/** What the server sends beside an event: `project` metadata and, sometimes, `brief`. */
|
|
46
|
+
function projectBriefFrom(project, brief) {
|
|
47
|
+
if (brief === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (brief !== null && typeof brief !== "string")
|
|
50
|
+
return undefined;
|
|
51
|
+
if (!project || typeof project !== "object")
|
|
52
|
+
return undefined;
|
|
53
|
+
const { name, version } = project;
|
|
54
|
+
if (typeof name !== "string" || typeof version !== "number" || !Number.isFinite(version))
|
|
55
|
+
return undefined;
|
|
56
|
+
return { name, version, text: brief };
|
|
57
|
+
}
|
|
58
|
+
/** One line of plain text: no escapes, no controls, no bidi marks, no line breaks. */
|
|
59
|
+
function label(text) {
|
|
60
|
+
return (0, terminal_text_1.cleanTerminalText)(text)
|
|
61
|
+
.replace(/[\u0080-\u009f\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, "")
|
|
62
|
+
.replace(/\s+/g, " ")
|
|
63
|
+
.trim();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The block that goes in front of the message, as lines (no trailing newline).
|
|
67
|
+
*
|
|
68
|
+
* `indent` is the depth every line of the brief is pushed to — deeper than any structural column
|
|
69
|
+
* of the surrounding output (4 for the wake transports, 6 for the ACP prompt).
|
|
70
|
+
*/
|
|
71
|
+
function formatProjectBrief(brief, indent = " ") {
|
|
72
|
+
const header = `Project brief ("${label(brief.name)}", v${Math.trunc(brief.version)}):`;
|
|
73
|
+
if (brief.text === null) {
|
|
74
|
+
return [`${header} removed — the earlier brief no longer applies.`, "---"].join("\n");
|
|
75
|
+
}
|
|
76
|
+
const body = (0, terminal_text_1.cleanTerminalText)(brief.text)
|
|
77
|
+
.replace(/\r\n|[\r\u2028\u2029\u0085]/g, "\n")
|
|
78
|
+
.replace(/[\u202A-\u202E\u2066-\u2069\u200E\u200F]/g, "")
|
|
79
|
+
.split("\n")
|
|
80
|
+
.map((line) => `${indent}${line}`);
|
|
81
|
+
return [header, ...body, "---"].join("\n");
|
|
82
|
+
}
|
|
83
|
+
/** Is `next` at least as new as `held`? (A brief is replaced only by one of a newer version.) */
|
|
84
|
+
function supersedes(next, held) {
|
|
85
|
+
return !held || next.version >= held.version;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Hand a brief whose own message will not reach the session to one that will: the first of
|
|
89
|
+
* `later` in the same conversation, unless that one already carries a newer brief.
|
|
90
|
+
*
|
|
91
|
+
* @returns true when it found a home; false means the caller must keep it for later.
|
|
92
|
+
*/
|
|
93
|
+
function carryBrief(brief, conversationId, later) {
|
|
94
|
+
const next = later.find((m) => m.conversationId === conversationId);
|
|
95
|
+
if (!next)
|
|
96
|
+
return false;
|
|
97
|
+
if (supersedes(brief, next.projectBrief))
|
|
98
|
+
next.projectBrief = brief;
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
function carriedBriefsPath() {
|
|
102
|
+
return path.join((0, config_1.configDir)(), "carried-briefs.json");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Briefs that arrived on a message that never reached the session (a session command) and had
|
|
106
|
+
* no later message in the same batch to ride — kept until the next message for that
|
|
107
|
+
* (session, chat) goes out.
|
|
108
|
+
*
|
|
109
|
+
* ON DISK, like the held store (held.ts) and the ACP queue: the server handed the brief once and
|
|
110
|
+
* will not again, so a relay restart between the command and the next message must not lose it.
|
|
111
|
+
* Plaintext, 0600, next to the relay's other state. Loaded lazily; a file that cannot be read is
|
|
112
|
+
* an empty store (said once on stderr), a write that fails is said and the store keeps working in
|
|
113
|
+
* memory.
|
|
114
|
+
*/
|
|
115
|
+
class CarriedBriefs {
|
|
116
|
+
file;
|
|
117
|
+
held;
|
|
118
|
+
constructor(file = carriedBriefsPath()) {
|
|
119
|
+
this.file = file;
|
|
120
|
+
}
|
|
121
|
+
keep(session, conversationId, brief) {
|
|
122
|
+
const held = this.load();
|
|
123
|
+
const key = `${session}\u0000${conversationId}`;
|
|
124
|
+
if (!supersedes(brief, held.get(key)))
|
|
125
|
+
return;
|
|
126
|
+
held.set(key, brief);
|
|
127
|
+
this.save();
|
|
128
|
+
}
|
|
129
|
+
/** Forget every brief kept for this session (it started fresh or ended; the server re-sends). */
|
|
130
|
+
forget(session) {
|
|
131
|
+
const held = this.load();
|
|
132
|
+
let changed = false;
|
|
133
|
+
for (const key of [...held.keys()]) {
|
|
134
|
+
if (key.startsWith(`${session}\u0000`)) {
|
|
135
|
+
held.delete(key);
|
|
136
|
+
changed = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (changed)
|
|
140
|
+
this.save();
|
|
141
|
+
}
|
|
142
|
+
/** Put any kept brief on the first message of each chat in this batch (mutates the batch). */
|
|
143
|
+
apply(session, batch) {
|
|
144
|
+
const held = this.load();
|
|
145
|
+
let changed = false;
|
|
146
|
+
for (const message of batch) {
|
|
147
|
+
const key = `${session}\u0000${message.conversationId}`;
|
|
148
|
+
const kept = held.get(key);
|
|
149
|
+
if (!kept)
|
|
150
|
+
continue;
|
|
151
|
+
held.delete(key);
|
|
152
|
+
changed = true;
|
|
153
|
+
if (supersedes(kept, message.projectBrief))
|
|
154
|
+
message.projectBrief = kept;
|
|
155
|
+
}
|
|
156
|
+
if (changed)
|
|
157
|
+
this.save();
|
|
158
|
+
}
|
|
159
|
+
load() {
|
|
160
|
+
if (this.held)
|
|
161
|
+
return this.held;
|
|
162
|
+
this.held = new Map();
|
|
163
|
+
let raw;
|
|
164
|
+
try {
|
|
165
|
+
raw = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
// Absent is the ordinary case (nothing was ever carried); anything else is said.
|
|
169
|
+
if (err.code !== "ENOENT") {
|
|
170
|
+
process.stderr.write(`baychat relay: could not read carried project briefs — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
171
|
+
}
|
|
172
|
+
return this.held;
|
|
173
|
+
}
|
|
174
|
+
if (raw && typeof raw === "object") {
|
|
175
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
176
|
+
const v = value;
|
|
177
|
+
if (v && typeof v.name === "string" && typeof v.version === "number" && (v.text === null || typeof v.text === "string")) {
|
|
178
|
+
this.held.set(key, { name: v.name, version: v.version, text: v.text });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return this.held;
|
|
183
|
+
}
|
|
184
|
+
save() {
|
|
185
|
+
try {
|
|
186
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
187
|
+
fs.writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.held ?? new Map())), { mode: 0o600 });
|
|
188
|
+
fs.chmodSync(this.file, 0o600);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
process.stderr.write(`baychat relay: could not persist carried project briefs — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
exports.CarriedBriefs = CarriedBriefs;
|