@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.3

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.
Files changed (149) hide show
  1. package/README.md +133 -78
  2. package/dist/adapter.d.ts +22 -10
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +10 -7
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +228 -69
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +92 -34
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/shared.d.ts +23 -0
  13. package/dist/adapters/shared.d.ts.map +1 -0
  14. package/dist/adapters/shared.js +57 -0
  15. package/dist/adapters/shared.js.map +1 -0
  16. package/dist/adapters/slack/bot.d.ts +19 -11
  17. package/dist/adapters/slack/bot.d.ts.map +1 -1
  18. package/dist/adapters/slack/bot.js +356 -96
  19. package/dist/adapters/slack/bot.js.map +1 -1
  20. package/dist/adapters/slack/branch-manager.d.ts +21 -0
  21. package/dist/adapters/slack/branch-manager.d.ts.map +1 -0
  22. package/dist/adapters/slack/branch-manager.js +96 -0
  23. package/dist/adapters/slack/branch-manager.js.map +1 -0
  24. package/dist/adapters/slack/context.d.ts.map +1 -1
  25. package/dist/adapters/slack/context.js +100 -67
  26. package/dist/adapters/slack/context.js.map +1 -1
  27. package/dist/adapters/slack/session.d.ts +3 -0
  28. package/dist/adapters/slack/session.d.ts.map +1 -0
  29. package/dist/adapters/slack/session.js +16 -0
  30. package/dist/adapters/slack/session.js.map +1 -0
  31. package/dist/adapters/telegram/bot.d.ts +4 -2
  32. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  33. package/dist/adapters/telegram/bot.js +141 -74
  34. package/dist/adapters/telegram/bot.js.map +1 -1
  35. package/dist/adapters/telegram/context.d.ts.map +1 -1
  36. package/dist/adapters/telegram/context.js +49 -109
  37. package/dist/adapters/telegram/context.js.map +1 -1
  38. package/dist/adapters/telegram/html.d.ts +3 -0
  39. package/dist/adapters/telegram/html.d.ts.map +1 -0
  40. package/dist/adapters/telegram/html.js +98 -0
  41. package/dist/adapters/telegram/html.js.map +1 -0
  42. package/dist/agent.d.ts +4 -11
  43. package/dist/agent.d.ts.map +1 -1
  44. package/dist/agent.js +116 -196
  45. package/dist/agent.js.map +1 -1
  46. package/dist/bindings.d.ts +1 -20
  47. package/dist/bindings.d.ts.map +1 -1
  48. package/dist/bindings.js +1 -21
  49. package/dist/bindings.js.map +1 -1
  50. package/dist/config.d.ts +9 -27
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/config.js +89 -63
  53. package/dist/config.js.map +1 -1
  54. package/dist/context.d.ts +13 -3
  55. package/dist/context.d.ts.map +1 -1
  56. package/dist/context.js +102 -18
  57. package/dist/context.js.map +1 -1
  58. package/dist/events.d.ts +18 -6
  59. package/dist/events.d.ts.map +1 -1
  60. package/dist/events.js +86 -35
  61. package/dist/events.js.map +1 -1
  62. package/dist/execution-resolver.d.ts.map +1 -1
  63. package/dist/execution-resolver.js +1 -3
  64. package/dist/execution-resolver.js.map +1 -1
  65. package/dist/instrument.d.ts.map +1 -1
  66. package/dist/instrument.js +5 -11
  67. package/dist/instrument.js.map +1 -1
  68. package/dist/{login.d.ts → login/index.d.ts} +2 -2
  69. package/dist/login/index.d.ts.map +1 -0
  70. package/dist/{login.js → login/index.js} +2 -2
  71. package/dist/login/index.js.map +1 -0
  72. package/dist/{link-server.d.ts → login/portal.d.ts} +6 -4
  73. package/dist/login/portal.d.ts.map +1 -0
  74. package/dist/login/portal.js +1453 -0
  75. package/dist/login/portal.js.map +1 -0
  76. package/dist/{link-token.d.ts → login/session.d.ts} +1 -1
  77. package/dist/login/session.d.ts.map +1 -0
  78. package/dist/{link-token.js → login/session.js} +1 -1
  79. package/dist/login/session.js.map +1 -0
  80. package/dist/main.d.ts.map +1 -1
  81. package/dist/main.js +175 -119
  82. package/dist/main.js.map +1 -1
  83. package/dist/provisioner.d.ts +17 -43
  84. package/dist/provisioner.d.ts.map +1 -1
  85. package/dist/provisioner.js +84 -50
  86. package/dist/provisioner.js.map +1 -1
  87. package/dist/sandbox/host.d.ts +0 -2
  88. package/dist/sandbox/host.d.ts.map +1 -1
  89. package/dist/sandbox/host.js +1 -5
  90. package/dist/sandbox/host.js.map +1 -1
  91. package/dist/sentry.d.ts.map +1 -1
  92. package/dist/sentry.js +2 -0
  93. package/dist/sentry.js.map +1 -1
  94. package/dist/session-policy.d.ts +13 -0
  95. package/dist/session-policy.d.ts.map +1 -0
  96. package/dist/session-policy.js +23 -0
  97. package/dist/session-policy.js.map +1 -0
  98. package/dist/session-store.d.ts +27 -1
  99. package/dist/session-store.d.ts.map +1 -1
  100. package/dist/session-store.js +162 -9
  101. package/dist/session-store.js.map +1 -1
  102. package/dist/session-view/command.d.ts +5 -0
  103. package/dist/session-view/command.d.ts.map +1 -0
  104. package/dist/session-view/command.js +11 -0
  105. package/dist/session-view/command.js.map +1 -0
  106. package/dist/session-view/portal.d.ts +9 -0
  107. package/dist/session-view/portal.d.ts.map +1 -0
  108. package/dist/session-view/portal.js +766 -0
  109. package/dist/session-view/portal.js.map +1 -0
  110. package/dist/session-view/service.d.ts +34 -0
  111. package/dist/session-view/service.d.ts.map +1 -0
  112. package/dist/session-view/service.js +380 -0
  113. package/dist/session-view/service.js.map +1 -0
  114. package/dist/session-view/store.d.ts +16 -0
  115. package/dist/session-view/store.d.ts.map +1 -0
  116. package/dist/session-view/store.js +38 -0
  117. package/dist/session-view/store.js.map +1 -0
  118. package/dist/store.d.ts +3 -6
  119. package/dist/store.d.ts.map +1 -1
  120. package/dist/store.js +15 -35
  121. package/dist/store.js.map +1 -1
  122. package/dist/tools/event.d.ts +3 -0
  123. package/dist/tools/event.d.ts.map +1 -1
  124. package/dist/tools/event.js +27 -8
  125. package/dist/tools/event.js.map +1 -1
  126. package/dist/tools/index.d.ts +3 -0
  127. package/dist/tools/index.d.ts.map +1 -1
  128. package/dist/tools/index.js +2 -2
  129. package/dist/tools/index.js.map +1 -1
  130. package/dist/ui-copy.d.ts +1 -0
  131. package/dist/ui-copy.d.ts.map +1 -1
  132. package/dist/ui-copy.js +3 -0
  133. package/dist/ui-copy.js.map +1 -1
  134. package/dist/vault-routing.d.ts +1 -2
  135. package/dist/vault-routing.d.ts.map +1 -1
  136. package/dist/vault-routing.js +1 -7
  137. package/dist/vault-routing.js.map +1 -1
  138. package/package.json +1 -1
  139. package/dist/link-server.d.ts.map +0 -1
  140. package/dist/link-server.js +0 -839
  141. package/dist/link-server.js.map +0 -1
  142. package/dist/link-token.d.ts.map +0 -1
  143. package/dist/link-token.js.map +0 -1
  144. package/dist/login.d.ts.map +0 -1
  145. package/dist/login.js.map +0 -1
  146. package/dist/vault.test.d.ts +0 -2
  147. package/dist/vault.test.d.ts.map +0 -1
  148. package/dist/vault.test.js +0 -67
  149. package/dist/vault.test.js.map +0 -1
@@ -0,0 +1,23 @@
1
+ export function resolveChatSessionKey(options) {
2
+ const { conversationId, conversationKind, messageId, persistentTopLevel, scopeDirectThreads, threadTs, } = options;
3
+ if (conversationKind === "direct" && (!threadTs || !scopeDirectThreads)) {
4
+ return conversationId;
5
+ }
6
+ if (!threadTs && persistentTopLevel) {
7
+ return conversationId;
8
+ }
9
+ return `${conversationId}:${threadTs || messageId}`;
10
+ }
11
+ export function inferConversationKind(platform, conversationId) {
12
+ if (platform === "slack") {
13
+ return conversationId.startsWith("D") ? "direct" : "shared";
14
+ }
15
+ if (platform === "telegram") {
16
+ return conversationId.startsWith("-") ? "shared" : "direct";
17
+ }
18
+ if (platform === "discord") {
19
+ return conversationId.startsWith("DM") ? "direct" : "shared";
20
+ }
21
+ return "shared";
22
+ }
23
+ //# sourceMappingURL=session-policy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-policy.js","sourceRoot":"","sources":["../src/session-policy.ts"],"names":[],"mappings":"AAaA,MAAM,UAAU,qBAAqB,CAAC,OAAiC;IACrE,MAAM,EACJ,cAAc,EACd,gBAAgB,EAChB,SAAS,EACT,kBAAkB,EAClB,kBAAkB,EAClB,QAAQ,GACT,GAAG,OAAO,CAAC;IACZ,IAAI,gBAAgB,KAAK,QAAQ,IAAI,CAAC,CAAC,QAAQ,IAAI,CAAC,kBAAkB,CAAC,EAAE,CAAC;QACxE,OAAO,cAAc,CAAC;IACxB,CAAC;IACD,IAAI,CAAC,QAAQ,IAAI,kBAAkB,EAAE,CAAC;QACpC,OAAO,cAAc,CAAC;IACxB,CAAC;IACD,OAAO,GAAG,cAAc,IAAI,QAAQ,IAAI,SAAS,EAAE,CAAC;AACtD,CAAC;AAED,MAAM,UAAU,qBAAqB,CACnC,QAAsB,EACtB,cAAsB;IAEtB,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;QACzB,OAAO,cAAc,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC9D,CAAC;IAED,IAAI,QAAQ,KAAK,UAAU,EAAE,CAAC;QAC5B,OAAO,cAAc,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC9D,CAAC;IAED,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,OAAO,cAAc,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC/D,CAAC;IAED,OAAO,QAAQ,CAAC;AAClB,CAAC","sourcesContent":["import type { ConversationKind } from \"./adapter.js\";\n\nexport type ChatPlatform = \"slack\" | \"telegram\" | \"discord\" | string;\n\nexport interface ResolveSessionKeyOptions {\n conversationId: string;\n conversationKind: ConversationKind;\n messageId: string;\n threadTs?: string;\n persistentTopLevel?: boolean;\n scopeDirectThreads?: boolean;\n}\n\nexport function resolveChatSessionKey(options: ResolveSessionKeyOptions): string {\n const {\n conversationId,\n conversationKind,\n messageId,\n persistentTopLevel,\n scopeDirectThreads,\n threadTs,\n } = options;\n if (conversationKind === \"direct\" && (!threadTs || !scopeDirectThreads)) {\n return conversationId;\n }\n if (!threadTs && persistentTopLevel) {\n return conversationId;\n }\n return `${conversationId}:${threadTs || messageId}`;\n}\n\nexport function inferConversationKind(\n platform: ChatPlatform,\n conversationId: string,\n): ConversationKind {\n if (platform === \"slack\") {\n return conversationId.startsWith(\"D\") ? \"direct\" : \"shared\";\n }\n\n if (platform === \"telegram\") {\n return conversationId.startsWith(\"-\") ? \"shared\" : \"direct\";\n }\n\n if (platform === \"discord\") {\n return conversationId.startsWith(\"DM\") ? \"direct\" : \"shared\";\n }\n\n return \"shared\";\n}\n"]}
@@ -1,6 +1,24 @@
1
1
  import { SessionManager } from "@mariozechner/pi-coding-agent";
