" must not appear literally. In JSON output "<" only occurs inside
// strings, where < is equivalent.
value => JSON.stringify(value).replace(/ {
const p = n => String(n).padStart(2, "0");
const stamp = `${date.getFullYear()}-${p(date.getMonth() + 1)}-${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}`;
return stamp + "-" + Math.random().toString(36).slice(2, 6);
}
)};
const _rs_readValues = function _readValues(){return(
// Resolve cells of a module by name, without leaving an observer behind.
(module, names) => new Promise((resolve, reject) => {
let v = null;
const once = f => x => { f(x); window.queueMicrotask(() => { if (v) { v.delete(); v = null; } }); };
v = module.variable({ pending() {}, fulfilled: once(resolve), rejected: once(reject) })
.define(names, (...xs) => xs);
})
)};
const _rs_defineLiteral = function _defineLiteral(realize,encodeLiteral){return(
async (runtime, module, name, value) => {
const [fn] = await realize([`function _${name}(){return(\n${encodeLiteral(value)}\n)}`], runtime);
const existing = [...runtime._variables].find(v => v._module === module && v._name === name && v._type === 1);
(existing ?? module.variable({ pending() {}, fulfilled() {}, rejected() {} })).define(name, [], fn);
}
)};
const _rs_turnNumbers = function _turnNumbers(){return(
(runtime, module) => [...runtime._variables]
.filter(v => v._module === module && v._type === 1 && /^turn_\d+$/.test(v._name))
.map(v => Number(v._name.slice(5)))
.sort((a, b) => a - b)
)};
const _rs_open = function _openSessionLog(readValues,turnNumbers,SESSION_KIND){return(
async (runtime, module) => {
const [meta] = await readValues(module, ["session_meta"]);
if (meta?.kind !== SESSION_KIND) throw new Error("not a robocoop-5 session module");
const nums = turnNumbers(runtime, module);
return { runtime, module, meta, nextTurn: (nums.at(-1) ?? 0) + 1 };
}
)};
const _rs_create = function _createSessionLog(newSessionId,defineLiteral,SESSION_KIND,openSessionLog){return(
async (runtime, { id = newSessionId(), title = "", model = null, profile = "default" } = {}) => {
const module = runtime.module();
const meta = { kind: SESSION_KIND, version: 1, id, created: new Date().toISOString(), title, model, profile };
await defineLiteral(runtime, module, "session_meta", meta);
return openSessionLog(runtime, module);
}
)};
const _rs_extImages = function _externaliseImages(createFileAttachment,setFileAttachment){return(
// data: image parts become attachments of the session module; the message keeps only the name.
async (log, turn, messages) => {
let k = 0;
const part = async p => {
const url = p?.type === "image_url" && p.image_url?.url;
if (typeof url !== "string" || !url.startsWith("data:")) return p;
const blob = await (await window.fetch(url)).blob();
const ext = (blob.type.split("/")[1] || "bin").replace(/[^a-z0-9]/gi, "");
const name = `${turn}_${k++}.${ext}`;
await setFileAttachment(createFileAttachment(window.URL.createObjectURL(blob), name, blob.type), log.module);
return { type: "image_attachment", name, mimeType: blob.type };
};
const out = [];
for (const m of messages)
out.push(Array.isArray(m.content) ? { ...m, content: await Promise.all(m.content.map(part)) } : m);
return out;
}
)};
const _rs_rehydrate = function _rehydrateImages(readValues){return(
async (log, messages) => {
if (!messages.some(m => Array.isArray(m.content) && m.content.some(p => p?.type === "image_attachment"))) return messages;
const [FA] = await readValues(log.module, ["FileAttachment"]);
const dataUrl = blob => new Promise((resolve, reject) => {
const r = new window.FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error);
r.readAsDataURL(blob);
});
const part = async p => p?.type === "image_attachment"
? { type: "image_url", image_url: { url: await dataUrl(await FA(p.name).blob()) } }
: p;
return Promise.all(messages.map(async m =>
Array.isArray(m.content) ? { ...m, content: await Promise.all(m.content.map(part)) } : m));
}
)};
const _rs_append = function _appendTurn(turnName,externaliseImages,defineLiteral){return(
// One new cell per turn; earlier turn cells are never redefined. The number is reserved before the
// first await so concurrent appends keep their order.
async (log, { messages, status = "complete", error = null, at = new Date().toISOString() }) => {
const name = turnName(log.nextTurn++);
const stored = await externaliseImages(log, name, messages);
await defineLiteral(log.runtime, log.module, name, { at, status, error, messages: stored });
return name;
}
)};
const _rs_readTurns = function _readTurns(turnNumbers,turnName,readValues){return(
async log => {
const names = turnNumbers(log.runtime, log.module).map(turnName);
return names.length ? readValues(log.module, names) : [];
}
)};
const _rs_readMessages = function _readMessages(readTurns,rehydrateImages){return(
async log => rehydrateImages(log, (await readTurns(log)).flatMap(t => t.messages))
)};
const _rs_list = function _listSessions(readValues,SESSION_KIND,sessionName){return(
// Reflection, no index: every module holding a session_meta cell of this kind is a session; it is
// saved iff it is a main.
async runtime => {
const modules = new Set();
for (const v of runtime._variables)
if (v._name === "session_meta" && v._type === 1) modules.add(v._module);
const mainName = new Map([...runtime.mains].map(([n, m]) => [m, n]));
const out = [];
for (const module of modules) {
let meta;
try { [meta] = await readValues(module, ["session_meta"]); } catch (e) { continue; }
if (meta?.kind !== SESSION_KIND) continue;
out.push({ id: meta.id, meta, module, saved: mainName.has(module), name: mainName.get(module) ?? sessionName(meta.id) });
}
return out.sort((a, b) => String(b.meta.created).localeCompare(String(a.meta.created)));
}
)};
const _rs_save = function _saveSession(sessionName){return(
log => { log.runtime.mains.set(sessionName(log.meta.id), log.module); return log; }
)};
const _rs_unsave = function _unsaveSession(){return(
log => {
for (const [n, m] of [...log.runtime.mains]) if (m === log.module) log.runtime.mains.delete(n);
return log;
}
)};
const _rs_dispose = function _disposeSession(unsaveSession){return(
log => {
unsaveSession(log);
for (const v of [...log.runtime._variables]) if (v._module === log.module && v._type === 1) v.delete();
}
)};
const _rs_storable = function _storableMessages(){return(
// The part of a turn worth keeping: messages added since `before` (a Set of message objects taken before
// send), minus the system prompt (send() re-adds it) and context blocks (regenerated
// every turn, and they carry the page URL).
(messages, before) => messages.filter((m, i) =>
!before.has(m) &&
!(i === 0 && m.role === "system") &&
!(m.role === "system" && typeof m.content === "string" && m.content.startsWith(" in it" }
];
const t2 = [{ role: "user", content: "again" }, { role: "assistant", content: "ok" }];
expect(await appendTurn(log, { messages: t1 })).toBe("turn_0001");
const first = [...runtime._variables].find(v => v._module === log.module && v._name === "turn_0001")._definition;
expect(await appendTurn(log, { messages: t2, status: "aborted", error: "stopped" })).toBe("turn_0002");
// append-only: the earlier turn's definition is untouched
expect([...runtime._variables].find(v => v._module === log.module && v._name === "turn_0001")._definition).toBe(first);
expect(String(first)).not.toContain("");
// PutGet: reading back gives exactly what was appended, image rehydrated
expect(await readMessages(log)).toEqual([...t1, ...t2]);
const turns = await readTurns(log);
expect(turns.map(t => t.status)).toEqual(["complete", "aborted"]);
expect(turns[0].messages[0].content[1]).toEqual({ type: "image_attachment", name: "turn_0001_0.png", mimeType: "image/png" });
// saved iff main
const find = async () => (await listSessions(runtime)).find(s => s.module === log.module);
expect((await find()).saved).toBe(false);
saveSession(log);
expect(runtime.mains.get(sessionName(log.meta.id))).toBe(log.module);
expect((await find()).saved).toBe(true);
unsaveSession(log);
expect((await find()).saved).toBe(false);
return "round trip, append-only, image attachment, save/unsave";
} finally {
disposeSession(log);
}
};
const _rs_test_storable = function _test_storableMessages(storableMessages,expect){
const sys = { role: "system", content: "prompt" };
const old = { role: "user", content: "earlier" };
const env = { role: "system", content: "x" };
const watch = { role: "system", content: "Watch updates: spec 3/3" };
const u = { role: "user", content: "now" };
const a = { role: "assistant", content: "done" };
expect(storableMessages([sys, old, env, u, watch, a], new Set([old]))).toEqual([u, watch, a]);
return "drops prompt, earlier turns and environment blocks; keeps notices";
};
export default function define(runtime, observer) {
const main = runtime.module();
const $def = (pid, name, deps, fn) => {
main.variable(observer(name)).define(name, deps, fn).pid = pid;
};
main.define("module @tomlarkworthy/runtime-sdk", async () => runtime.module((await import("/@tomlarkworthy/runtime-sdk.js?v=4")).default));
main.define("module @tomlarkworthy/fileattachments", async () => runtime.module((await import("/@tomlarkworthy/fileattachments.js?v=4")).default));
main.define("module @tomlarkworthy/jest-expect-standalone", async () => runtime.module((await import("/@tomlarkworthy/jest-expect-standalone.js?v=4")).default));
$def("_rs_doc", null, ["md"], _rs_doc);
$def("_rs_kind", "SESSION_KIND", [], _rs_kind);
$def("_rs_ns", "SESSION_NAMESPACE", [], _rs_ns);
$def("_rs_sessionName", "sessionName", ["SESSION_NAMESPACE"], _rs_sessionName);
$def("_rs_turnName", "turnName", [], _rs_turnName);
$def("_rs_encode", "encodeLiteral", [], _rs_encode);
$def("_rs_newId", "newSessionId", [], _rs_newId);
$def("_rs_readValues", "readValues", [], _rs_readValues);
$def("_rs_defineLiteral", "defineLiteral", ["realize","encodeLiteral"], _rs_defineLiteral);
$def("_rs_turnNumbers", "turnNumbers", [], _rs_turnNumbers);
$def("_rs_open", "openSessionLog", ["readValues","turnNumbers","SESSION_KIND"], _rs_open);
$def("_rs_create", "createSessionLog", ["newSessionId","defineLiteral","SESSION_KIND","openSessionLog"], _rs_create);
$def("_rs_extImages", "externaliseImages", ["createFileAttachment","setFileAttachment"], _rs_extImages);
$def("_rs_rehydrate", "rehydrateImages", ["readValues"], _rs_rehydrate);
$def("_rs_append", "appendTurn", ["turnName","externaliseImages","defineLiteral"], _rs_append);
$def("_rs_readTurns", "readTurns", ["turnNumbers","turnName","readValues"], _rs_readTurns);
$def("_rs_readMessages", "readMessages", ["readTurns","rehydrateImages"], _rs_readMessages);
$def("_rs_list", "listSessions", ["readValues","SESSION_KIND","sessionName"], _rs_list);
$def("_rs_save", "saveSession", ["sessionName"], _rs_save);
$def("_rs_unsave", "unsaveSession", [], _rs_unsave);
$def("_rs_dispose", "disposeSession", ["unsaveSession"], _rs_dispose);
$def("_rs_storable", "storableMessages", [], _rs_storable);
$def("_rs_test_roundtrip", "test_session_roundtrip", ["createSessionLog","appendTurn","readMessages","readTurns","listSessions","saveSession","unsaveSession","disposeSession","sessionName","runtime","expect"], _rs_test_roundtrip);
$def("_rs_test_storable", "test_storableMessages", ["storableMessages","expect"], _rs_test_storable);
main.define("realize", ["module @tomlarkworthy/runtime-sdk", "@variable"], (_, v) => v.import("realize", _));
main.define("runtime", ["module @tomlarkworthy/runtime-sdk", "@variable"], (_, v) => v.import("runtime", _));
main.define("createFileAttachment", ["module @tomlarkworthy/fileattachments", "@variable"], (_, v) => v.import("createFileAttachment", _));
main.define("setFileAttachment", ["module @tomlarkworthy/fileattachments", "@variable"], (_, v) => v.import("setFileAttachment", _));
main.define("expect", ["module @tomlarkworthy/jest-expect-standalone", "@variable"], (_, v) => v.import("expect", _));
return main;
}