Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/channels/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ on the Agent tab marked "no channel" until something claims that slug again. The
same replay, same model handling — and they are listed on the **Agent** tab,
where clicking one opens it in the normal chat view.

From that list a conversation can be renamed or deleted. Deleting one does not
block the chat it belonged to: the next message in it starts a new conversation,
with none of the old memory. That makes it the way to reset a chat the agent has
got wrong. The messages themselves can be edited or deleted as in any session —
see [Sent messages](/guide/sessions#sent-messages) — but that changes what the
agent remembers, not what was already delivered to the channel.

## Channel types are packages

Nothing about Telegram is hardcoded. A channel type is a package with a
Expand Down
39 changes: 39 additions & 0 deletions docs/guide/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ after the server accepts it. If submission fails, the dialog stays open with an
error so you can retry or dismiss it. A timeout or cancellation resolves the
original question; it does not cancel a newer question that has replaced it.

## Sent messages

Hovering one of your messages gives it these actions:

- **Retry**, on your last message, drops the agent's reply to it — a half-finished
one after a Stop, say — and sends the same text again. It is editing without
changing a word, so the agent's memory ends up as if the first attempt never
happened rather than holding it and a second copy of the question.
- **Send again**, on an older message, sends its text as a new message at the
end. Retrying one of those would drop everything since. While a run is going
it queues, like anything else you type.
- **Edit** rewrites the message in place. It replaces that message *and
everything after it* — the agent's answers were to a question that is no
longer the same one — and sends the new text.
- **Delete** removes the message and the agent's answer to it, tool calls
included, and leaves the rest of the conversation as it was.

Edit and delete change what the agent remembers, not just what the page shows:
pi's own record of the conversation is edited too, and the agent's next turn
reads the version without the message. They are unavailable while a run is in
progress, so stop it first.

They do not undo what the agent *did*. Files it changed, commands it ran and
messages it sent stay as they are; only the memory of having done them goes.

Some cases are refused rather than guessed at, with a message saying why. A
message that a compaction has already folded into its summary cannot be deleted
on its own, since the summary would go on describing it — edit it instead, which
drops the summary with everything after. A conversation with branches from pi's
`/tree` cannot be trimmed cleanly either. A message is only matched to the agent's
record by its exact text (or as a voice turn), never by a fragment of it. Nothing
is changed when this happens.

If an edit's replacement is refused — the model is unreachable, say — the
conversation is put back as it was, rather than left without the messages the
edit meant to replace.

## Sidebar and the sessions page

The sidebar opens with New, Sessions and Agents, then **Pinned**, then
Expand All @@ -48,6 +85,8 @@ Pinning is stored server-side and drives the ordering (`pinned DESC,
updated_at DESC`), so the sidebar and the Sessions page never disagree.

Hovering a session gives you pin and delete. Double-clicking its name renames it.
The Agent tab's conversations can be renamed and deleted the same way, from the
row.

## Using a phone

Expand Down
56 changes: 56 additions & 0 deletions server/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,62 @@ export function replayStart(sessionId: string, keep: number): number {
return row?.seq ?? 0;
}

/** Every message the portal sent to the agent in this session, oldest first. */
export function sentMessages(sessionId: string): { seq: number; message: string }[] {
const rows = getDb()
.prepare("SELECT seq, payload FROM events WHERE session_id = ? AND type = 'portal_prompt' ORDER BY seq ASC")
.all(sessionId) as { seq: number; payload: string }[];
return rows.map((r) => ({ seq: r.seq, message: String(JSON.parse(r.payload)?.message ?? "") }));
}

/**
* Drops a stretch of a session's transcript: `from` up to, not including, `to` — or to the end.
* Returns what it removed, so the caller can put it back.
*/
export function deleteEventsBetween(sessionId: string, from: number, to: number | null): EventRow[] {
const db = getDb();
return db.transaction(() => {
const rows = db
.prepare("SELECT * FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?) ORDER BY seq ASC")
.all(sessionId, from, to, to) as EventRow[];
db.prepare("DELETE FROM events WHERE session_id = ? AND seq >= ? AND (? IS NULL OR seq < ?)").run(
sessionId,
from,
to,
to,
);
return rows;
})();
}

/** Puts events back under the seq they had — the inverse of deleteEventsBetween. */
export function restoreEvents(rows: EventRow[]): void {
const db = getDb();
const insert = db.prepare(
"INSERT OR REPLACE INTO events (seq, session_id, type, payload, created_at) VALUES (?, ?, ?, ?, ?)",
);
db.transaction(() => {
for (const r of rows) insert.run(r.seq, r.session_id, r.type, r.payload, r.created_at);
})();
}

/**
* The highest seq ever handed out, deleted events included: everything recorded
* from now on is greater. Read from the sequence rather than the table, because
* the newest rows may be the ones just removed.
*/
export function latestSeq(): number {
const row = getDb().prepare("SELECT seq FROM sqlite_sequence WHERE name = 'events'").get() as
| { seq: number }
| undefined;
return row?.seq ?? 0;
}

/** Drops one event. */
export function deleteEvent(seq: number): void {
getDb().prepare("DELETE FROM events WHERE seq = ?").run(seq);
}

/** The page before a cursor, oldest first — what a transcript scrolls back into. */
export function eventsBefore(sessionId: string, before: number, limit = 1500): EventRow[] {
return getDb()
Expand Down
31 changes: 31 additions & 0 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
} from "./pi-settings.js";
import { eventTime, getDb } from "./db.js";
import { getBuiltinCommands } from "./pi/builtins.js";
import { SessionEditError } from "./pi/session-edit.js";
import { isValidSlug, slugify } from "./slug.js";
import { getSettingDefaults, getSettings, getStoredSettings, setSettings } from "./db.js";

Expand Down Expand Up @@ -388,6 +389,36 @@ app.post("/api/sessions/:id/prompt", async (req, res) => {
}
});

