Skip to content

Commit e732b4e

Browse files
committed
Fix Photon conversation persistence
1 parent 7463aeb commit e732b4e

9 files changed

Lines changed: 173 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.2] - 2026-07-23
11+
12+
### Fixed
13+
14+
- Photon now keeps every message from the same mapped sender in one durable
15+
1Helm thread across connector restarts and Photon conversation-ID changes.
16+
Sending `/new` closes that conversation without invoking the resident; the
17+
sender's next text starts a new thread.
18+
- Existing Photon installations carry their latest sender thread forward on
19+
upgrade, and completed resident replies remain deduplicated on the return
20+
path.
21+
1022
## [0.0.1] - 2026-07-23
1123

1224
### Initial release
@@ -37,5 +49,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3749
notarization, stapled tickets, Gatekeeper verification, persistent
3850
Application Support, and isolated Apple container machines.
3951

40-
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.1...HEAD
52+
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.2...HEAD
53+
[0.0.2]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.2
4154
[0.0.1]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.1

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
208208
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
209209
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. |
210210
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `native` elsewhere | Explicit development/test backend override. |
211-
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.1` | Versioned Apple channel-machine image. |
211+
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.2` | Versioned Apple channel-machine image. |
212212

213213
### Agent-first JSON CLI
214214

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "1helm",
33
"productName": "1Helm",
4-
"version": "0.0.1",
4+
"version": "0.0.2",
55
"private": true,
66
"type": "module",
77
"description": "1Helm is the self-hosted home for durable AI employees: one resident, one private computer, compounding memory and skills, and Skipper for every boundary.",

src/server/channel-computers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0";
6767
export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`;
6868
export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`;
6969
export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714";
70-
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.1";
70+
export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.2";
7171
const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[];
7272
const COMMAND_TIMEOUT_MS = Math.max(5_000, Number(process.env.HELM_MACHINE_COMMAND_TIMEOUT_MS || 120_000));
7373
const IDLE_AFTER_MS = Math.max(60_000, Number(process.env.HELM_MACHINE_IDLE_MS || 15 * 60_000));