2
+ export declare class ThreadRootNotFoundError extends Error {
3
+ constructor(sessionFile: string);
4
+ }
5
+ export interface ThreadRootMessage {
6
+ text?: string;
7
+ userName?: string;
8
+ user?: string;
9
+ loggedAt?: number;
10
+ }
11
+ export interface ResolvedSessionScope {
12
+ sessionDir: string;
13
+ contextFile: string;
14
+ threadRootMessage: ThreadRootMessage | null;
15
+ }
16
+ export interface ResolveGenericSessionScopeOptions {
17
+ conversationDir: string;
18
+ sessionKey: string;
19
+ }
2
20
  /**
3
- * Returns the shared session directory for a channel.
21
+ * Returns the shared session directory for a conversation.
4
22
  * Channel sessions use a current pointer within this directory.
5
23
  * Thread sessions are stored as fixed files within the same directory.
6
24
  */
@@ -49,6 +67,12 @@ export declare function createManagedSessionFileAtPath(sessionFile: string, cwd:
49
67
  * Returns the fixed session file path for a Slack thread.
50
68
  */
51
69
  export declare function getThreadSessionFile(channelDir: string, sessionKey: string): string;
70
+ /**
71
+ * Resolve the default session scope for platforms without Slack-style branch forking.
72
+ * Top-level/private sessions use the conversation's current pointer. Threaded or
73
+ * per-message sessions use a fixed file derived from the session key suffix.
74
+ */
75
+ export declare function resolveGenericSessionScope(options: ResolveGenericSessionScopeOptions): ResolvedSessionScope;
52
76
  /**
53
77
  * Try to resolve an existing current session file.
54
78
  * Returns null if no current pointer exists or the pointed file has no valid session header.
@@ -69,4 +93,6 @@ export declare function resolveChannelSessionFile(channelDir: string): string |
69
93
  * The resulting file keeps forkFrom's distinct session/header metadata.
70
94
  */
71
95
  export declare function forkThreadSessionFile(sourceSessionFile: string, targetSessionFile: string, cwd: string): string;
96
+ export declare function createThreadSessionFileFromRootMessage(targetSessionFile: string, cwd: string, rootMessage: ThreadRootMessage, parentSession?: string): string;
97
+ export declare function forkThreadSessionFileFromRootMessage(sourceSessionFile: string, targetSessionFile: string, cwd: string, rootMessage: ThreadRootMessage): string;
72
98
  //# sourceMappingURL=session-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAIjF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAS/D;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAQhF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,GACV,cAAc,CAKhB;AAQD;;GAEG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAGvF;AAeD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEnF;AA6BD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAU1E;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE1E;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG3E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,GACV,MAAM,CAWR","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@mariozechner/pi-coding-agent\";\n\n/**\n * Returns the shared session directory for a channel.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n return sessionKey.includes(\":\") ? sessionKey.split(\":\").pop()! : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n writeFileSync(filePath, \"\", \"utf-8\");\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n writeFileSync(sessionFile, `${JSON.stringify(header)}\\n`, \"utf-8\");\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const lines = readFileSync(sessionFile, \"utf-8\").split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = JSON.parse(trimmed) as { type?: string };\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n if (!existsSync(pointerFile)) return null;\n const filename = readFileSync(pointerFile, \"utf-8\").trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const currentSessionPath = getCurrentSessionPath(sessionDir);\n if (\n currentSessionPath &&\n existsSync(currentSessionPath) &&\n hasSessionHeader(currentSessionPath)\n ) {\n return currentSessionPath;\n }\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n const channelSessionDir = getChannelSessionDir(channelDir);\n return tryResolveCurrentSession(channelSessionDir);\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n"]}
1
+ {"version":3,"file":"session-store.d.ts","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,qBAAa,uBAAwB,SAAQ,KAAK;IAChD,YAAY,WAAW,EAAE,MAAM,EAG9B;CACF;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,iBAAiB,EAAE,iBAAiB,GAAG,IAAI,CAAC;CAC7C;AAED,MAAM,WAAW,iCAAiC;IAChD,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;CACpB;AAcD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAIjF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAG9D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAE/D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAS/D;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAQhF;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,GACV,cAAc,CAShB;AAQD;;GAEG;AACH,wBAAgB,8BAA8B,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAGvF;AAeD;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEnF;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,oBAAoB,CAoBtB;AA4HD;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI1E;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE1E;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE3E;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,GACV,MAAM,CAWR;AAED,wBAAgB,sCAAsC,CACpD,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,iBAAiB,EAC9B,aAAa,CAAC,EAAE,MAAM,GACrB,MAAM,CAiCR;AAED,wBAAgB,oCAAoC,CAClD,iBAAiB,EAAE,MAAM,EACzB,iBAAiB,EAAE,MAAM,EACzB,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,iBAAiB,GAC7B,MAAM,CAqBR","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@mariozechner/pi-coding-agent\";\n\nexport class ThreadRootNotFoundError extends Error {\n constructor(sessionFile: string) {\n super(`Thread root message not found in source session: ${sessionFile}`);\n this.name = \"ThreadRootNotFoundError\";\n }\n}\n\nexport interface ThreadRootMessage {\n text?: string;\n userName?: string;\n user?: string;\n loggedAt?: number;\n}\n\nexport interface ResolvedSessionScope {\n sessionDir: string;\n contextFile: string;\n threadRootMessage: ThreadRootMessage | null;\n}\n\nexport interface ResolveGenericSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n}\n\ninterface SessionMessageEntryLike {\n type: string;\n id: string;\n parentId: string | null;\n timestamp: string;\n message?: {\n role?: string;\n timestamp?: number;\n content?: Array<{ type?: string; text?: string }> | string;\n };\n}\n\n/**\n * Returns the shared session directory for a conversation.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n return sessionKey.includes(\":\") ? sessionKey.split(\":\").pop()! : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n writeFileSync(filePath, \"\", \"utf-8\");\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n if (shouldRecreatePreinitializedSession(sessionFile)) {\n rmSync(sessionFile, { force: true });\n }\n\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n writeFileSync(sessionFile, `${JSON.stringify(header)}\\n`, \"utf-8\");\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\n/**\n * Resolve the default session scope for platforms without Slack-style branch forking.\n * Top-level/private sessions use the conversation's current pointer. Threaded or\n * per-message sessions use a fixed file derived from the session key suffix.\n */\nexport function resolveGenericSessionScope(\n options: ResolveGenericSessionScopeOptions,\n): ResolvedSessionScope {\n const { conversationDir, sessionKey } = options;\n const sessionDir = getChannelSessionDir(conversationDir);\n\n if (!sessionKey.includes(\":\")) {\n return {\n sessionDir,\n contextFile: resolveManagedSessionFile(sessionDir, conversationDir),\n threadRootMessage: null,\n };\n }\n\n const threadFile = getThreadSessionFile(conversationDir, sessionKey);\n return {\n sessionDir,\n contextFile:\n tryResolveThreadSession(threadFile) ??\n createManagedSessionFileAtPath(threadFile, conversationDir),\n threadRootMessage: null,\n };\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const lines = readFileSync(sessionFile, \"utf-8\").split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = JSON.parse(trimmed) as { type?: string };\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction shouldRecreatePreinitializedSession(sessionFile: string): boolean {\n if (!existsSync(sessionFile)) return false;\n\n try {\n const entries = readFileSync(sessionFile, \"utf-8\")\n .split(\"\\n\")\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => JSON.parse(line) as { type?: string });\n\n return entries.length === 1 && entries[0]?.type === \"session\";\n } catch {\n return false;\n }\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction resolveThreadSnapshotEntries(\n sourceSessionFile: string,\n rootMessage: ThreadRootMessage,\n): SessionMessageEntryLike[] | null {\n const targetText = buildComparableRootMessageText(rootMessage);\n if (!targetText) return null;\n\n const entries = SessionManager.open(sourceSessionFile).getEntries() as SessionMessageEntryLike[];\n const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);\n if (matchIndex === -1) return null;\n\n const nextTopLevelUserIndex = entries.findIndex(\n (entry, index) => index > matchIndex && isUserMessageEntry(entry),\n );\n const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;\n return entries.slice(0, endIndex);\n}\n\nfunction findRootMessageIndex(\n entries: SessionMessageEntryLike[],\n targetText: string,\n loggedAt?: number,\n): number {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (!isUserMessageEntry(entry)) continue;\n\n const comparableText = normalizeComparableUserText(getMessageText(entry));\n if (comparableText !== targetText) continue;\n\n const messageTimestamp = entry.message?.timestamp;\n if (\n loggedAt !== undefined &&\n typeof messageTimestamp === \"number\" &&\n messageTimestamp < loggedAt\n ) {\n continue;\n }\n\n return i;\n }\n\n return -1;\n}\n\nfunction isUserMessageEntry(entry: SessionMessageEntryLike): boolean {\n return entry.type === \"message\" && entry.message?.role === \"user\";\n}\n\nfunction getMessageText(entry: SessionMessageEntryLike): string {\n const content = entry.message?.content;\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n\n return content\n .filter((part): part is { type?: string; text?: string } => part.type === \"text\")\n .map((part) => part.text ?? \"\")\n .join(\"\\n\\n\");\n}\n\nfunction buildComparableRootMessageText(rootMessage: ThreadRootMessage): string | null {\n const userLabel = rootMessage.userName || rootMessage.user || \"unknown\";\n const text = rootMessage.text?.trim();\n if (!text) return null;\n return normalizeComparableUserText(`[${userLabel}]: ${text}`);\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\n /^\\[[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2}\\]\\s+(?=\\[[^\\]]+\\](?:\\s+\\[in-thread:[^\\]]+\\])?:\\s)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n if (!existsSync(pointerFile)) return null;\n const filename = readFileSync(pointerFile, \"utf-8\").trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const fullPath = getCurrentSessionPath(sessionDir);\n if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath)) return fullPath;\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n return tryResolveCurrentSession(getChannelSessionDir(channelDir));\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n\nexport function createThreadSessionFileFromRootMessage(\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n parentSession?: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n ...(parentSession ? { parentSession } : {}),\n };\n const rootText = buildComparableRootMessageText(rootMessage);\n if (!rootText) {\n writeFileSync(targetSessionFile, `${JSON.stringify(header)}\\n`, \"utf-8\");\n return targetSessionFile;\n }\n\n const rootEntry = {\n type: \"message\",\n id: randomUUID().slice(0, 8),\n parentId: null,\n timestamp: new Date().toISOString(),\n message: {\n role: \"user\",\n content: [{ type: \"text\", text: rootText }],\n ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),\n },\n };\n const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n writeFileSync(targetSessionFile, `${content}\\n`, \"utf-8\");\n return targetSessionFile;\n}\n\nexport function forkThreadSessionFileFromRootMessage(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n): string {\n const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);\n if (!snapshotEntries) {\n throw new ThreadRootNotFoundError(sourceSessionFile);\n }\n\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n parentSession: sourceSessionFile,\n };\n const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n writeFileSync(targetSessionFile, `${content}\\n`, \"utf-8\");\n return targetSessionFile;\n}\n"]}
@@ -2,8 +2,14 @@ import { randomUUID } from "crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { SessionManager } from "@mariozechner/pi-coding-agent";
5
+ export class ThreadRootNotFoundError extends Error {
6
+ constructor(sessionFile) {
7
+ super(`Thread root message not found in source session: ${sessionFile}`);
8
+ this.name = "ThreadRootNotFoundError";
9
+ }
10
+ }
5
11
  /**
6
- * Returns the shared session directory for a channel.
12
+ * Returns the shared session directory for a conversation.
7
13
  * Channel sessions use a current pointer within this directory.
8
14
  * Thread sessions are stored as fixed files within the same directory.
9
15
  */