// --- editing the conversation ---

const editStatus = { busy: 409, missing: 404 } as const;

/** Removes a message and the agent's answer to it. */
app.delete("/api/sessions/:id/messages/:seq", async (req, res) => {
try {
await sessions.removeMessage(req.params.id, Number(req.params.seq), "turn");
res.json({ ok: true });
} catch (e) {
if (!(e instanceof SessionEditError)) return res.status(500).json({ error: (e as Error).message });
res.status(editStatus[e.code as keyof typeof editStatus] ?? 422).json({ error: e.message });
}
});

/** Replaces a message: it and everything after it are dropped, and the new text is sent. */
app.post("/api/sessions/:id/messages/:seq/edit", async (req, res) => {
const message = req.body?.message;
if (typeof message !== "string" || !message.trim()) {
return res.status(400).json({ error: "message required" });
}
try {
await sessions.editMessage(req.params.id, Number(req.params.seq), message);
res.json({ ok: true, status: "running" });
} catch (e) {
if (!(e instanceof SessionEditError)) return res.status(500).json({ error: (e as Error).message });
res.status(editStatus[e.code as keyof typeof editStatus] ?? 422).json({ error: e.message });
}
});

/** The browser answering a dialog an extension is waiting on. */
app.post("/api/sessions/:id/ui-response", (req, res) => {
const session = getSession(req.params.id);
Expand Down
204 changes: 204 additions & 0 deletions server/src/pi/session-edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { AUDIO_MESSAGE_PREFIX } from "./voice-first.js";

/**
* Taking a message back out of pi's own record of the conversation.
*
* The portal's transcript is its own log, so trimming that is easy — and does
* nothing for the model, which reads pi's session file. A message deleted from
* the screen but still in the file is one the agent goes on answering to, which
* is worse than not offering delete at all. So the file is edited too.
*
* pi's file is a tree: every entry names its parent, and the conversation is
* the path from the last entry back to the root. Removing entries means
* removing them from that path and stitching the rest together, which is all
* this does. It works on the text of the file and touches nothing else, so it
* can be checked against a copy without a live session.
*/

export type SessionEditCode =
| "busy"
| "missing"
| "unsupported"
| "unmatched"
| "branched"
| "compacted";

export class SessionEditError extends Error {
constructor(
readonly code: SessionEditCode,
message: string,
) {
super(message);
}
}

/**
* `turn`: this message and what the agent did about it, and nothing else.
* `tail`: this message and everything after it — what editing does, since a
* conversation cannot keep answers to a question that is no longer the same.
*/
export type Scope = "turn" | "tail";

interface Entry {
type: string;
id?: string;
parentId?: string | null;
message?: { role?: string; content?: unknown };
targetId?: string;
[key: string]: unknown;
}

const textOf = (content: unknown): string =>
typeof content === "string"
? content
: Array.isArray(content)
? content.map((c) => (c?.type === "text" ? (c.text ?? "") : "")).join("")
: "";

const isUser = (e: Entry) => e.type === "message" && e.message?.role === "user";

/** Root to leaf, following parent links from the last entry. */
function pathTo(byId: Map<string, Entry>, leaf: string): Entry[] {
const out: Entry[] = [];
const seen = new Set<string>();
for (let id: string | null | undefined = leaf; id && !seen.has(id); ) {
seen.add(id);
const entry = byId.get(id);
if (!entry) break;
out.push(entry);
id = entry.parentId;
}
return out.reverse();
}

/**
* Which of pi's user entries a message the portal sent corresponds to.
*
* Matched by text, in order, and only exactly — or with the one prefix the
* portal adds to a voice turn. A looser match would take a message pi never
* received for a later one that merely contains its words, and remove the wrong
* turn from the file. The portal logs every message it sends and pi stores every
* one it receives, but they are not the same list: a slash command is not a chat
* message to the portal and can expand into one for pi, and a message that
* failed before reaching pi has no entry at all. So each sent message looks
* forward from the last one matched, and one that finds nothing is skipped
* rather than stopping the count — only the message being asked about has to be
* found.
*/
function locate(path: Entry[], sent: string[], ordinal: number): string {
const users = path.filter(isUser).map((e) => ({ id: e.id!, text: textOf(e.message?.content) }));
let from = 0;
let found = -1;
for (let i = 0; i <= ordinal; i++) {
const want = sent[i] ?? "";
const hit = users.findIndex(
(u, j) => j >= from && (u.text === want || u.text === AUDIO_MESSAGE_PREFIX + want),
);
if (hit < 0) {
if (i === ordinal) {
throw new SessionEditError(
"unmatched",
"This message could not be found in the agent's history, so nothing was changed.",
);
}
continue;
}
from = hit + 1;
found = i === ordinal ? hit : found;
}
return users[found].id;
}

/**
* The session file with one message taken out.
*
* Refuses rather than guesses. If the tree has branches this cannot reason
* about, or the message sits inside a summary the agent has already written,
* the honest answer is that it cannot be done cleanly — a file left half-edited
* would corrupt every later turn, and a refusal costs nothing.
*
* @param sent every message the portal sent up to and including this one, oldest first
* @param ordinal which of them this is
*/
export function dropMessage(raw: string, sent: string[], ordinal: number, scope: Scope): string {
const rows = raw
.split("\n")
.filter((line) => line.trim())
.map((line) => ({ line, entry: JSON.parse(line) as Entry }));
const header = rows.filter((r) => r.entry.type === "session");
const body = rows.filter((r) => r.entry.type !== "session");
const byId = new Map(body.map((r) => [r.entry.id!, r.entry]));

// pi opens a file with its last entry as the leaf.
const leaf = body.at(-1)?.entry.id;
if (!leaf) throw new SessionEditError("unmatched", "The agent has no history to edit.");
const path = pathTo(byId, leaf);
const onPath = new Set(path.map((e) => e.id!));

const target = locate(path, sent, ordinal);
const at = path.findIndex((e) => e.id === target);

const removed = new Set<string>();
if (scope === "tail") {
for (const e of path.slice(at)) removed.add(e.id!);
} else {
// Up to the next thing the person said. What lies between is the agent's
// answer: its replies, its tool calls, and their results.
const next = path.findIndex((e, i) => i > at && isUser(e));
const span = path.slice(at, next < 0 ? path.length : next);

// A summary already stands in for these messages. Deleting them leaves the
// summary describing something that never happened.
if (path.slice(at).some((e) => e.type === "compaction")) {
throw new SessionEditError(
"compacted",
"This message was already folded into a compacted summary and can no longer be removed on its own.",
);
}
// Only messages. A model or thinking-level change that fell in between is
// still true of the conversation, so it stays.
for (const e of span) if (e.type === "message" || e.type === "custom_message") removed.add(e.id!);
}

// Whatever hangs off a removed entry off the main path goes with it.
for (const { entry } of body) {
if (entry.parentId && removed.has(entry.parentId) && !onPath.has(entry.id!)) removed.add(entry.id!);
}
// A label on something that no longer exists.
for (const { entry } of body) {
if (entry.type === "label" && entry.targetId && removed.has(entry.targetId)) removed.add(entry.id!);
}

const parentOf = (id: string | null | undefined): string | null => {
let cur = id ?? null;
while (cur && removed.has(cur)) cur = byId.get(cur)?.parentId ?? null;
return cur;
};

const kept = body
.filter((r) => !removed.has(r.entry.id!))
.map((r) => {
const parent = parentOf(r.entry.parentId);
return parent === (r.entry.parentId ?? null)
? r
: { line: JSON.stringify({ ...r.entry, parentId: parent }), entry: { ...r.entry, parentId: parent } };
});

// The result must be the old conversation minus exactly what was removed.
// If a side branch made the last entry something else, opening the file
// would silently land on a different conversation — so check before writing.
const after = new Map(kept.map((r) => [r.entry.id!, r.entry]));
const newLeaf = kept.at(-1)?.entry.id;
const actual = newLeaf ? pathTo(after, newLeaf).map((e) => e.id) : [];
const expected = (scope === "tail" ? path.slice(0, at) : path.filter((e) => !removed.has(e.id!))).map(
(e) => e.id,
);
if (actual.length !== expected.length || actual.some((id, i) => id !== expected[i])) {
throw new SessionEditError(
"branched",
"This conversation has branches, so a message cannot be removed cleanly. Nothing was changed.",
);
}

return [...header, ...kept].map((r) => r.line).join("\n") + "\n";
}
Loading