src/server/db.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,39 @@ export function migrate(): void {
340340
);
341341
CREATE UNIQUE INDEX IF NOT EXISTS idx_photon_external ON photon_messages(external_id) WHERE external_id<>'';
342342
CREATE INDEX IF NOT EXISTS idx_photon_channel_time ON photon_messages(channel_id,received_at DESC);
343+
CREATE TABLE IF NOT EXISTS photon_conversations (
344+
id INTEGER PRIMARY KEY,
345+
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
346+
sender TEXT NOT NULL,
347+
space_id TEXT NOT NULL,
348+
root_message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
349+
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
350+
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)),
351+
started INTEGER NOT NULL,
352+
updated INTEGER NOT NULL,
353+
closed INTEGER
354+
);
355+
CREATE UNIQUE INDEX IF NOT EXISTS idx_photon_conversation_active
356+
ON photon_conversations(channel_id,sender) WHERE active=1;
357+
CREATE INDEX IF NOT EXISTS idx_photon_conversation_history
358+
ON photon_conversations(channel_id,sender,started DESC);
359+
INSERT INTO photon_conversations
360+
(channel_id,sender,space_id,root_message_id,thread_id,active,started,updated)
361+
SELECT pm.channel_id,pm.sender,pm.space_id,COALESCE(m.parent_id,m.id),t.id,1,m.created,pm.received_at
362+
FROM photon_messages pm
363+
JOIN messages m ON m.id=pm.message_id AND m.channel_id=pm.channel_id
364+
JOIN threads t ON t.root_message_id=COALESCE(m.parent_id,m.id) AND t.channel_id=pm.channel_id
365+
WHERE pm.direction='inbound'
366+
AND lower(trim(pm.body))<>'/new'
367+
AND NOT EXISTS (
368+
SELECT 1 FROM photon_conversations pc
369+
WHERE pc.channel_id=pm.channel_id AND pc.sender=pm.sender
370+
)
371+
AND NOT EXISTS (
372+
SELECT 1 FROM photon_messages newer
373+
WHERE newer.channel_id=pm.channel_id AND newer.sender=pm.sender AND newer.direction='inbound'
374+
AND (newer.received_at>pm.received_at OR (newer.received_at=pm.received_at AND newer.id>pm.id))
375+
);
343376
CREATE TABLE IF NOT EXISTS connector_deliveries (
344377
id INTEGER PRIMARY KEY,
345378
connector TEXT NOT NULL,
@@ -746,7 +779,7 @@ export function migrate(): void {
746779
// developer deliberately opts into the native compatibility backend.
747780
const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || (process.platform === "darwin" ? "apple" : "native"));
748781
const backend = ["apple", "native", "mock"].includes(configuredBackend) ? configuredBackend : "native";
749-
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.1");
782+
const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.2");
750783
for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id
751784
WHERE c.kind='channel' AND c.status<>'deleted'`)) {
752785
const channelId = Number(channel.id);

src/server/photon.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,37 @@ function recoverInterruptedPhotonDeliveries(): void {
157157
}
158158
}
159159

160-
function queuePhotonDelivery(channelId: number, destination: string, body: string, sourceMessageId: number, key: string): void {
160+
function queuePhotonDelivery(channelId: number, destination: string, body: string, sourceMessageId: number | null, key: string): void {
161161
run(`INSERT OR IGNORE INTO connector_deliveries
162162
(connector,idempotency_key,channel_id,destination,body,source_message_id,state,created,updated)
163163
VALUES ('photon',?,?,?,?,?,'pending',?,?)`, key, channelId, destination, body.slice(0, 50_000), sourceMessageId, now(), now());
164164
}
165165

166+
function activePhotonConversation(channelId: number, sender: string): Row | undefined {
167+
return q1(`SELECT pc.* FROM photon_conversations pc
168+
JOIN messages root ON root.id=pc.root_message_id AND root.channel_id=pc.channel_id AND root.parent_id IS NULL
169+
JOIN threads t ON t.id=pc.thread_id AND t.root_message_id=pc.root_message_id AND t.channel_id=pc.channel_id
170+
WHERE pc.channel_id=? AND pc.sender=? AND pc.active=1
171+
ORDER BY pc.updated DESC,pc.id DESC LIMIT 1`, channelId, sender);
172+
}
173+
174+
function closePhotonConversation(channelId: number, sender: string, spaceId: string, event: PhotonEvent): number | null {
175+
const conversation = activePhotonConversation(channelId, sender);
176+
const timestamp = Date.parse(event.timestamp) || now();
177+
run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'inbound',?,?,NULL)",
178+
channelId, event.id, spaceId, sender, event.text.slice(0, 50_000), timestamp);
179+
if (!conversation) return null;
180+
run("UPDATE photon_conversations SET active=0,space_id=?,updated=?,closed=? WHERE id=? AND active=1", spaceId, timestamp, timestamp, conversation.id);
181+
run("UPDATE threads SET status='resolved',updated_at=? WHERE id=?", timestamp, conversation.thread_id);
182+
const noteId = createMessage({ channelId, parentId: Number(conversation.root_message_id), botId: null, body: "Photon conversation closed with /new. The next text starts a new thread." });
183+
run("UPDATE messages SET system_message=1 WHERE id=?", noteId);
184+
run("INSERT INTO channel_activity (channel_id,thread_id,kind,summary,actor_type,created) VALUES (?,?,'connector',?,'system',?)",
185+
channelId, conversation.thread_id, `Photon closed the active conversation for ${sender}; the next text will start a new thread.`, timestamp);
186+
broadcastToChannel(channelId, { type: "message", message: serializeMessage(noteId), parent: serializeMessage(Number(conversation.root_message_id)) });
187+
refreshThreadSummary(Number(conversation.root_message_id));
188+
return noteId;
189+
}
190+
166191
/** Drain durable reply obligations. The attempt is persisted before crossing
167192
* the external boundary. A crash after that point is reported as uncertain on
168193
* restart and is never silently replayed into a duplicate iMessage. */
@@ -198,21 +223,44 @@ export async function deliverPhotonEvent(event: PhotonEvent): Promise<boolean> {
198223
const channelId = Number(mapping.channel_id);
199224
const resident = agentForChannel(channelId);
200225
if (!resident?.bot_id) return false;
201-
const body = `[Photon iMessage from ${event.sender}; conversation ${event.space_id}]\n${event.text}`;
202-
const messageId = createMessage({ channelId, parentId: null, botId: null, body });
226+
const timestamp = Date.parse(event.timestamp) || now();
227+
if (event.text.trim().toLowerCase() === "/new") {
228+
const noteId = closePhotonConversation(channelId, event.sender, event.space_id, event);
229+
queuePhotonDelivery(channelId, event.space_id,
230+
noteId ? "Started fresh. Your next text will open a new 1Helm thread." : "No conversation was open. Your next text will start a new 1Helm thread.",
231+
noteId, `photon:event:${event.id}:new`);
232+
await drainPhotonDeliveries();
233+
return true;
234+
}
235+
let conversation = activePhotonConversation(channelId, event.sender);
236+
let rootMessageId = Number(conversation?.root_message_id || 0);
237+
const body = `[Photon iMessage from ${event.sender}]\n${event.text}`;
238+
const messageId = createMessage({ channelId, parentId: rootMessageId || null, botId: null, body });
203239
run("UPDATE messages SET system_message=1 WHERE id=?", messageId);
204-
const threadId = ensureThread(messageId, channelId);
240+
if (!rootMessageId) {
241+
rootMessageId = messageId;
242+
const threadId = ensureThread(rootMessageId, channelId);
243+
const conversationId = run(`INSERT INTO photon_conversations
244+
(channel_id,sender,space_id,root_message_id,thread_id,active,started,updated)
245+
VALUES (?,?,?,?,?,1,?,?)`, channelId, event.sender, event.space_id, rootMessageId, threadId, timestamp, timestamp).lastInsertRowid;
246+
conversation = q1("SELECT * FROM photon_conversations WHERE id=?", conversationId);
247+
} else {
248+
run("UPDATE photon_conversations SET space_id=?,updated=? WHERE id=? AND active=1", event.space_id, timestamp, conversation!.id);
249+
}
250+
const threadId = Number(conversation!.thread_id);
251+
run("UPDATE threads SET status='open',updated_at=? WHERE id=?", timestamp, threadId);
205252
run("INSERT INTO photon_messages (channel_id,external_id,space_id,sender,direction,body,received_at,message_id) VALUES (?,?,?,?, 'inbound',?,?,?)", channelId, event.id, event.space_id, event.sender, event.text.slice(0, 50_000), Date.parse(event.timestamp) || now(), messageId);
206253
run("INSERT INTO channel_activity (channel_id,thread_id,kind,summary,actor_type,created) VALUES (?,?,'connector',?,'system',?)", channelId, threadId, `Photon delivered an iMessage from ${event.sender}.`, now());
207-
broadcastToChannel(channelId, { type: "message", message: serializeMessage(messageId) });
208-
refreshThreadSummary(messageId);
254+
broadcastToChannel(channelId, { type: "message", message: serializeMessage(messageId), parent: rootMessageId === messageId ? null : serializeMessage(rootMessageId) });
255+
refreshThreadSummary(rootMessageId);
209256
const bot = q1("SELECT * FROM bots WHERE id=?", resident.bot_id);
210257
if (bot && dispatchInbound) {
211258
try {
212259
const outboundBefore = Number(q1("SELECT COALESCE(MAX(id),0) id FROM photon_messages WHERE channel_id=? AND space_id=? AND direction='outbound'", channelId, event.space_id)?.id || 0);
213-
await dispatchInbound(bot, channelId, messageId, messageId);
260+
const replyBefore = Number(q1("SELECT COALESCE(MAX(id),0) id FROM messages WHERE channel_id=?", channelId)?.id || 0);
261+
await dispatchInbound(bot, channelId, messageId, rootMessageId);
214262
const reply = q1(`SELECT id,body FROM messages WHERE channel_id=? AND parent_id=? AND bot_id=?
215-
AND trim(body)<>'' AND body<>'_Working…_' ORDER BY id DESC LIMIT 1`, channelId, messageId, bot.id);
263+
AND id>? AND trim(body)<>'' AND body<>'_Working…_' ORDER BY id DESC LIMIT 1`, channelId, rootMessageId, bot.id, replyBefore);
216264
const alreadyReturned = q1("SELECT 1 FROM photon_messages WHERE channel_id=? AND space_id=? AND direction='outbound' AND id>? LIMIT 1", channelId, event.space_id, outboundBefore);
217265
const alreadyQueued = reply?.id ? q1("SELECT 1 FROM connector_deliveries WHERE connector='photon' AND source_message_id=? LIMIT 1", reply.id) : undefined;
218266
if (reply?.body && !alreadyReturned && !alreadyQueued) queuePhotonDelivery(channelId, event.space_id, String(reply.body), Number(reply.id), `photon:event:${event.id}:final`);

test/channel-computers.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive
154154
test("runtime digest and packaged image recipe stay pinned", async () => {
155155
assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714");
156156
assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/);
157-
assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.1");
157+
assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.2");
158158
const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8");
159159
assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets");
160160
const image = await readFile(join(root, "container", "Containerfile"), "utf8");

0 commit comments

Comments
 (0)