@@ -78,6 +84,9 @@ export function createManagedSessionFile(sessionDir, cwd) {
78
84
  * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.
79
85
  */
80
86
  export function openManagedSession(sessionFile, sessionDir, cwd) {
87
+ if (shouldRecreatePreinitializedSession(sessionFile)) {
88
+ rmSync(sessionFile, { force: true });
89
+ }
81
90
  const SessionManagerCtor = SessionManager;
82
91
  return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);
83
92
  }
@@ -111,6 +120,29 @@ function writeSessionHeader(sessionFile, cwd, sessionId = randomUUID()) {
111
120
  export function getThreadSessionFile(channelDir, sessionKey) {
112
121
  return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);
113
122
  }
123
+ /**
124
+ * Resolve the default session scope for platforms without Slack-style branch forking.
125
+ * Top-level/private sessions use the conversation's current pointer. Threaded or
126
+ * per-message sessions use a fixed file derived from the session key suffix.
127
+ */
128
+ export function resolveGenericSessionScope(options) {
129
+ const { conversationDir, sessionKey } = options;
130
+ const sessionDir = getChannelSessionDir(conversationDir);
131
+ if (!sessionKey.includes(":")) {
132
+ return {
133
+ sessionDir,
134
+ contextFile: resolveManagedSessionFile(sessionDir, conversationDir),
135
+ threadRootMessage: null,
136
+ };
137
+ }
138
+ const threadFile = getThreadSessionFile(conversationDir, sessionKey);
139
+ return {
140
+ sessionDir,
141
+ contextFile: tryResolveThreadSession(threadFile) ??
142
+ createManagedSessionFileAtPath(threadFile, conversationDir),
143
+ threadRootMessage: null,
144
+ };
145
+ }
114
146
  function hasSessionHeader(sessionFile) {
115
147
  try {
116
148
  const lines = readFileSync(sessionFile, "utf-8").split("\n");
@@ -127,9 +159,82 @@ function hasSessionHeader(sessionFile) {
127
159
  }
128
160
  return false;
129
161
  }
162
+ function shouldRecreatePreinitializedSession(sessionFile) {
163
+ if (!existsSync(sessionFile))
164
+ return false;
165
+ try {
166
+ const entries = readFileSync(sessionFile, "utf-8")
167
+ .split("\n")
168
+ .map((line) => line.trim())
169
+ .filter(Boolean)
170
+ .map((line) => JSON.parse(line));
171
+ return entries.length === 1 && entries[0]?.type === "session";
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
130
177
  function getFileDir(sessionFile) {
131
178
  return sessionFile.substring(0, sessionFile.lastIndexOf("/"));
132
179
  }
180
+ function resolveThreadSnapshotEntries(sourceSessionFile, rootMessage) {
181
+ const targetText = buildComparableRootMessageText(rootMessage);
182
+ if (!targetText)
183
+ return null;
184
+ const entries = SessionManager.open(sourceSessionFile).getEntries();
185
+ const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);
186
+ if (matchIndex === -1)
187
+ return null;
188
+ const nextTopLevelUserIndex = entries.findIndex((entry, index) => index > matchIndex && isUserMessageEntry(entry));
189
+ const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;
190
+ return entries.slice(0, endIndex);
191
+ }
192
+ function findRootMessageIndex(entries, targetText, loggedAt) {
193
+ for (let i = 0; i < entries.length; i++) {
194
+ const entry = entries[i];
195
+ if (!isUserMessageEntry(entry))
196
+ continue;
197
+ const comparableText = normalizeComparableUserText(getMessageText(entry));
198
+ if (comparableText !== targetText)
199
+ continue;
200
+ const messageTimestamp = entry.message?.timestamp;
201
+ if (loggedAt !== undefined &&
202
+ typeof messageTimestamp === "number" &&
203
+ messageTimestamp < loggedAt) {
204
+ continue;
205
+ }
206
+ return i;
207
+ }
208
+ return -1;
209
+ }
210
+ function isUserMessageEntry(entry) {
211
+ return entry.type === "message" && entry.message?.role === "user";
212
+ }
213
+ function getMessageText(entry) {
214
+ const content = entry.message?.content;
215
+ if (typeof content === "string")
216
+ return content;
217
+ if (!Array.isArray(content))
218
+ return "";
219
+ return content
220
+ .filter((part) => part.type === "text")
221
+ .map((part) => part.text ?? "")
222
+ .join("\n\n");
223
+ }
224
+ function buildComparableRootMessageText(rootMessage) {
225
+ const userLabel = rootMessage.userName || rootMessage.user || "unknown";
226
+ const text = rootMessage.text?.trim();
227
+ if (!text)
228
+ return null;
229
+ return normalizeComparableUserText(`[${userLabel}]: ${text}`);
230
+ }
231
+ function stripSlackAttachmentBlock(text) {
232
+ return text.replace(/\n*<slack_attachments>\n[\s\S]*?\n<\/slack_attachments>\s*$/g, "");
233
+ }
234
+ function normalizeComparableUserText(text) {
235
+ const withoutTimestamp = text.replace(/^\[[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2}\]\s+(?=\[[^\]]+\](?:\s+\[in-thread:[^\]]+\])?:\s)/, "");
236
+ return stripSlackAttachmentBlock(withoutTimestamp).trim();
237
+ }
133
238
  function getCurrentSessionPath(sessionDir) {
134
239
  const pointerFile = join(sessionDir, "current");
135
240
  if (!existsSync(pointerFile))
@@ -144,12 +249,9 @@ function getCurrentSessionPath(sessionDir) {
144
249
  * Returns null if no current pointer exists or the pointed file has no valid session header.
145
250
  */
146
251
  export function tryResolveCurrentSession(sessionDir) {
147
- const currentSessionPath = getCurrentSessionPath(sessionDir);
148
- if (currentSessionPath &&
149
- existsSync(currentSessionPath) &&
150
- hasSessionHeader(currentSessionPath)) {
151
- return currentSessionPath;
152
- }
252
+ const fullPath = getCurrentSessionPath(sessionDir);
253
+ if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath))
254
+ return fullPath;
153
255
  return null;
154
256
  }
155
257
  /**
@@ -164,8 +266,7 @@ export function tryResolveThreadSession(sessionFile) {
164
266
  * Returns null if no channel session exists.
165
267
  */
166
268
  export function resolveChannelSessionFile(channelDir) {
167
- const channelSessionDir = getChannelSessionDir(channelDir);
168
- return tryResolveCurrentSession(channelSessionDir);
269
+ return tryResolveCurrentSession(getChannelSessionDir(channelDir));
169
270
  }
170
271
  /**
171
272
  * Fork a channel session into a fixed thread-session path.
@@ -183,4 +284,56 @@ export function forkThreadSessionFile(sourceSessionFile, targetSessionFile, cwd)
183
284
  renameSync(forkedFile, targetSessionFile);
184
285
  return targetSessionFile;
185
286
  }
287
+ export function createThreadSessionFileFromRootMessage(targetSessionFile, cwd, rootMessage, parentSession) {
288
+ const sessionDir = getFileDir(targetSessionFile);
289
+ mkdirSync(sessionDir, { recursive: true });
290
+ rmSync(targetSessionFile, { force: true });
291
+ const header = {
292
+ type: "session",
293
+ version: 3,
294
+ id: randomUUID(),
295
+ timestamp: new Date().toISOString(),
296
+ cwd,
297
+ ...(parentSession ? { parentSession } : {}),
298
+ };
299
+ const rootText = buildComparableRootMessageText(rootMessage);
300
+ if (!rootText) {
301
+ writeFileSync(targetSessionFile, `${JSON.stringify(header)}\n`, "utf-8");
302
+ return targetSessionFile;
303
+ }
304
+ const rootEntry = {
305
+ type: "message",
306
+ id: randomUUID().slice(0, 8),
307
+ parentId: null,
308
+ timestamp: new Date().toISOString(),
309
+ message: {
310
+ role: "user",
311
+ content: [{ type: "text", text: rootText }],
312
+ ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),
313
+ },
314
+ };
315
+ const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join("\n");
316
+ writeFileSync(targetSessionFile, `${content}\n`, "utf-8");
317
+ return targetSessionFile;
318
+ }
319
+ export function forkThreadSessionFileFromRootMessage(sourceSessionFile, targetSessionFile, cwd, rootMessage) {
320
+ const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);
321
+ if (!snapshotEntries) {
322
+ throw new ThreadRootNotFoundError(sourceSessionFile);
323
+ }
324
+ const sessionDir = getFileDir(targetSessionFile);
325
+ mkdirSync(sessionDir, { recursive: true });
326
+ rmSync(targetSessionFile, { force: true });
327
+ const header = {
328
+ type: "session",
329
+ version: 3,
330
+ id: randomUUID(),
331
+ timestamp: new Date().toISOString(),
332
+ cwd,
333
+ parentSession: sourceSessionFile,
334
+ };
335
+ const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join("\n");
336
+ writeFileSync(targetSessionFile, `${content}\n`, "utf-8");
337
+ return targetSessionFile;
338
+ }
186
339
  //# sourceMappingURL=session-store.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@mariozechner/pi-coding-agent\";\n\n/**\n * Returns the shared session directory for a channel.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n return sessionKey.includes(\":\") ? sessionKey.split(\":\").pop()! : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n writeFileSync(filePath, \"\", \"utf-8\");\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n writeFileSync(sessionFile, `${JSON.stringify(header)}\\n`, \"utf-8\");\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const lines = readFileSync(sessionFile, \"utf-8\").split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = JSON.parse(trimmed) as { type?: string };\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n if (!existsSync(pointerFile)) return null;\n const filename = readFileSync(pointerFile, \"utf-8\").trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const currentSessionPath = getCurrentSessionPath(sessionDir);\n if (\n currentSessionPath &&\n existsSync(currentSessionPath) &&\n hasSessionHeader(currentSessionPath)\n ) {\n return currentSessionPath;\n }\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n const channelSessionDir = getChannelSessionDir(channelDir);\n return tryResolveCurrentSession(channelSessionDir);\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n"]}
1
+ {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,IAAI,CAAC;AAC5F,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC5B,OAAO,EAAE,cAAc,EAAE,MAAM,+BAA+B,CAAC;AAE/D,MAAM,OAAO,uBAAwB,SAAQ,KAAK;IAChD,YAAY,WAAmB;QAC7B,KAAK,CAAC,oDAAoD,WAAW,EAAE,CAAC,CAAC;QACzE,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAgCD;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,OAAO,IAAI,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC;AACtC,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,UAAkB;IACnD,MAAM,QAAQ,GAAG,wBAAwB,CAAC,UAAU,CAAC,CAAC;IACtD,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,OAAO,oBAAoB,CAAC,UAAU,CAAC,CAAC;AAC1C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,UAAkB,EAAE,GAAW;IACvE,MAAM,YAAY,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC;IACvD,IAAI,YAAY;QAAE,OAAO,YAAY,CAAC;IACtC,OAAO,wBAAwB,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;AACnD,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAAC,WAAmB;IACpD,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,WAAW,CAAC;IACzD,OAAO,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC;AAC7D,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,OAAO,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAG,CAAC,CAAC,CAAC,UAAU,CAAC;AAC9E,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB;IACrD,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACjE,MAAM,IAAI,GAAG,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IACtC,MAAM,QAAQ,GAAG,GAAG,SAAS,IAAI,IAAI,QAAQ,CAAC;IAC9C,MAAM,QAAQ,GAAG,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;IAC5C,aAAa,CAAC,IAAI,CAAC,UAAU,EAAE,SAAS,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC9D,aAAa,CAAC,QAAQ,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC;IACrC,OAAO,QAAQ,CAAC;AAClB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,UAAkB,EAAE,GAAW;IACtE,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC,CAAC;IACjE,MAAM,SAAS,GAAG,UAAU,EAAE,CAAC;IAC/B,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,EAAE,GAAG,SAAS,IAAI,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;IACpF,kBAAkB,CAAC,WAAW,EAAE,GAAG,EAAE,SAAS,CAAC,CAAC;IAChD,iBAAiB,CAAC,UAAU,EAAE,WAAW,CAAC,CAAC;IAC3C,OAAO,WAAW,CAAC;AACrB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAAmB,EACnB,UAAkB,EAClB,GAAW;IAEX,IAAI,mCAAmC,CAAC,WAAW,CAAC,EAAE,CAAC;QACrD,MAAM,CAAC,WAAW,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IACvC,CAAC;IAED,MAAM,kBAAkB,GAAG,cAE1B,CAAC;IACF,OAAO,IAAI,kBAAkB,CAAC,GAAG,EAAE,UAAU,EAAE,WAAW,EAAE,IAAI,CAAC,CAAC;AACpE,CAAC;AAED,SAAS,iBAAiB,CAAC,UAAkB,EAAE,eAAuB;IACpE,MAAM,QAAQ,GAAG,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,EAAG,CAAC;IACnD,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,aAAa,CAAC,IAAI,CAAC,UAAU,EAAE,SAAS,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,CAAC;AAChE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,8BAA8B,CAAC,WAAmB,EAAE,GAAW;IAC7E,kBAAkB,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC;IACrC,OAAO,WAAW,CAAC;AACrB,CAAC;AAED,SAAS,kBAAkB,CAAC,WAAmB,EAAE,GAAW,EAAE,SAAS,GAAG,UAAU,EAAE;IACpF,MAAM,UAAU,GAAG,UAAU,CAAC,WAAW,CAAC,CAAC;IAC3C,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,MAAM,GAAG;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,CAAC;QACV,EAAE,EAAE,SAAS;QACb,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,GAAG;KACJ,CAAC;IACF,aAAa,CAAC,WAAW,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AACrE,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,UAAkB,EAAE,UAAkB;IACzE,OAAO,IAAI,CAAC,oBAAoB,CAAC,UAAU,CAAC,EAAE,GAAG,oBAAoB,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC;AAC7F,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,0BAA0B,CACxC,OAA0C;IAE1C,MAAM,EAAE,eAAe,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAChD,MAAM,UAAU,GAAG,oBAAoB,CAAC,eAAe,CAAC,CAAC;IAEzD,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC9B,OAAO;YACL,UAAU;YACV,WAAW,EAAE,yBAAyB,CAAC,UAAU,EAAE,eAAe,CAAC;YACnE,iBAAiB,EAAE,IAAI;SACxB,CAAC;IACJ,CAAC;IAED,MAAM,UAAU,GAAG,oBAAoB,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;IACrE,OAAO;QACL,UAAU;QACV,WAAW,EACT,uBAAuB,CAAC,UAAU,CAAC;YACnC,8BAA8B,CAAC,UAAU,EAAE,eAAe,CAAC;QAC7D,iBAAiB,EAAE,IAAI;KACxB,CAAC;AACJ,CAAC;AAED,SAAS,gBAAgB,CAAC,WAAmB;IAC3C,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC7D,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YAC5B,IAAI,CAAC,OAAO;gBAAE,SAAS;YACvB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAsB,CAAC;YACvD,OAAO,KAAK,CAAC,IAAI,KAAK,SAAS,CAAC;QAClC,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,mCAAmC,CAAC,WAAmB;IAC9D,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,KAAK,CAAC;IAE3C,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC;aAC/C,KAAK,CAAC,IAAI,CAAC;aACX,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;aAC1B,MAAM,CAAC,OAAO,CAAC;aACf,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAsB,CAAC,CAAC;QAExD,OAAO,OAAO,CAAC,MAAM,KAAK,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,KAAK,SAAS,CAAC;IAChE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,WAAmB;IACrC,OAAO,WAAW,CAAC,SAAS,CAAC,CAAC,EAAE,WAAW,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC;AAChE,CAAC;AAED,SAAS,4BAA4B,CACnC,iBAAyB,EACzB,WAA8B;IAE9B,MAAM,UAAU,GAAG,8BAA8B,CAAC,WAAW,CAAC,CAAC;IAC/D,IAAI,CAAC,UAAU;QAAE,OAAO,IAAI,CAAC;IAE7B,MAAM,OAAO,GAAG,cAAc,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,UAAU,EAA+B,CAAC;IACjG,MAAM,UAAU,GAAG,oBAAoB,CAAC,OAAO,EAAE,UAAU,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAC;IACnF,IAAI,UAAU,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IAEnC,MAAM,qBAAqB,GAAG,OAAO,CAAC,SAAS,CAC7C,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,GAAG,UAAU,IAAI,kBAAkB,CAAC,KAAK,CAAC,CAClE,CAAC;IACF,MAAM,QAAQ,GAAG,qBAAqB,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,qBAAqB,CAAC;IACvF,OAAO,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;AACpC,CAAC;AAED,SAAS,oBAAoB,CAC3B,OAAkC,EAClC,UAAkB,EAClB,QAAiB;IAEjB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACxC,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;QACzB,IAAI,CAAC,kBAAkB,CAAC,KAAK,CAAC;YAAE,SAAS;QAEzC,MAAM,cAAc,GAAG,2BAA2B,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC;QAC1E,IAAI,cAAc,KAAK,UAAU;YAAE,SAAS;QAE5C,MAAM,gBAAgB,GAAG,KAAK,CAAC,OAAO,EAAE,SAAS,CAAC;QAClD,IACE,QAAQ,KAAK,SAAS;YACtB,OAAO,gBAAgB,KAAK,QAAQ;YACpC,gBAAgB,GAAG,QAAQ,EAC3B,CAAC;YACD,SAAS;QACX,CAAC;QAED,OAAO,CAAC,CAAC;IACX,CAAC;IAED,OAAO,CAAC,CAAC,CAAC;AACZ,CAAC;AAED,SAAS,kBAAkB,CAAC,KAA8B;IACxD,OAAO,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,EAAE,IAAI,KAAK,MAAM,CAAC;AACpE,CAAC;AAED,SAAS,cAAc,CAAC,KAA8B;IACpD,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC;IACvC,IAAI,OAAO,OAAO,KAAK,QAAQ;QAAE,OAAO,OAAO,CAAC;IAChD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,OAAO,OAAO;SACX,MAAM,CAAC,CAAC,IAAI,EAA4C,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC;SAChF,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,IAAI,EAAE,CAAC;SAC9B,IAAI,CAAC,MAAM,CAAC,CAAC;AAClB,CAAC;AAED,SAAS,8BAA8B,CAAC,WAA8B;IACpE,MAAM,SAAS,GAAG,WAAW,CAAC,QAAQ,IAAI,WAAW,CAAC,IAAI,IAAI,SAAS,CAAC;IACxE,MAAM,IAAI,GAAG,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC;IACtC,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,OAAO,2BAA2B,CAAC,IAAI,SAAS,MAAM,IAAI,EAAE,CAAC,CAAC;AAChE,CAAC;AAED,SAAS,yBAAyB,CAAC,IAAY;IAC7C,OAAO,IAAI,CAAC,OAAO,CAAC,8DAA8D,EAAE,EAAE,CAAC,CAAC;AAC1F,CAAC;AAED,SAAS,2BAA2B,CAAC,IAAY;IAC/C,MAAM,gBAAgB,GAAG,IAAI,CAAC,OAAO,CACnC,iIAAiI,EACjI,EAAE,CACH,CAAC;IACF,OAAO,yBAAyB,CAAC,gBAAgB,CAAC,CAAC,IAAI,EAAE,CAAC;AAC5D,CAAC;AAED,SAAS,qBAAqB,CAAC,UAAkB;IAC/C,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,EAAE,SAAS,CAAC,CAAC;IAChD,IAAI,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,IAAI,CAAC;IAC1C,MAAM,QAAQ,GAAG,YAAY,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;IAC3D,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3B,OAAO,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;AACpC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,UAAkB;IACzD,MAAM,QAAQ,GAAG,qBAAqB,CAAC,UAAU,CAAC,CAAC;IACnD,IAAI,QAAQ,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,gBAAgB,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAC;IACpF,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CAAC,WAAmB;IACzD,OAAO,UAAU,CAAC,WAAW,CAAC,IAAI,gBAAgB,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC;AACvF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CAAC,UAAkB;IAC1D,OAAO,wBAAwB,CAAC,oBAAoB,CAAC,UAAU,CAAC,CAAC,CAAC;AACpE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,qBAAqB,CACnC,iBAAyB,EACzB,iBAAyB,EACzB,GAAW;IAEX,MAAM,UAAU,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC;IACjD,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,MAAM,GAAG,cAAc,CAAC,QAAQ,CAAC,iBAAiB,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC;IAC3E,MAAM,UAAU,GAAG,MAAM,CAAC,cAAc,EAAE,CAAC;IAC3C,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,MAAM,IAAI,KAAK,CAAC,+BAA+B,iBAAiB,EAAE,CAAC,CAAC;IACtE,CAAC;IACD,MAAM,CAAC,iBAAiB,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,UAAU,CAAC,UAAU,EAAE,iBAAiB,CAAC,CAAC;IAC1C,OAAO,iBAAiB,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,sCAAsC,CACpD,iBAAyB,EACzB,GAAW,EACX,WAA8B,EAC9B,aAAsB;IAEtB,MAAM,UAAU,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC;IACjD,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,CAAC,iBAAiB,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAE3C,MAAM,MAAM,GAAG;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,CAAC;QACV,EAAE,EAAE,UAAU,EAAE;QAChB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,GAAG;QACH,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC5C,CAAC;IACF,MAAM,QAAQ,GAAG,8BAA8B,CAAC,WAAW,CAAC,CAAC;IAC7D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,aAAa,CAAC,iBAAiB,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACzE,OAAO,iBAAiB,CAAC;IAC3B,CAAC;IAED,MAAM,SAAS,GAAG;QAChB,IAAI,EAAE,SAAS;QACf,EAAE,EAAE,UAAU,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;QAC5B,QAAQ,EAAE,IAAI;QACd,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,OAAO,EAAE;YACP,IAAI,EAAE,MAAM;YACZ,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YAC3C,GAAG,CAAC,WAAW,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,WAAW,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACnF;KACF,CAAC;IACF,MAAM,OAAO,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACrF,aAAa,CAAC,iBAAiB,EAAE,GAAG,OAAO,IAAI,EAAE,OAAO,CAAC,CAAC;IAC1D,OAAO,iBAAiB,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,oCAAoC,CAClD,iBAAyB,EACzB,iBAAyB,EACzB,GAAW,EACX,WAA8B;IAE9B,MAAM,eAAe,GAAG,4BAA4B,CAAC,iBAAiB,EAAE,WAAW,CAAC,CAAC;IACrF,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,MAAM,IAAI,uBAAuB,CAAC,iBAAiB,CAAC,CAAC;IACvD,CAAC;IAED,MAAM,UAAU,GAAG,UAAU,CAAC,iBAAiB,CAAC,CAAC;IACjD,SAAS,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,CAAC,iBAAiB,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAE3C,MAAM,MAAM,GAAG;QACb,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,CAAC;QACV,EAAE,EAAE,UAAU,EAAE;QAChB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QACnC,GAAG;QACH,aAAa,EAAE,iBAAiB;KACjC,CAAC;IACF,MAAM,OAAO,GAAG,CAAC,MAAM,EAAE,GAAG,eAAe,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9F,aAAa,CAAC,iBAAiB,EAAE,GAAG,OAAO,IAAI,EAAE,OAAO,CAAC,CAAC;IAC1D,OAAO,iBAAiB,CAAC;AAC3B,CAAC","sourcesContent":["import { randomUUID } from \"crypto\";\nimport { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from \"fs\";\nimport { join } from \"path\";\nimport { SessionManager } from \"@mariozechner/pi-coding-agent\";\n\nexport class ThreadRootNotFoundError extends Error {\n constructor(sessionFile: string) {\n super(`Thread root message not found in source session: ${sessionFile}`);\n this.name = \"ThreadRootNotFoundError\";\n }\n}\n\nexport interface ThreadRootMessage {\n text?: string;\n userName?: string;\n user?: string;\n loggedAt?: number;\n}\n\nexport interface ResolvedSessionScope {\n sessionDir: string;\n contextFile: string;\n threadRootMessage: ThreadRootMessage | null;\n}\n\nexport interface ResolveGenericSessionScopeOptions {\n conversationDir: string;\n sessionKey: string;\n}\n\ninterface SessionMessageEntryLike {\n type: string;\n id: string;\n parentId: string | null;\n timestamp: string;\n message?: {\n role?: string;\n timestamp?: number;\n content?: Array<{ type?: string; text?: string }> | string;\n };\n}\n\n/**\n * Returns the shared session directory for a conversation.\n * Channel sessions use a current pointer within this directory.\n * Thread sessions are stored as fixed files within the same directory.\n */\nexport function getChannelSessionDir(channelDir: string): string {\n return join(channelDir, \"sessions\");\n}\n\n/**\n * Resolves the current active session file for a session directory.\n * Reads the \"current\" pointer file; creates a new session if none exists\n * or the pointed-to file is missing.\n */\nexport function resolveSessionFile(sessionDir: string): string {\n const existing = tryResolveCurrentSession(sessionDir);\n if (existing) return existing;\n return createNewSessionFile(sessionDir);\n}\n\n/**\n * Resolve the current active session file for a session directory.\n * Creates a fully initialized persistent session with the provided cwd when none exists.\n */\nexport function resolveManagedSessionFile(sessionDir: string, cwd: string): string {\n const existingPath = getCurrentSessionPath(sessionDir);\n if (existingPath) return existingPath;\n return createManagedSessionFile(sessionDir, cwd);\n}\n\n/**\n * Extracts the short UUID from a session file path.\n * e.g. \"2026-04-05T00-00_7b54cf90.jsonl\" → \"7b54cf90\"\n */\nexport function extractSessionUuid(sessionFile: string): string {\n const base = sessionFile.split(\"/\").pop() ?? sessionFile;\n return base.replace(\".jsonl\", \"\").split(\"_\").pop() ?? base;\n}\n\n/**\n * Extracts the thread/suffix part of a session key.\n * \"channelId:threadId\" → \"threadId\", \"channelId\" → \"channelId\"\n */\nexport function extractSessionSuffix(sessionKey: string): string {\n return sessionKey.includes(\":\") ? sessionKey.split(\":\").pop()! : sessionKey;\n}\n\n/**\n * Creates an empty timestamped file and updates the \"current\" pointer.\n * Used only by tests for placeholder-file scenarios.\n */\nexport function createNewSessionFile(sessionDir: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const uuid = randomUUID().slice(0, 8);\n const filename = `${timestamp}_${uuid}.jsonl`;\n const filePath = join(sessionDir, filename);\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n writeFileSync(filePath, \"\", \"utf-8\");\n return filePath;\n}\n\n/**\n * Creates a new persistent session file with a proper SessionManager header and cwd.\n * Also updates the \"current\" pointer.\n */\nexport function createManagedSessionFile(sessionDir: string, cwd: string): string {\n mkdirSync(sessionDir, { recursive: true });\n const timestamp = new Date().toISOString().replace(/[:.]/g, \"-\");\n const sessionId = randomUUID();\n const sessionFile = join(sessionDir, `${timestamp}_${sessionId.slice(0, 8)}.jsonl`);\n writeSessionHeader(sessionFile, cwd, sessionId);\n setCurrentPointer(sessionDir, sessionFile);\n return sessionFile;\n}\n\n/**\n * Open a session file with an explicit cwd, even if the file does not exist yet.\n * This avoids SessionManager.open() falling back to process.cwd() for fresh sessions.\n */\nexport function openManagedSession(\n sessionFile: string,\n sessionDir: string,\n cwd: string,\n): SessionManager {\n if (shouldRecreatePreinitializedSession(sessionFile)) {\n rmSync(sessionFile, { force: true });\n }\n\n const SessionManagerCtor = SessionManager as unknown as {\n new (cwd: string, sessionDir: string, sessionFile: string, persist: boolean): SessionManager;\n };\n return new SessionManagerCtor(cwd, sessionDir, sessionFile, true);\n}\n\nfunction setCurrentPointer(sessionDir: string, sessionFilePath: string): void {\n const filename = sessionFilePath.split(\"/\").pop()!;\n mkdirSync(sessionDir, { recursive: true });\n writeFileSync(join(sessionDir, \"current\"), filename, \"utf-8\");\n}\n\n/**\n * Creates or overwrites a fixed-path session file with a valid session header.\n */\nexport function createManagedSessionFileAtPath(sessionFile: string, cwd: string): string {\n writeSessionHeader(sessionFile, cwd);\n return sessionFile;\n}\n\nfunction writeSessionHeader(sessionFile: string, cwd: string, sessionId = randomUUID()): void {\n const sessionDir = getFileDir(sessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const header = {\n type: \"session\",\n version: 3,\n id: sessionId,\n timestamp: new Date().toISOString(),\n cwd,\n };\n writeFileSync(sessionFile, `${JSON.stringify(header)}\\n`, \"utf-8\");\n}\n\n/**\n * Returns the fixed session file path for a Slack thread.\n */\nexport function getThreadSessionFile(channelDir: string, sessionKey: string): string {\n return join(getChannelSessionDir(channelDir), `${extractSessionSuffix(sessionKey)}.jsonl`);\n}\n\n/**\n * Resolve the default session scope for platforms without Slack-style branch forking.\n * Top-level/private sessions use the conversation's current pointer. Threaded or\n * per-message sessions use a fixed file derived from the session key suffix.\n */\nexport function resolveGenericSessionScope(\n options: ResolveGenericSessionScopeOptions,\n): ResolvedSessionScope {\n const { conversationDir, sessionKey } = options;\n const sessionDir = getChannelSessionDir(conversationDir);\n\n if (!sessionKey.includes(\":\")) {\n return {\n sessionDir,\n contextFile: resolveManagedSessionFile(sessionDir, conversationDir),\n threadRootMessage: null,\n };\n }\n\n const threadFile = getThreadSessionFile(conversationDir, sessionKey);\n return {\n sessionDir,\n contextFile:\n tryResolveThreadSession(threadFile) ??\n createManagedSessionFileAtPath(threadFile, conversationDir),\n threadRootMessage: null,\n };\n}\n\nfunction hasSessionHeader(sessionFile: string): boolean {\n try {\n const lines = readFileSync(sessionFile, \"utf-8\").split(\"\\n\");\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n const entry = JSON.parse(trimmed) as { type?: string };\n return entry.type === \"session\";\n }\n } catch {\n return false;\n }\n return false;\n}\n\nfunction shouldRecreatePreinitializedSession(sessionFile: string): boolean {\n if (!existsSync(sessionFile)) return false;\n\n try {\n const entries = readFileSync(sessionFile, \"utf-8\")\n .split(\"\\n\")\n .map((line) => line.trim())\n .filter(Boolean)\n .map((line) => JSON.parse(line) as { type?: string });\n\n return entries.length === 1 && entries[0]?.type === \"session\";\n } catch {\n return false;\n }\n}\n\nfunction getFileDir(sessionFile: string): string {\n return sessionFile.substring(0, sessionFile.lastIndexOf(\"/\"));\n}\n\nfunction resolveThreadSnapshotEntries(\n sourceSessionFile: string,\n rootMessage: ThreadRootMessage,\n): SessionMessageEntryLike[] | null {\n const targetText = buildComparableRootMessageText(rootMessage);\n if (!targetText) return null;\n\n const entries = SessionManager.open(sourceSessionFile).getEntries() as SessionMessageEntryLike[];\n const matchIndex = findRootMessageIndex(entries, targetText, rootMessage.loggedAt);\n if (matchIndex === -1) return null;\n\n const nextTopLevelUserIndex = entries.findIndex(\n (entry, index) => index > matchIndex && isUserMessageEntry(entry),\n );\n const endIndex = nextTopLevelUserIndex === -1 ? entries.length : nextTopLevelUserIndex;\n return entries.slice(0, endIndex);\n}\n\nfunction findRootMessageIndex(\n entries: SessionMessageEntryLike[],\n targetText: string,\n loggedAt?: number,\n): number {\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (!isUserMessageEntry(entry)) continue;\n\n const comparableText = normalizeComparableUserText(getMessageText(entry));\n if (comparableText !== targetText) continue;\n\n const messageTimestamp = entry.message?.timestamp;\n if (\n loggedAt !== undefined &&\n typeof messageTimestamp === \"number\" &&\n messageTimestamp < loggedAt\n ) {\n continue;\n }\n\n return i;\n }\n\n return -1;\n}\n\nfunction isUserMessageEntry(entry: SessionMessageEntryLike): boolean {\n return entry.type === \"message\" && entry.message?.role === \"user\";\n}\n\nfunction getMessageText(entry: SessionMessageEntryLike): string {\n const content = entry.message?.content;\n if (typeof content === \"string\") return content;\n if (!Array.isArray(content)) return \"\";\n\n return content\n .filter((part): part is { type?: string; text?: string } => part.type === \"text\")\n .map((part) => part.text ?? \"\")\n .join(\"\\n\\n\");\n}\n\nfunction buildComparableRootMessageText(rootMessage: ThreadRootMessage): string | null {\n const userLabel = rootMessage.userName || rootMessage.user || \"unknown\";\n const text = rootMessage.text?.trim();\n if (!text) return null;\n return normalizeComparableUserText(`[${userLabel}]: ${text}`);\n}\n\nfunction stripSlackAttachmentBlock(text: string): string {\n return text.replace(/\\n*<slack_attachments>\\n[\\s\\S]*?\\n<\\/slack_attachments>\\s*$/g, \"\");\n}\n\nfunction normalizeComparableUserText(text: string): string {\n const withoutTimestamp = text.replace(\n /^\\[[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2}\\]\\s+(?=\\[[^\\]]+\\](?:\\s+\\[in-thread:[^\\]]+\\])?:\\s)/,\n \"\",\n );\n return stripSlackAttachmentBlock(withoutTimestamp).trim();\n}\n\nfunction getCurrentSessionPath(sessionDir: string): string | null {\n const pointerFile = join(sessionDir, \"current\");\n if (!existsSync(pointerFile)) return null;\n const filename = readFileSync(pointerFile, \"utf-8\").trim();\n if (!filename) return null;\n return join(sessionDir, filename);\n}\n\n/**\n * Try to resolve an existing current session file.\n * Returns null if no current pointer exists or the pointed file has no valid session header.\n */\nexport function tryResolveCurrentSession(sessionDir: string): string | null {\n const fullPath = getCurrentSessionPath(sessionDir);\n if (fullPath && existsSync(fullPath) && hasSessionHeader(fullPath)) return fullPath;\n return null;\n}\n\n/**\n * Try to resolve an existing thread session file.\n * Returns the file path if found, or null if no valid thread session exists yet.\n */\nexport function tryResolveThreadSession(sessionFile: string): string | null {\n return existsSync(sessionFile) && hasSessionHeader(sessionFile) ? sessionFile : null;\n}\n\n/**\n * Resolve the channel's current session file path (for fork source).\n * Returns null if no channel session exists.\n */\nexport function resolveChannelSessionFile(channelDir: string): string | null {\n return tryResolveCurrentSession(getChannelSessionDir(channelDir));\n}\n\n/**\n * Fork a channel session into a fixed thread-session path.\n * The resulting file keeps forkFrom's distinct session/header metadata.\n */\nexport function forkThreadSessionFile(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n const forked = SessionManager.forkFrom(sourceSessionFile, cwd, sessionDir);\n const forkedFile = forked.getSessionFile();\n if (!forkedFile) {\n throw new Error(`Failed to fork session from ${sourceSessionFile}`);\n }\n rmSync(targetSessionFile, { force: true });\n renameSync(forkedFile, targetSessionFile);\n return targetSessionFile;\n}\n\nexport function createThreadSessionFileFromRootMessage(\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n parentSession?: string,\n): string {\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n ...(parentSession ? { parentSession } : {}),\n };\n const rootText = buildComparableRootMessageText(rootMessage);\n if (!rootText) {\n writeFileSync(targetSessionFile, `${JSON.stringify(header)}\\n`, \"utf-8\");\n return targetSessionFile;\n }\n\n const rootEntry = {\n type: \"message\",\n id: randomUUID().slice(0, 8),\n parentId: null,\n timestamp: new Date().toISOString(),\n message: {\n role: \"user\",\n content: [{ type: \"text\", text: rootText }],\n ...(rootMessage.loggedAt !== undefined ? { timestamp: rootMessage.loggedAt } : {}),\n },\n };\n const content = [header, rootEntry].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n writeFileSync(targetSessionFile, `${content}\\n`, \"utf-8\");\n return targetSessionFile;\n}\n\nexport function forkThreadSessionFileFromRootMessage(\n sourceSessionFile: string,\n targetSessionFile: string,\n cwd: string,\n rootMessage: ThreadRootMessage,\n): string {\n const snapshotEntries = resolveThreadSnapshotEntries(sourceSessionFile, rootMessage);\n if (!snapshotEntries) {\n throw new ThreadRootNotFoundError(sourceSessionFile);\n }\n\n const sessionDir = getFileDir(targetSessionFile);\n mkdirSync(sessionDir, { recursive: true });\n rmSync(targetSessionFile, { force: true });\n\n const header = {\n type: \"session\",\n version: 3,\n id: randomUUID(),\n timestamp: new Date().toISOString(),\n cwd,\n parentSession: sourceSessionFile,\n };\n const content = [header, ...snapshotEntries].map((entry) => JSON.stringify(entry)).join(\"\\n\");\n writeFileSync(targetSessionFile, `${content}\\n`, \"utf-8\");\n return targetSessionFile;\n}\n"]}
@@ -0,0 +1,5 @@
1
+ export interface ParsedSessionViewCommand {
2
+ command: "session" | "/session" | "/pi-session";
3
+ }
4
+ export declare function parseSessionViewCommand(text: string): ParsedSessionViewCommand | null;
5
+ //# sourceMappingURL=command.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.d.ts","sourceRoot":"","sources":["../../src/session-view/command.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,wBAAwB;IACvC,OAAO,EAAE,SAAS,GAAG,UAAU,GAAG,aAAa,CAAC;CACjD;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,wBAAwB,GAAG,IAAI,CAUrF","sourcesContent":["export interface ParsedSessionViewCommand {\n command: \"session\" | \"/session\" | \"/pi-session\";\n}\n\nexport function parseSessionViewCommand(text: string): ParsedSessionViewCommand | null {\n const tokens = text.trim().split(/\\s+/).filter(Boolean);\n if (tokens.length === 0) return null;\n\n const command = tokens[0].toLowerCase();\n if (command !== \"session\" && command !== \"/session\" && command !== \"/pi-session\") {\n return null;\n }\n\n return { command: command as ParsedSessionViewCommand[\"command\"] };\n}\n"]}
@@ -0,0 +1,11 @@
1
+ export function parseSessionViewCommand(text) {
2
+ const tokens = text.trim().split(/\s+/).filter(Boolean);
3
+ if (tokens.length === 0)
4
+ return null;
5
+ const command = tokens[0].toLowerCase();
6
+ if (command !== "session" && command !== "/session" && command !== "/pi-session") {
7
+ return null;
8
+ }
9
+ return { command: command };
10
+ }
11
+ //# sourceMappingURL=command.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"command.js","sourceRoot":"","sources":["../../src/session-view/command.ts"],"names":[],"mappings":"AAIA,MAAM,UAAU,uBAAuB,CAAC,IAAY;IAClD,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACxD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAErC,MAAM,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC;IACxC,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,KAAK,UAAU,IAAI,OAAO,KAAK,aAAa,EAAE,CAAC;QACjF,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,OAA8C,EAAE,CAAC;AACrE,CAAC","sourcesContent":["export interface ParsedSessionViewCommand {\n command: \"session\" | \"/session\" | \"/pi-session\";\n}\n\nexport function parseSessionViewCommand(text: string): ParsedSessionViewCommand | null {\n const tokens = text.trim().split(/\\s+/).filter(Boolean);\n if (tokens.length === 0) return null;\n\n const command = tokens[0].toLowerCase();\n if (command !== \"session\" && command !== \"/session\" && command !== \"/pi-session\") {\n return null;\n }\n\n return { command: command as ParsedSessionViewCommand[\"command\"] };\n}\n"]}
@@ -0,0 +1,9 @@
1
+ import type { IncomingMessage, ServerResponse } from "http";
2
+ import type { InMemorySessionViewTokenStore } from "./store.js";
3
+ export declare function handleSessionViewRequest(req: IncomingMessage, res: ServerResponse, url: URL, sessionViewTokenStore?: InMemorySessionViewTokenStore): Promise<boolean>;
4
+ export declare function parseUserBody(raw: string): {
5
+ username: string | null;
6
+ threadTs: string | null;
7
+ content: string;
8
+ };
9
+ //# sourceMappingURL=portal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../src/session-view/portal.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAQ5D,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,YAAY,CAAC;AAEhE,wBAAsB,wBAAwB,CAC5C,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,GAAG,EACR,qBAAqB,CAAC,EAAE,6BAA6B,GACpD,OAAO,CAAC,OAAO,CAAC,CAgDlB;AA4FD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG;IAC1C,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;CACjB,CAcA","sourcesContent":["import type { IncomingMessage, ServerResponse } from \"http\";\nimport * as log from \"../log.js\";\nimport {\n loadSessionViewModel,\n resolveRequestedSessionFile,\n type SessionViewItem,\n type SessionViewRelation,\n} from \"./service.js\";\nimport type { InMemorySessionViewTokenStore } from \"./store.js\";\n\nexport async function handleSessionViewRequest(\n req: IncomingMessage,\n res: ServerResponse,\n url: URL,\n sessionViewTokenStore?: InMemorySessionViewTokenStore,\n): Promise<boolean> {\n if (req.method !== \"GET\" || url.pathname !== \"/session\") {\n return false;\n }\n\n const token = url.searchParams.get(\"token\")?.trim();\n if (!token || !sessionViewTokenStore) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderStatusPage(\"Session unavailable\", \"This session link is invalid or has expired.\"),\n );\n return true;\n }\n\n const entry = sessionViewTokenStore.peek(token);\n if (!entry) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(\n renderStatusPage(\"Session unavailable\", \"This session link is invalid or has expired.\"),\n );\n return true;\n }\n\n const requestedSession = url.searchParams.get(\"session\");\n const targetSessionFile = resolveRequestedSessionFile(entry.sessionFile, requestedSession);\n if (!targetSessionFile) {\n res.writeHead(400, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderStatusPage(\"Session unavailable\", \"The selected session link is invalid.\"));\n return true;\n }\n\n try {\n const model = loadSessionViewModel(targetSessionFile);\n res.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Cache-Control\": \"no-store\",\n });\n res.end(renderSessionPage(model, entry.token, entry.expiresAt));\n } catch (error) {\n log.logWarning(\n `[${entry.conversationId}] Failed to render session ${entry.sessionFile}`,\n error instanceof Error ? error.message : String(error),\n );\n res.writeHead(500, { \"Content-Type\": \"text/html; charset=utf-8\" });\n res.end(renderStatusPage(\"Session unavailable\", \"The session could not be loaded right now.\"));\n }\n\n return true;\n}\n\nfunction renderSessionPage(\n model: {\n title: string;\n sessionId: string;\n fileName: string;\n createdAt: string;\n updatedAt: string;\n entryCount: number;\n items: SessionViewItem[];\n parent?: SessionViewRelation;\n forks: SessionViewRelation[];\n },\n token: string,\n expiresAt: number,\n): string {\n const items =\n model.items.length > 0\n ? model.items.map((item) => renderItem(item, token)).join(\"\\n\")\n : `<div class=\"system-event\"><span class=\"event-dot\"></span><span class=\"event-text\">No messages yet — send one to the bot, then refresh.</span></div>`;\n\n const relatedSections = model.parent\n ? `<section class=\"related-card stack\">\n <p class=\"eyebrow\">Forked from</p>\n ${renderRelationCard(model.parent, token)}\n </section>`\n : \"\";\n\n return renderHtmlDocument(\n `${model.title} · Session Viewer`,\n `<header class=\"hero-card\">\n <div class=\"hero-top\">\n <div class=\"hero-title-group\">\n <span class=\"hero-wordmark\">mama</span>\n <h1 class=\"hero-title\">${esc(model.title)}</h1>\n </div>\n <button class=\"refresh-btn\" onclick=\"window.location.reload()\">\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\"><path d=\"M12.5 2.5A6 6 0 1 0 13 7\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/><path d=\"M10 2.5h2.5V5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n Refresh\n </button>\n </div>\n <div class=\"stat-row\">\n ${renderSummaryItem(\"ID\", model.sessionId.slice(0, 8))}\n ${renderSummaryItem(\"File\", model.fileName)}\n ${renderSummaryItem(\"Created\", formatDate(model.createdAt))}\n ${renderSummaryItem(\"Updated\", formatDate(model.updatedAt))}\n ${renderSummaryItem(\"Entries\", String(model.entryCount))}\n ${renderSummaryItem(\"Expires\", formatDate(new Date(expiresAt).toISOString()))}\n </div>\n </header>\n\n ${relatedSections}\n\n <main class=\"timeline-shell\">\n <div class=\"timeline-list\">\n ${items}\n </div>\n </main>`,\n );\n}\n\nfunction renderSummaryItem(label: string, value: string): string {\n return `<span class=\"stat-chip\"><span class=\"stat-label\">${esc(label)}</span><strong class=\"stat-value\">${esc(value)}</strong></span>`;\n}\n\nfunction renderRelationCard(relation: SessionViewRelation, token: string): string {\n const href = `/session?token=${encodeURIComponent(token)}&session=${encodeURIComponent(relation.fileName)}`;\n const summary = relation.summary ? `<p class=\"related-summary\">${esc(relation.summary)}</p>` : \"\";\n return `<a class=\"related-link\" href=\"${href}\">\n <span class=\"related-copy\">\n <strong class=\"related-title\">${esc(relation.title)}</strong>\n ${summary}\n <span class=\"related-meta\">${esc(formatDate(relation.updatedAt))} · ${esc(String(relation.entryCount))} entries · ${esc(relation.fileName)}</span>\n </span>\n <span class=\"related-arrow\" aria-hidden=\"true\">→</span>\n </a>`;\n}\n\nfunction renderForkLinks(relations: SessionViewRelation[] | undefined, token: string): string {\n if (!relations || relations.length === 0) return \"\";\n return `<div class=\"fork-links\">${relations\n .map((relation) => {\n const href = `/session?token=${encodeURIComponent(token)}&session=${encodeURIComponent(relation.fileName)}`;\n return `<a class=\"fork-link\" href=\"${href}\" title=\"Open ${esc(relation.title)}\">\n <span class=\"fork-dot\" aria-hidden=\"true\"></span>\n <span class=\"fork-text\">Thread</span>\n </a>`;\n })\n .join(\"\")}</div>`;\n}\n\nexport function parseUserBody(raw: string): {\n username: string | null;\n threadTs: string | null;\n content: string;\n} {\n // [timestamp] [username] [in-thread:ts]: content\n let m = raw.match(\n /^\\[[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2}\\]\\s*\\[([^\\]]+)\\](?:\\s*\\[in-thread:([^\\]]+)\\])?:\\s*([\\s\\S]*)$/,\n );\n if (m) {\n return { username: m[1], threadTs: m[2] ?? null, content: m[3] };\n }\n // [username] [in-thread:ts]: content\n m = raw.match(/^\\[([^\\]]+)\\](?:\\s*\\[in-thread:([^\\]]+)\\])?:\\s*([\\s\\S]*)$/);\n if (m) {\n return { username: m[1], threadTs: m[2] ?? null, content: m[3] };\n }\n return { username: null, threadTs: null, content: raw };\n}\n\nfunction renderItem(item: SessionViewItem, token?: string): string {\n if (item.kind === \"system\") {\n const parts = [item.title, item.body].filter((x): x is string => Boolean(x)).map(esc);\n const time = item.meta\n ? ` · <time class=\"event-time\">${esc(formatDate(item.meta))}</time>`\n : \"\";\n return `<div class=\"system-event\"><span class=\"event-dot\"></span><span class=\"event-text\">${parts.join(\" — \")}</span>${time}</div>`;\n }\n\n if (item.kind === \"tool\") {\n const toneClass = item.tone === \"err\" ? \" tone-err\" : item.tone === \"ok\" ? \" tone-ok\" : \"\";\n const body = item.body ? `<pre class=\"tool-output${toneClass}\">${esc(item.body)}</pre>` : \"\";\n const time = item.meta ? `<time class=\"tool-time\">${esc(formatDate(item.meta))}</time>` : \"\";\n return `<div class=\"tool-block\">\n <div class=\"tool-header\">\n <span class=\"tool-icon\"><svg width=\"10\" height=\"10\" viewBox=\"0 0 10 10\" fill=\"none\"><path d=\"M1.5 2L5 5.5 1.5 9\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><path d=\"M6 9h2.5\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg></span>\n <span class=\"tool-name\">${esc(item.title)}</span>\n ${time}\n </div>\n ${body}\n</div>`;\n }\n\n const time = item.meta ? `<time class=\"msg-time\">${esc(formatDate(item.meta))}</time>` : \"\";\n\n if (item.kind === \"user\") {\n const { username, threadTs, content } = item.body\n ? parseUserBody(item.body)\n : { username: null, threadTs: null, content: \"\" };\n const initial = username ? esc(username.slice(0, 2).toUpperCase()) : \"U\";\n const body = content ? `<pre class=\"msg-body\">${esc(content)}</pre>` : \"\";\n const threadBadge = threadTs\n ? `<div class=\"thread-badge\" title=\"Thread ${esc(threadTs)}\">Thread · <code>${esc(threadTs)}</code></div>`\n : \"\";\n const forks = renderForkLinks(item.forks, token ?? \"\");\n return `<div class=\"msg-row msg-user\">\n <div class=\"user-bubble\">\n ${threadBadge}\n ${body}\n ${forks}\n ${time}\n </div>\n <div class=\"msg-avatar user-avatar\" title=\"${username ? esc(username) : \"User\"}\">${initial}</div>\n</div>`;\n }\n\n // assistant\n const body = item.body ? `<pre class=\"msg-body\">${esc(item.body)}</pre>` : \"\";\n const forks = renderForkLinks(item.forks, token ?? \"\");\n return `<div class=\"msg-row msg-assistant\">\n <div class=\"msg-avatar asst-avatar\" aria-hidden=\"true\">A</div>\n <div class=\"asst-card\">\n ${body}\n ${forks}\n ${time}\n </div>\n</div>`;\n}\n\nfunction renderStatusPage(title: string, message: string): string {\n return renderHtmlDocument(\n title,\n `<section class=\"card stack\">\n <p class=\"eyebrow\">mama</p>\n <h1>${esc(title)}</h1>\n <div class=\"status err\">${esc(message)}</div>\n </section>`,\n );\n}\n\nfunction renderHtmlDocument(title: string, shellContent: string): string {\n return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>${esc(title)}</title>\n <style>${styles}</style>\n</head>\n<body>\n <main class=\"shell\">\n ${shellContent}\n </main>\n</body>\n</html>`;\n}\n\nfunction formatDate(value: string): string {\n const date = new Date(value);\n if (Number.isNaN(date.getTime())) return value;\n return date.toLocaleString();\n}\n\nfunction esc(value: string): string {\n return value\n .replaceAll(\"&\", \"&amp;\")\n .replaceAll(\"<\", \"&lt;\")\n .replaceAll(\">\", \"&gt;\")\n .replaceAll('\"', \"&quot;\")\n .replaceAll(\"'\", \"&#39;\");\n}\n\nconst styles = `\n @import url('https://fonts.googleapis.com/css2?family=Lora:wght@400;600&family=DM+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');\n\n :root {\n --bg: #f0ece3;\n --surface: #ffffff;\n --border: rgba(0, 0, 0, 0.08);\n --text: #18181b;\n --muted: #71717a;\n --subtle: #a1a1aa;\n\n --user-bg: #18181b;\n --user-text: #fafafa;\n --user-time: rgba(250, 250, 250, 0.5);\n\n --asst-border: #22c55e;\n --asst-avatar-bg: #f0fdf4;\n --asst-avatar-text: #16a34a;\n\n --tool-bg: #0d1117;\n --tool-header: #161b22;\n --tool-text: #c9d1d9;\n --tool-accent: #58a6ff;\n --tool-ok: #3fb950;\n --tool-err: #f85149;\n --tool-time: #484f58;\n\n --ok-bg: #f0fdf4;\n --ok-text: #15803d;\n --err-bg: #fef2f2;\n --err-text: #b91c1c;\n }\n\n *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\n body {\n min-height: 100vh;\n padding: 40px 20px 80px;\n display: flex;\n flex-direction: column;\n align-items: center;\n background-color: var(--bg);\n background-image:\n radial-gradient(ellipse 80% 40% at 50% -10%, rgba(255,255,255,0.6) 0%, transparent 70%);\n color: var(--text);\n font-family: 'DM Sans', 'Segoe UI', system-ui, sans-serif;\n font-size: 15px;\n line-height: 1.5;\n -webkit-font-smoothing: antialiased;\n }\n\n .shell {\n width: 100%;\n max-width: 780px;\n display: flex;\n flex-direction: column;\n gap: 12px;\n }\n\n /* ── Hero ─────────────────────────────────────────────────────────────── */\n\n .hero-card {\n padding: 28px 32px 24px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--surface);\n box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06);\n }\n\n .hero-top {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n margin-bottom: 20px;\n }\n\n .hero-wordmark {\n display: block;\n margin-bottom: 6px;\n color: var(--subtle);\n font-size: 0.72rem;\n font-weight: 600;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n }\n\n .hero-title {\n font-family: 'Lora', Georgia, serif;\n font-size: clamp(1.4rem, 2.5vw, 1.75rem);\n font-weight: 600;\n line-height: 1.2;\n letter-spacing: -0.01em;\n color: var(--text);\n text-wrap: balance;\n }\n\n .refresh-btn {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n flex-shrink: 0;\n padding: 7px 14px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: transparent;\n color: var(--muted);\n font: 500 0.8rem/1 'DM Sans', sans-serif;\n cursor: pointer;\n transition: color 120ms, border-color 120ms, background 120ms;\n white-space: nowrap;\n }\n\n .refresh-btn:hover {\n color: var(--text);\n border-color: rgba(0,0,0,0.2);\n background: rgba(0,0,0,0.03);\n }\n\n .refresh-btn:focus-visible {\n outline: 2px solid var(--text);\n outline-offset: 2px;\n }\n\n .stat-row {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .stat-chip {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n padding: 4px 10px;\n border: 1px solid var(--border);\n border-radius: 999px;\n background: #f4f4f5;\n font-size: 0.775rem;\n line-height: 1;\n }\n\n .stat-label {\n color: var(--muted);\n font-weight: 500;\n }\n\n .stat-value {\n color: var(--text);\n font-weight: 600;\n }\n\n /* ── Timeline shell ───────────────────────────────────────────────────── */\n\n .fork-links {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n margin-top: 10px;\n }\n\n .fork-link {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n padding: 5px 10px;\n border-radius: 999px;\n border: 1px solid rgba(239, 68, 68, 0.18);\n background: rgba(254, 242, 242, 0.95);\n color: #b91c1c;\n text-decoration: none;\n font-size: 0.74rem;\n font-weight: 600;\n line-height: 1;\n transition: transform 120ms, background 120ms, border-color 120ms;\n }\n\n .fork-link:hover {\n transform: translateY(-1px);\n background: #fff1f2;\n border-color: rgba(239, 68, 68, 0.28);\n }\n\n .fork-dot {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: #ef4444;\n box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);\n flex-shrink: 0;\n }\n\n .fork-text {\n white-space: nowrap;\n }\n\n .related-card {\n padding: 18px 20px;\n border: 1px solid var(--border);\n border-radius: 18px;\n background: rgba(255,255,255,0.78);\n box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04);\n backdrop-filter: blur(12px);\n }\n\n .related-list {\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n\n .related-link {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 12px;\n padding: 12px 14px;\n border-radius: 14px;\n border: 1px solid var(--border);\n background: rgba(255,255,255,0.82);\n color: inherit;\n text-decoration: none;\n transition: transform 120ms, border-color 120ms, box-shadow 120ms, background 120ms;\n }\n\n .related-link:hover {\n transform: translateY(-1px);\n border-color: rgba(0,0,0,0.16);\n background: #fff;\n box-shadow: 0 8px 18px rgba(0,0,0,0.05);\n }\n\n .related-copy {\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n .related-title {\n color: var(--text);\n font-size: 0.94rem;\n line-height: 1.3;\n }\n\n .related-summary {\n color: var(--muted);\n font-size: 0.82rem;\n line-height: 1.45;\n }\n\n .related-meta {\n color: var(--subtle);\n font-size: 0.74rem;\n line-height: 1.4;\n }\n\n .related-arrow {\n flex-shrink: 0;\n color: var(--subtle);\n font-size: 1rem;\n }\n\n .timeline-shell {\n padding: 20px 0;\n }\n\n .timeline-list {\n display: flex;\n flex-direction: column;\n gap: 4px;\n }\n\n /* ── Message rows ─────────────────────────────────────────────────────── */\n\n .msg-row {\n display: flex;\n align-items: flex-end;\n gap: 8px;\n padding: 2px 0;\n }\n\n /* ── User messages ────────────────────────────────────────────────────── */\n\n .msg-user {\n justify-content: flex-end;\n }\n\n .user-bubble {\n max-width: 85%;\n padding: 12px 16px;\n border-radius: 18px 18px 4px 18px;\n background: var(--user-bg);\n color: var(--user-text);\n box-shadow: 0 1px 2px rgba(0,0,0,0.12);\n }\n\n .thread-badge {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 8px;\n padding: 4px 10px;\n border-radius: 999px;\n background: rgba(255,255,255,0.22);\n color: var(--user-text);\n font-size: 0.68rem;\n font-weight: 700;\n letter-spacing: 0.01em;\n }\n\n .thread-badge code {\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.66rem;\n background: rgba(255,255,255,0.16);\n padding: 1px 6px;\n border-radius: 999px;\n color: inherit;\n }\n\n .msg-user .msg-body {\n font-family: 'DM Sans', system-ui, sans-serif;\n font-size: 0.9rem;\n line-height: 1.6;\n white-space: pre-wrap;\n word-break: break-word;\n color: var(--user-text);\n }\n\n .msg-user .msg-time {\n display: block;\n margin-top: 6px;\n font-size: 0.72rem;\n color: var(--user-time);\n text-align: right;\n }\n\n /* ── Avatars ──────────────────────────────────────────────────────────── */\n\n .msg-avatar {\n flex: 0 0 28px;\n width: 28px;\n height: 28px;\n border-radius: 50%;\n font-size: 0.68rem;\n font-weight: 700;\n display: flex;\n align-items: center;\n justify-content: center;\n letter-spacing: 0;\n flex-shrink: 0;\n }\n\n .user-avatar {\n background: #eff6ff;\n border: 1.5px solid #93c5fd;\n color: #1d4ed8;\n }\n\n .asst-avatar {\n background: var(--asst-avatar-bg);\n border: 1.5px solid var(--asst-border);\n color: var(--asst-avatar-text);\n margin-bottom: 2px;\n }\n\n /* ── Assistant messages ───────────────────────────────────────────────── */\n\n .msg-assistant {\n align-items: flex-end;\n gap: 8px;\n max-width: 85%;\n }\n\n .asst-card {\n min-width: 0;\n padding: 14px 18px;\n border: 1px solid var(--border);\n border-radius: 18px 18px 18px 4px;\n background: var(--surface);\n box-shadow: 0 1px 3px rgba(0,0,0,0.04);\n }\n\n .msg-assistant .msg-body {\n font-family: 'DM Sans', system-ui, sans-serif;\n font-size: 0.9rem;\n line-height: 1.65;\n white-space: pre-wrap;\n word-break: break-word;\n color: var(--text);\n }\n\n .msg-assistant .msg-time {\n display: block;\n margin-top: 8px;\n font-size: 0.72rem;\n color: var(--subtle);\n }\n\n /* ── Tool blocks ──────────────────────────────────────────────────────── */\n\n .tool-block {\n max-width: 92%;\n margin-left: 36px;\n border-radius: 10px;\n overflow: hidden;\n border: 1px solid rgba(255,255,255,0.06);\n box-shadow: 0 2px 8px rgba(0,0,0,0.16);\n margin: 6px 0;\n }\n\n .tool-header {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 14px;\n background: var(--tool-header);\n border-bottom: 1px solid rgba(255,255,255,0.06);\n overflow: hidden;\n }\n\n .tool-icon {\n color: var(--tool-accent);\n flex-shrink: 0;\n display: flex;\n align-items: center;\n }\n\n .tool-name {\n flex: 1;\n font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;\n font-size: 0.75rem;\n font-weight: 500;\n color: var(--tool-accent);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .tool-time {\n flex-shrink: 0;\n font-family: 'JetBrains Mono', ui-monospace, monospace;\n font-size: 0.7rem;\n color: var(--tool-time);\n }\n\n .tool-output {\n display: block;\n padding: 12px 14px;\n background: var(--tool-bg);\n color: var(--tool-text);\n font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;\n font-size: 0.78rem;\n line-height: 1.6;\n white-space: pre-wrap;\n word-break: break-word;\n overflow-x: auto;\n max-height: 400px;\n overflow-y: auto;\n }\n\n .tool-output.tone-ok { color: var(--tool-ok); }\n .tool-output.tone-err { color: var(--tool-err); }\n\n /* ── System events ────────────────────────────────────────────────────── */\n\n .system-event {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 6px;\n padding: 10px 0;\n color: var(--subtle);\n font-size: 0.775rem;\n }\n\n .event-dot {\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: var(--subtle);\n flex-shrink: 0;\n opacity: 0.6;\n }\n\n .event-text {\n color: var(--muted);\n }\n\n .event-time {\n color: var(--subtle);\n font-style: normal;\n }\n\n /* ── Status page ──────────────────────────────────────────────────────── */\n\n .card {\n padding: 28px 32px;\n border: 1px solid var(--border);\n border-radius: 20px;\n background: var(--surface);\n box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.06);\n }\n\n .stack > * + * { margin-top: 14px; }\n\n .eyebrow {\n color: var(--subtle);\n font-size: 0.72rem;\n font-weight: 600;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n }\n\n h1 {\n font-family: 'Lora', Georgia, serif;\n font-size: clamp(1.4rem, 2.5vw, 1.75rem);\n font-weight: 600;\n letter-spacing: -0.01em;\n line-height: 1.2;\n }\n\n p { color: var(--muted); font-size: 0.9rem; line-height: 1.5; }\n\n .status {\n padding: 12px 16px;\n border-radius: 10px;\n font-size: 0.9rem;\n }\n\n .status.err {\n background: var(--err-bg);\n color: var(--err-text);\n border: 1px solid rgba(185, 28, 28, 0.12);\n }\n\n /* ── Responsive ───────────────────────────────────────────────────────── */\n\n @media (max-width: 600px) {\n body { padding: 20px 12px 60px; }\n\n .hero-card, .card { padding: 20px; border-radius: 16px; }\n\n .hero-top { flex-direction: column; gap: 12px; }\n\n .refresh-btn { align-self: flex-start; }\n\n .user-bubble { max-width: 88%; }\n\n .asst-avatar { display: none; }\n\n .asst-card { border-radius: 4px 14px 14px 14px; }\n }\n`;\n"]}