pi-roundtable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (315) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +140 -0
  4. package/docs/plugins.md +924 -0
  5. package/examples/channels.test.ts +13 -0
  6. package/examples/channels.ts +27 -0
  7. package/examples/dashboard.test.ts +11 -0
  8. package/examples/dashboard.ts +9 -0
  9. package/examples/events.test.ts +26 -0
  10. package/examples/events.ts +25 -0
  11. package/examples/guide.test.ts +52 -0
  12. package/examples/holds.test.ts +16 -0
  13. package/examples/holds.ts +30 -0
  14. package/examples/http.test.ts +12 -0
  15. package/examples/http.ts +17 -0
  16. package/examples/interactions.test.ts +25 -0
  17. package/examples/interactions.ts +28 -0
  18. package/examples/migrations.test.ts +31 -0
  19. package/examples/migrations.ts +42 -0
  20. package/examples/packages.test.ts +9 -0
  21. package/examples/packages.ts +9 -0
  22. package/examples/preflight.test.ts +11 -0
  23. package/examples/preflight.ts +19 -0
  24. package/examples/prompt.test.ts +25 -0
  25. package/examples/prompt.ts +20 -0
  26. package/examples/providers.test.ts +10 -0
  27. package/examples/providers.ts +17 -0
  28. package/examples/seeds.test.ts +12 -0
  29. package/examples/seeds.ts +18 -0
  30. package/examples/selection.test.ts +13 -0
  31. package/examples/selection.ts +14 -0
  32. package/examples/services.test.ts +20 -0
  33. package/examples/services.ts +30 -0
  34. package/examples/session-tools.test.ts +24 -0
  35. package/examples/session-tools.ts +34 -0
  36. package/examples/tools.test.ts +18 -0
  37. package/examples/tools.ts +28 -0
  38. package/package.json +55 -0
  39. package/src/cli/add-plugin.test.ts +107 -0
  40. package/src/cli/add-plugin.ts +75 -0
  41. package/src/cli/checks/basic.test.ts +276 -0
  42. package/src/cli/checks/bun.ts +26 -0
  43. package/src/cli/checks/configuration.ts +28 -0
  44. package/src/cli/checks/database.test.ts +117 -0
  45. package/src/cli/checks/database.ts +100 -0
  46. package/src/cli/checks/discord.test.ts +200 -0
  47. package/src/cli/checks/discord.ts +218 -0
  48. package/src/cli/checks/environment.ts +27 -0
  49. package/src/cli/checks/model.ts +28 -0
  50. package/src/cli/checks/public-url.ts +40 -0
  51. package/src/cli/cli.test.ts +122 -0
  52. package/src/cli/cli.ts +166 -0
  53. package/src/cli/config-edit.test.ts +130 -0
  54. package/src/cli/config-edit.ts +199 -0
  55. package/src/cli/discord-api.ts +96 -0
  56. package/src/cli/doctor.test.ts +184 -0
  57. package/src/cli/doctor.ts +92 -0
  58. package/src/cli/http.ts +23 -0
  59. package/src/cli/init.test.ts +127 -0
  60. package/src/cli/init.ts +82 -0
  61. package/src/cli/main.ts +13 -0
  62. package/src/cli/project.ts +123 -0
  63. package/src/cli/report.ts +68 -0
  64. package/src/cli/roundtable.mjs +22 -0
  65. package/src/cli/runtime.ts +53 -0
  66. package/src/cli/size.test.ts +9 -0
  67. package/src/cli/start.ts +33 -0
  68. package/src/cli/templates.test.ts +129 -0
  69. package/src/cli/templates.ts +119 -0
  70. package/src/cli/testing/fixtures.ts +106 -0
  71. package/src/core/agents/agent-claim.test.ts +142 -0
  72. package/src/core/agents/agent-claim.ts +170 -0
  73. package/src/core/agents/agent-dashboard.test.ts +120 -0
  74. package/src/core/agents/agent-dashboard.ts +185 -0
  75. package/src/core/agents/agent-guild.test.ts +124 -0
  76. package/src/core/agents/agent-messages.ts +189 -0
  77. package/src/core/agents/agent-ports.ts +103 -0
  78. package/src/core/agents/agent-prompt.ts +163 -0
  79. package/src/core/agents/agent-rules.ts +106 -0
  80. package/src/core/agents/agent-schema.ts +115 -0
  81. package/src/core/agents/agent-settings.ts +49 -0
  82. package/src/core/agents/agent-store.test.ts +205 -0
  83. package/src/core/agents/agent-store.ts +384 -0
  84. package/src/core/agents/agent-team.ts +445 -0
  85. package/src/core/agents/agent-tools.ts +386 -0
  86. package/src/core/agents/avatar-studio.test.ts +88 -0
  87. package/src/core/agents/avatar-studio.ts +147 -0
  88. package/src/core/agents/group-messages.ts +99 -0
  89. package/src/core/agents/group-round.test.ts +143 -0
  90. package/src/core/agents/group-round.ts +117 -0
  91. package/src/core/agents/group-turns.ts +132 -0
  92. package/src/core/agents/owner-identity.test.ts +172 -0
  93. package/src/core/agents/team-editing.ts +205 -0
  94. package/src/core/agents/team-keys.ts +40 -0
  95. package/src/core/agents/team-layout.ts +135 -0
  96. package/src/core/agents/team-lifecycle.ts +188 -0
  97. package/src/core/agents/team-options.ts +52 -0
  98. package/src/core/agents/team-status.ts +80 -0
  99. package/src/core/agents/team-text.ts +60 -0
  100. package/src/core/agents/team-turn-types.ts +107 -0
  101. package/src/core/agents/team-turns.test.ts +278 -0
  102. package/src/core/agents/team-turns.ts +323 -0
  103. package/src/core/assets/neutral.png +0 -0
  104. package/src/core/assets/prompts/shared-guest.md +11 -0
  105. package/src/core/assets/prompts/shared.md +11 -0
  106. package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
  107. package/src/core/attachments/attachment-dir.ts +15 -0
  108. package/src/core/attachments/attachment-fetcher.ts +83 -0
  109. package/src/core/attachments/attachments.test.ts +85 -0
  110. package/src/core/attachments/image-prep.ts +50 -0
  111. package/src/core/attachments/prompt-block.ts +37 -0
  112. package/src/core/attachments/turn-attachments.ts +79 -0
  113. package/src/core/boundary.test.ts +45 -0
  114. package/src/core/builtin/agent-server.ts +275 -0
  115. package/src/core/builtin/discord.ts +95 -0
  116. package/src/core/builtin/modules.test.ts +127 -0
  117. package/src/core/builtin/modules.ts +176 -0
  118. package/src/core/builtin/seeds.ts +10 -0
  119. package/src/core/builtin/stores.ts +42 -0
  120. package/src/core/config/config.test.ts +135 -0
  121. package/src/core/config/config.ts +285 -0
  122. package/src/core/config/schema.ts +181 -0
  123. package/src/core/contract/channels.ts +152 -0
  124. package/src/core/contract/discord.ts +33 -0
  125. package/src/core/contract/providers.ts +67 -0
  126. package/src/core/db/guild-scope.ts +53 -0
  127. package/src/core/db/migrations.test.ts +237 -0
  128. package/src/core/db/migrations.ts +37 -0
  129. package/src/core/define-roundtable.test.ts +134 -0
  130. package/src/core/define-roundtable.ts +195 -0
  131. package/src/core/define.test.ts +144 -0
  132. package/src/core/define.ts +154 -0
  133. package/src/core/discord/agent-commands.test.ts +58 -0
  134. package/src/core/discord/agent-commands.ts +385 -0
  135. package/src/core/discord/agent-discord.test.ts +70 -0
  136. package/src/core/discord/agent-discord.ts +367 -0
  137. package/src/core/discord/channel-executor.ts +404 -0
  138. package/src/core/discord/channel-operations.ts +329 -0
  139. package/src/core/discord/discord-surface.ts +431 -0
  140. package/src/core/discord/dispatch-thread-host.test.ts +114 -0
  141. package/src/core/discord/dispatch-thread-host.ts +72 -0
  142. package/src/core/discord/dispatch-threads.test.ts +94 -0
  143. package/src/core/discord/dispatch-threads.ts +180 -0
  144. package/src/core/discord/interaction-module.ts +1 -0
  145. package/src/core/discord/owner-cards.test.ts +435 -0
  146. package/src/core/discord/owner-cards.ts +463 -0
  147. package/src/core/discord/owner-command.ts +126 -0
  148. package/src/core/discord/owner-discord-access.ts +183 -0
  149. package/src/core/discord/owner-discord-threads.ts +160 -0
  150. package/src/core/discord/owner-discord.test.ts +325 -0
  151. package/src/core/discord/owner-discord.ts +282 -0
  152. package/src/core/discord/owner-panel.ts +103 -0
  153. package/src/core/discord/schedule-commands.ts +149 -0
  154. package/src/core/discord/stop-button.ts +20 -0
  155. package/src/core/domain/attachment.ts +39 -0
  156. package/src/core/domain/conversation.ts +42 -0
  157. package/src/core/domain/errors.ts +31 -0
  158. package/src/core/domain/expression.ts +21 -0
  159. package/src/core/domain/owner-prompts.ts +45 -0
  160. package/src/core/domain/ports.ts +91 -0
  161. package/src/core/domain/profile.ts +32 -0
  162. package/src/core/drain.test.ts +46 -0
  163. package/src/core/drain.ts +33 -0
  164. package/src/core/errors.ts +29 -0
  165. package/src/core/events.test.ts +81 -0
  166. package/src/core/events.ts +55 -0
  167. package/src/core/holds.test.ts +54 -0
  168. package/src/core/holds.ts +46 -0
  169. package/src/core/host.test.ts +536 -0
  170. package/src/core/host.ts +297 -0
  171. package/src/core/http/listeners.test.ts +164 -0
  172. package/src/core/http/listeners.ts +151 -0
  173. package/src/core/i18n/agent-panel.ts +96 -0
  174. package/src/core/i18n/cards.ts +64 -0
  175. package/src/core/i18n/channels.ts +28 -0
  176. package/src/core/i18n/conversation.ts +49 -0
  177. package/src/core/i18n/dashboard.ts +72 -0
  178. package/src/core/i18n/discord.ts +23 -0
  179. package/src/core/i18n/en.ts +27 -0
  180. package/src/core/i18n/i18n.test.ts +139 -0
  181. package/src/core/i18n/index.ts +58 -0
  182. package/src/core/i18n/owner.ts +35 -0
  183. package/src/core/i18n/schedules.ts +92 -0
  184. package/src/core/i18n/time-zones.ts +27 -0
  185. package/src/core/i18n/types.ts +7 -0
  186. package/src/core/i18n/zh-tw.ts +26 -0
  187. package/src/core/identity.test.ts +18 -0
  188. package/src/core/identity.ts +31 -0
  189. package/src/core/judging/confirmation-judge.ts +68 -0
  190. package/src/core/judging/effort-judge.test.ts +112 -0
  191. package/src/core/judging/effort-judge.ts +158 -0
  192. package/src/core/judging/model-judge.test.ts +126 -0
  193. package/src/core/judging/model-judge.ts +193 -0
  194. package/src/core/log.ts +45 -0
  195. package/src/core/models.ts +52 -0
  196. package/src/core/modules/background/background-turns.ts +78 -0
  197. package/src/core/modules/delegation/delegate.ts +54 -0
  198. package/src/core/modules/delegation/delegator.test.ts +170 -0
  199. package/src/core/modules/delegation/delegator.ts +191 -0
  200. package/src/core/modules/delegation/sol-worker.ts +78 -0
  201. package/src/core/modules/discord-admin/discord-admin.ts +288 -0
  202. package/src/core/modules/host-shell/shell-policy.ts +386 -0
  203. package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
  204. package/src/core/modules/memory/owner-memory-store.ts +228 -0
  205. package/src/core/modules/memory/owner-memory.ts +153 -0
  206. package/src/core/modules/notify/notify.ts +26 -0
  207. package/src/core/modules/schedules/recurrence.ts +206 -0
  208. package/src/core/modules/schedules/schedule-store.ts +213 -0
  209. package/src/core/modules/schedules/schedule-tools.ts +196 -0
  210. package/src/core/modules/schedules/schedule.test.ts +358 -0
  211. package/src/core/modules/schedules/scheduler.ts +121 -0
  212. package/src/core/modules/schedules/schedules.ts +87 -0
  213. package/src/core/modules/skills/repo-name.ts +11 -0
  214. package/src/core/modules/skills/skill-kind.test.ts +55 -0
  215. package/src/core/modules/skills/skill-link.ts +69 -0
  216. package/src/core/modules/skills/skill-listing.ts +128 -0
  217. package/src/core/modules/skills/skill-registry.test.ts +376 -0
  218. package/src/core/modules/skills/skill-registry.ts +323 -0
  219. package/src/core/modules/skills/skill-rules.ts +120 -0
  220. package/src/core/modules/skills/skill-store.ts +285 -0
  221. package/src/core/modules/skills/skill-tools.ts +207 -0
  222. package/src/core/ops/error-reporter.test.ts +283 -0
  223. package/src/core/ops/error-reporter.ts +271 -0
  224. package/src/core/plugin.ts +200 -0
  225. package/src/core/presentation/card-cadence.ts +41 -0
  226. package/src/core/presentation/headline.ts +34 -0
  227. package/src/core/presentation/presentation.test.ts +166 -0
  228. package/src/core/presentation/quiet-links.ts +36 -0
  229. package/src/core/presentation/reply-splitter.ts +110 -0
  230. package/src/core/presentation/thinking-line.ts +24 -0
  231. package/src/core/public-entry.test.ts +59 -0
  232. package/src/core/registry/contributions.test.ts +215 -0
  233. package/src/core/registry/contributions.ts +249 -0
  234. package/src/core/registry/interactions.test.ts +87 -0
  235. package/src/core/registry/interactions.ts +55 -0
  236. package/src/core/registry/providers.test.ts +103 -0
  237. package/src/core/registry/providers.ts +55 -0
  238. package/src/core/routing/channel-queue.test.ts +31 -0
  239. package/src/core/routing/channel-queue.ts +44 -0
  240. package/src/core/routing/channel-router.test.ts +326 -0
  241. package/src/core/routing/channel-router.ts +157 -0
  242. package/src/core/routing/conversation-kind.ts +14 -0
  243. package/src/core/routing/forward-join.ts +65 -0
  244. package/src/core/routing/message-text.ts +16 -0
  245. package/src/core/routing/settle-turn.test.ts +21 -0
  246. package/src/core/routing/settle-turn.ts +26 -0
  247. package/src/core/runtime/compaction-tiers.test.ts +227 -0
  248. package/src/core/runtime/compaction-tiers.ts +203 -0
  249. package/src/core/runtime/conversation-sessions.ts +162 -0
  250. package/src/core/runtime/extensions/agent-prompt.ts +16 -0
  251. package/src/core/runtime/extensions/ask-user.test.ts +114 -0
  252. package/src/core/runtime/extensions/ask-user.ts +98 -0
  253. package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
  254. package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
  255. package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
  256. package/src/core/runtime/mcp.ts +23 -0
  257. package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
  258. package/src/core/runtime/pending-confirmation-store.ts +75 -0
  259. package/src/core/runtime/pi-agent-runtime.ts +477 -0
  260. package/src/core/runtime/prompt-slot.ts +81 -0
  261. package/src/core/runtime/runtime-types.ts +150 -0
  262. package/src/core/runtime/session-archive.test.ts +20 -0
  263. package/src/core/runtime/session-archive.ts +17 -0
  264. package/src/core/runtime/session-factory.ts +291 -0
  265. package/src/core/runtime/steerable-run.test.ts +264 -0
  266. package/src/core/runtime/steerable-run.ts +109 -0
  267. package/src/core/runtime/text-tools.test.ts +110 -0
  268. package/src/core/runtime/text-tools.ts +66 -0
  269. package/src/core/runtime/turn-answer.test.ts +72 -0
  270. package/src/core/runtime/turn-answer.ts +79 -0
  271. package/src/core/runtime/worker-task.test.ts +82 -0
  272. package/src/core/runtime/worker-task.ts +65 -0
  273. package/src/core/services.test.ts +28 -0
  274. package/src/core/services.ts +107 -0
  275. package/src/core/sessions.test.ts +85 -0
  276. package/src/core/sessions.ts +149 -0
  277. package/src/core/shared/attachment-reader.ts +91 -0
  278. package/src/core/shared/delegate-tool.ts +15 -0
  279. package/src/core/shared/mcp-adapter.ts +58 -0
  280. package/src/core/shared/package-dir.ts +9 -0
  281. package/src/core/shared/profile-tools.ts +16 -0
  282. package/src/core/shared/read-attachment-tool.ts +43 -0
  283. package/src/core/shared/schedule-tools.ts +118 -0
  284. package/src/core/shared/session-messages.ts +50 -0
  285. package/src/core/shared/tool-result.ts +9 -0
  286. package/src/core/shared/unix-server.ts +18 -0
  287. package/src/core/size.test.ts +9 -0
  288. package/src/core/speakers.test.ts +78 -0
  289. package/src/core/speakers.ts +143 -0
  290. package/src/core/testing/database.ts +52 -0
  291. package/src/core/testing/file-size.ts +34 -0
  292. package/src/core/testing/locale.ts +8 -0
  293. package/src/core/testing/modules.ts +97 -0
  294. package/src/core/testing/owner.ts +15 -0
  295. package/src/core/testing/thread-host.ts +60 -0
  296. package/src/core/time.test.ts +166 -0
  297. package/src/core/time.ts +67 -0
  298. package/src/core/tool-tiers.test.ts +117 -0
  299. package/src/core/tool-tiers.ts +127 -0
  300. package/src/entries.test.ts +112 -0
  301. package/src/index.ts +28 -0
  302. package/src/testing.test.ts +156 -0
  303. package/src/testing.ts +182 -0
  304. package/templates/.env.example +22 -0
  305. package/templates/README.md +11 -0
  306. package/templates/_gitignore +3 -0
  307. package/templates/agents.ts +12 -0
  308. package/templates/biome.json.tmpl +7 -0
  309. package/templates/docker-compose.yml +16 -0
  310. package/templates/package.json.tmpl +24 -0
  311. package/templates/persona/shared.md +2 -0
  312. package/templates/plugin/plugin.test.ts.tmpl +13 -0
  313. package/templates/plugin/plugin.ts +18 -0
  314. package/templates/roundtable.config.ts +24 -0
  315. package/templates/tsconfig.json.tmpl +16 -0
@@ -0,0 +1,228 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "../../db/migrations.ts";
3
+ import { MemoryError } from "../../domain/errors.ts";
4
+
5
+ export const OWNER_MEMORY_KINDS = ["core", "note", "event"] as const;
6
+ export type OwnerMemoryKind = (typeof OWNER_MEMORY_KINDS)[number];
7
+
8
+ export interface OwnerMemory {
9
+ id: number;
10
+ kind: OwnerMemoryKind;
11
+ fact: string;
12
+ /** YYYY-MM-DD; set for events only. */
13
+ eventDate: string | null;
14
+ }
15
+
16
+ /** What every owner turn carries: all core facts, and events that have not passed yet. */
17
+ export interface OwnerPromptMemory {
18
+ core: OwnerMemory[];
19
+ events: OwnerMemory[];
20
+ }
21
+
22
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
23
+ const SEARCH_LIMIT = 20;
24
+
25
+ interface Row {
26
+ id: string | number;
27
+ kind: OwnerMemoryKind;
28
+ fact: string;
29
+ event_date: string | null;
30
+ }
31
+
32
+ function toMemory(row: Row): OwnerMemory {
33
+ return {
34
+ id: Number(row.id),
35
+ kind: row.kind,
36
+ fact: row.fact,
37
+ eventDate: row.event_date,
38
+ };
39
+ }
40
+
41
+ /** Whitespace-separated query terms, deduplicated; Chinese needs no tokenizer this way. */
42
+ export function searchTerms(query: string): string[] {
43
+ return [
44
+ ...new Set(
45
+ query
46
+ .toLowerCase()
47
+ .split(/\s+/)
48
+ .filter((term) => term.length > 0),
49
+ ),
50
+ ];
51
+ }
52
+
53
+ /** The trimmed fact, after the rules every stored memory follows. */
54
+ function checkedFact(
55
+ fact: string,
56
+ kind: OwnerMemoryKind,
57
+ eventDate: string | undefined,
58
+ ): string {
59
+ const trimmed = fact.trim();
60
+ if (!trimmed) throw new MemoryError("a memory fact cannot be empty");
61
+ if (!(OWNER_MEMORY_KINDS as readonly string[]).includes(kind))
62
+ throw new MemoryError(`unknown memory kind: ${kind}`);
63
+ if (kind === "event" && !(eventDate && DATE.test(eventDate)))
64
+ throw new MemoryError("an event needs its date as YYYY-MM-DD");
65
+ if (kind !== "event" && eventDate)
66
+ throw new MemoryError("only an event has a date");
67
+ return trimmed;
68
+ }
69
+
70
+ /**
71
+ * One speaker's remembered facts in the database: core facts, searchable notes, and
72
+ * dated events. The store is bound to a speaker and never reads or changes another's; every
73
+ * agent shares the memory of whoever is speaking.
74
+ */
75
+ export class OwnerMemoryStore {
76
+ readonly #sql: SQL;
77
+ /** Whose memory this is: a Discord user id. */
78
+ readonly speakerId: string;
79
+
80
+ private constructor(sql: SQL, speakerId: string) {
81
+ this.#sql = sql;
82
+ this.speakerId = speakerId;
83
+ }
84
+
85
+ /** Connects, and creates or upgrades the table; rows from before kinds existed become core facts. */
86
+ /** The store's tables; the host runs this before any store attaches. */
87
+ static readonly migration: Migration = {
88
+ name: "owner-memory",
89
+ up: async (sql) => {
90
+ await sql`
91
+ CREATE TABLE IF NOT EXISTS owner_memory (
92
+ id bigserial PRIMARY KEY,
93
+ fact text NOT NULL CHECK (length(btrim(fact)) > 0),
94
+ created_at timestamptz NOT NULL DEFAULT now()
95
+ )`;
96
+ await sql`
97
+ ALTER TABLE owner_memory
98
+ ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'core'
99
+ CHECK (kind IN ('core', 'note', 'event')),
100
+ ADD COLUMN IF NOT EXISTS event_date date,
101
+ ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now()`;
102
+ },
103
+ };
104
+
105
+ /**
106
+ * The store's table, then each fact's speaker, with the facts stored before speakers
107
+ * existed given to `ownerId`, whose they were.
108
+ */
109
+ static migrations(ownerId: string): Migration[] {
110
+ return [
111
+ OwnerMemoryStore.migration,
112
+ {
113
+ name: "owner-memory-speaker",
114
+ up: async (sql) => {
115
+ await sql.begin(async (tx) => {
116
+ await tx`SELECT pg_advisory_xact_lock(7284912302)`;
117
+ await tx`ALTER TABLE owner_memory ADD COLUMN IF NOT EXISTS speaker_id text`;
118
+ await tx`UPDATE owner_memory SET speaker_id = ${ownerId} WHERE speaker_id IS NULL`;
119
+ await tx`ALTER TABLE owner_memory ALTER COLUMN speaker_id SET NOT NULL`;
120
+ await tx`CREATE INDEX IF NOT EXISTS owner_memory_speaker ON owner_memory (speaker_id, id)`;
121
+ });
122
+ },
123
+ },
124
+ ];
125
+ }
126
+
127
+ /** The store over the host's migrated pool, holding one speaker's memory. */
128
+ static attach(sql: SQL, speakerId: string): OwnerMemoryStore {
129
+ return new OwnerMemoryStore(sql, speakerId);
130
+ }
131
+
132
+ /** Another speaker's memory in the same database. */
133
+ forSpeaker(speakerId: string): OwnerMemoryStore {
134
+ return speakerId === this.speakerId
135
+ ? this
136
+ : new OwnerMemoryStore(this.#sql, speakerId);
137
+ }
138
+
139
+ async list(): Promise<OwnerMemory[]> {
140
+ const rows: Row[] = await this.#sql`
141
+ SELECT id, kind, fact, to_char(event_date, 'YYYY-MM-DD') AS event_date
142
+ FROM owner_memory WHERE speaker_id = ${this.speakerId} ORDER BY id`;
143
+ return rows.map(toMemory);
144
+ }
145
+
146
+ async forPrompt(today: string): Promise<OwnerPromptMemory> {
147
+ const rows: Row[] = await this.#sql`
148
+ SELECT id, kind, fact, to_char(event_date, 'YYYY-MM-DD') AS event_date
149
+ FROM owner_memory
150
+ WHERE speaker_id = ${this.speakerId}
151
+ AND (kind = 'core' OR (kind = 'event' AND event_date >= ${today}::date))
152
+ ORDER BY event_date NULLS FIRST, id`;
153
+ const memories = rows.map(toMemory);
154
+ return {
155
+ core: memories.filter((memory) => memory.kind === "core"),
156
+ events: memories.filter((memory) => memory.kind === "event"),
157
+ };
158
+ }
159
+
160
+ async add(
161
+ fact: string,
162
+ kind: OwnerMemoryKind = "core",
163
+ eventDate?: string,
164
+ ): Promise<OwnerMemory> {
165
+ const trimmed = checkedFact(fact, kind, eventDate);
166
+ const [row]: Row[] = await this.#sql`
167
+ INSERT INTO owner_memory (speaker_id, fact, kind, event_date)
168
+ VALUES (${this.speakerId}, ${trimmed}, ${kind}, ${eventDate ?? null}::date)
169
+ RETURNING id, kind, fact, to_char(event_date, 'YYYY-MM-DD') AS event_date`;
170
+ if (!row) throw new MemoryError("the memory was not saved");
171
+ return toMemory(row);
172
+ }
173
+
174
+ /**
175
+ * Memories containing any query term, case-insensitively, most terms matched first,
176
+ * then newest first.
177
+ */
178
+ async search(query: string, limit = SEARCH_LIMIT): Promise<OwnerMemory[]> {
179
+ const terms = searchTerms(query);
180
+ if (terms.length === 0) throw new MemoryError("the search query is empty");
181
+ const rows: (Row & { hits: number })[] = await this.#sql`
182
+ SELECT id, kind, fact, to_char(event_date, 'YYYY-MM-DD') AS event_date, hits
183
+ FROM (
184
+ SELECT *, (
185
+ SELECT count(*) FROM jsonb_array_elements_text(${terms}::jsonb) AS term
186
+ WHERE strpos(lower(fact), term) > 0
187
+ ) AS hits
188
+ FROM owner_memory WHERE speaker_id = ${this.speakerId}
189
+ ) AS scored
190
+ WHERE hits > 0
191
+ ORDER BY hits DESC, updated_at DESC, id DESC
192
+ LIMIT ${limit}`;
193
+ return rows.map(toMemory);
194
+ }
195
+
196
+ /** Replaces one memory's text, kind, and date under `add`'s rules; undefined when the id is unknown. */
197
+ async update(
198
+ id: number,
199
+ change: { fact: string; kind: OwnerMemoryKind; eventDate?: string },
200
+ ): Promise<OwnerMemory | undefined> {
201
+ const trimmed = checkedFact(change.fact, change.kind, change.eventDate);
202
+ const [row]: Row[] = await this.#sql`
203
+ UPDATE owner_memory
204
+ SET fact = ${trimmed}, kind = ${change.kind},
205
+ event_date = ${change.eventDate ?? null}::date, updated_at = now()
206
+ WHERE speaker_id = ${this.speakerId} AND id = ${id}
207
+ RETURNING id, kind, fact, to_char(event_date, 'YYYY-MM-DD') AS event_date`;
208
+ return row ? toMemory(row) : undefined;
209
+ }
210
+
211
+ /** Deletes one memory; false when the id is unknown. */
212
+ async removeById(id: number): Promise<boolean> {
213
+ const rows = await this.#sql`DELETE FROM owner_memory
214
+ WHERE speaker_id = ${this.speakerId} AND id = ${id} RETURNING id`;
215
+ return rows.length > 0;
216
+ }
217
+
218
+ /** Deletes every memory containing the text, case-insensitively; returns the removed facts. */
219
+ async remove(text: string): Promise<string[]> {
220
+ const needle = text.trim();
221
+ if (!needle) throw new MemoryError("the text to forget cannot be empty");
222
+ const rows: { fact: string }[] = await this.#sql`
223
+ DELETE FROM owner_memory
224
+ WHERE speaker_id = ${this.speakerId} AND strpos(lower(fact), lower(${needle})) > 0
225
+ RETURNING fact`;
226
+ return rows.map((row) => row.fact);
227
+ }
228
+ }
@@ -0,0 +1,153 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { messages } from "../../i18n/index.ts";
4
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
5
+ import { toolError, toolText } from "../../shared/tool-result.ts";
6
+ import type { Speaker } from "../../speakers.ts";
7
+ import { timeZone, zonedToday } from "../../time.ts";
8
+ import type {
9
+ OwnerMemory,
10
+ OwnerMemoryStore,
11
+ OwnerPromptMemory,
12
+ } from "./owner-memory-store.ts";
13
+
14
+ function line(memory: OwnerMemory): string {
15
+ return memory.eventDate
16
+ ? `- ${memory.eventDate}: ${memory.fact}`
17
+ : `- ${memory.fact}`;
18
+ }
19
+
20
+ export function ownerMemorySection(
21
+ memory: OwnerPromptMemory,
22
+ today: string,
23
+ /** Whose memory it is, when not the owner's. */
24
+ speaker?: Speaker,
25
+ ): string {
26
+ const core =
27
+ memory.core.length > 0
28
+ ? memory.core.map(line).join("\n")
29
+ : "(nothing remembered yet)";
30
+ const events =
31
+ memory.events.length > 0 ? memory.events.map(line).join("\n") : "(none)";
32
+ return [
33
+ speaker ? `## Memory of ${speaker.name}` : "## Owner memory",
34
+ `Today in ${messages().zoneName(timeZone())} is ${today}. What is stored right now, including anything you added earlier in this conversation; each appears once.`,
35
+ `### Core facts\n${core}`,
36
+ `### Upcoming events\n${events}`,
37
+ "Notes (projects, people, references, past events) are not listed here. Use memory_search when a request may depend on them.",
38
+ ].join("\n\n");
39
+ }
40
+
41
+ /**
42
+ * Adds the owner's core facts and upcoming events to every run, and registers
43
+ * memory_add, memory_search, and memory_remove. The section goes into appendSystemPrompt
44
+ * because claude-bridge forwards only the prompt's option sections to Claude Code, not a
45
+ * replaced system prompt.
46
+ */
47
+ export function ownerMemoryExtension(
48
+ ownerStore: OwnerMemoryStore,
49
+ owner: OwnerIdentity,
50
+ /**
51
+ * The person the running turn is for, in a session that serves several. Their memory, not
52
+ * the owner's, is what the turn reads and changes; without it every turn is the owner's.
53
+ */
54
+ speaker?: () => Speaker | undefined,
55
+ ): ExtensionFactory {
56
+ const o = ownerWords(owner);
57
+ /** The running turn's other speaker; undefined when the owner's own memory applies. */
58
+ const other = (): Speaker | undefined => {
59
+ const current = speaker?.();
60
+ return current && current.id !== ownerStore.speakerId ? current : undefined;
61
+ };
62
+ const storeOf = () => {
63
+ const current = other();
64
+ return current ? ownerStore.forSpeaker(current.id) : ownerStore;
65
+ };
66
+ const shared = speaker
67
+ ? " Whoever is speaking has a memory of their own, shared by every agent; this reads and changes theirs, not someone else's."
68
+ : "";
69
+ return (pi) => {
70
+ pi.on("before_agent_start", async (event) => {
71
+ const today = zonedToday();
72
+ const section = ownerMemorySection(
73
+ await storeOf().forPrompt(today),
74
+ today,
75
+ other(),
76
+ );
77
+ const appended = event.systemPromptOptions.appendSystemPrompt;
78
+ event.systemPromptOptions.appendSystemPrompt = appended
79
+ ? `${appended}\n\n${section}`
80
+ : section;
81
+ });
82
+
83
+ pi.registerTool({
84
+ name: "memory_add",
85
+ label: "Remember",
86
+ description: `Remember one fact about ${o.name} for future conversations, when ${o.he} asks you to or when it will clearly matter later. Search first and do not store what is already there. kind core: a stable fact about who ${o.he} is or how ${o.he} wants things done; it is shown in every conversation, so keep core facts few and short. kind note: projects, people, references, and other details worth finding later with memory_search. kind event: something happening on a date, shown until that date passes.${shared}`,
87
+ parameters: Type.Object({
88
+ fact: Type.String({
89
+ description:
90
+ "One self-contained fact, written so it makes sense on its own later.",
91
+ }),
92
+ kind: Type.Union(
93
+ [Type.Literal("core"), Type.Literal("note"), Type.Literal("event")],
94
+ { description: "core, note, or event." },
95
+ ),
96
+ date: Type.Optional(
97
+ Type.String({
98
+ description: "For an event: its date as YYYY-MM-DD.",
99
+ }),
100
+ ),
101
+ }),
102
+ execute: async (_toolCallId, params) => {
103
+ const saved = await storeOf().add(
104
+ params.fact,
105
+ params.kind,
106
+ params.date,
107
+ );
108
+ return toolText(`Remembered as ${saved.kind}: ${line(saved).slice(2)}`);
109
+ },
110
+ });
111
+
112
+ pi.registerTool({
113
+ name: "memory_search",
114
+ label: "Search memory",
115
+ description: `Search everything remembered about ${o.name}, including notes and past events. Give a few short keywords separated by spaces, in the language the memory is likely written in; results contain any of them, best matches first.${shared}`,
116
+ parameters: Type.Object({
117
+ query: Type.String({ description: "Keywords separated by spaces." }),
118
+ }),
119
+ execute: async (_toolCallId, params) => {
120
+ const found = await storeOf().search(params.query);
121
+ return toolText(
122
+ found.length === 0
123
+ ? `Nothing remembered matches "${params.query}".`
124
+ : found
125
+ .map((memory) => `- [${memory.kind}] ${line(memory).slice(2)}`)
126
+ .join("\n"),
127
+ );
128
+ },
129
+ });
130
+
131
+ pi.registerTool({
132
+ name: "memory_remove",
133
+ label: "Forget",
134
+ description: `Forget remembered facts about ${o.name}. Removes every remembered fact that contains the given text.${shared}`,
135
+ parameters: Type.Object({
136
+ text: Type.String({
137
+ description: "Text contained in the fact or facts to forget.",
138
+ }),
139
+ }),
140
+ execute: async (_toolCallId, params) => {
141
+ const removed = await storeOf().remove(params.text);
142
+ if (removed.length === 0) {
143
+ return toolError(
144
+ `No remembered fact contains "${params.text}". Nothing was forgotten.`,
145
+ );
146
+ }
147
+ return toolText(
148
+ `Forgot:\n${removed.map((fact) => `- ${fact}`).join("\n")}`,
149
+ );
150
+ },
151
+ });
152
+ };
153
+ }
@@ -0,0 +1,26 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import type { OwnerNotifier } from "../../domain/ports.ts";
4
+ import { type OwnerIdentity, ownerWords } from "../../identity.ts";
5
+ import { toolText } from "../../shared/tool-result.ts";
6
+
7
+ export function notifyExtension(
8
+ notifier: OwnerNotifier,
9
+ owner: OwnerIdentity,
10
+ ): ExtensionFactory {
11
+ const o = ownerWords(owner);
12
+ return (pi) => {
13
+ pi.registerTool({
14
+ name: "notify_owner",
15
+ label: "Notify owner",
16
+ description: `Send ${o.name} a direct message on Discord. Use only when ${o.he} asks to be notified or reminded by DM; your normal reply already reaches ${o.him}.`,
17
+ parameters: Type.Object({
18
+ text: Type.String({ description: "The message to send." }),
19
+ }),
20
+ execute: async (_toolCallId, params) => {
21
+ await notifier.notifyOwner(params.text);
22
+ return toolText("Sent.");
23
+ },
24
+ });
25
+ };
26
+ }
@@ -0,0 +1,206 @@
1
+ import { ScheduleError } from "../../domain/errors.ts";
2
+ import { messages } from "../../i18n/index.ts";
3
+ import { timeZone, zonedDate, zonedInstant, zonedStamp } from "../../time.ts";
4
+
5
+ export const WEEKDAYS = [
6
+ "sun",
7
+ "mon",
8
+ "tue",
9
+ "wed",
10
+ "thu",
11
+ "fri",
12
+ "sat",
13
+ ] as const;
14
+ export type Weekday = (typeof WEEKDAYS)[number];
15
+
16
+ /** When a schedule runs, in the configured wall-clock time. */
17
+ export type Recurrence =
18
+ | { kind: "once"; date: string; time: string; instant?: string }
19
+ | { kind: "every"; time: string; everyDays: number; startDate: string }
20
+ | { kind: "weekly"; time: string; weekdays: Weekday[] };
21
+
22
+ const DAY_MS = 86_400_000;
23
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
24
+ const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
25
+
26
+ function dayIndex(date: string): number {
27
+ return Date.parse(`${date}T00:00:00Z`) / DAY_MS;
28
+ }
29
+
30
+ function addDays(date: string, days: number): string {
31
+ return new Date((dayIndex(date) + days) * DAY_MS).toISOString().slice(0, 10);
32
+ }
33
+
34
+ function weekday(date: string): Weekday {
35
+ return WEEKDAYS[new Date(`${date}T00:00:00Z`).getUTCDay()] ?? "sun";
36
+ }
37
+
38
+ /** The first run strictly after `after`, or undefined when a one-time schedule has passed. */
39
+ export function nextRun(recurrence: Recurrence, after: Date): Date | undefined {
40
+ switch (recurrence.kind) {
41
+ case "once": {
42
+ const at = recurrence.instant
43
+ ? new Date(recurrence.instant)
44
+ : zonedInstant(recurrence.date, recurrence.time);
45
+ return at > after ? at : undefined;
46
+ }
47
+ case "every": {
48
+ const { startDate, everyDays, time } = recurrence;
49
+ const behind = dayIndex(zonedDate(after)) - dayIndex(startDate);
50
+ let step = Math.max(0, Math.ceil(behind / everyDays));
51
+ for (;;) {
52
+ const at = zonedInstant(addDays(startDate, step * everyDays), time);
53
+ if (at > after) return at;
54
+ step += 1;
55
+ }
56
+ }
57
+ case "weekly": {
58
+ const today = zonedDate(after);
59
+ for (let offset = 0; offset <= 7; offset += 1) {
60
+ const date = addDays(today, offset);
61
+ if (!recurrence.weekdays.includes(weekday(date))) continue;
62
+ const at = zonedInstant(date, recurrence.time);
63
+ if (at > after) return at;
64
+ }
65
+ throw new ScheduleError("a weekly schedule needs at least one weekday");
66
+ }
67
+ default:
68
+ throw new ScheduleError("unknown recurrence kind");
69
+ }
70
+ }
71
+
72
+ /** The recurrence in the owner's words, for tool answers and panels. */
73
+ export function describeRecurrence(recurrence: Recurrence): string {
74
+ const text = messages();
75
+ switch (recurrence.kind) {
76
+ case "once":
77
+ return text.scheduleOnce(recurrence.date, recurrence.time);
78
+ case "every":
79
+ return recurrence.everyDays === 1
80
+ ? text.scheduleDaily(recurrence.time)
81
+ : text.scheduleEveryDays(
82
+ recurrence.everyDays,
83
+ recurrence.time,
84
+ recurrence.startDate,
85
+ );
86
+ case "weekly":
87
+ return text.scheduleWeekly(
88
+ recurrence.weekdays.map((d) => text.scheduleWeekday(d)),
89
+ recurrence.time,
90
+ );
91
+ default:
92
+ throw new ScheduleError("unknown recurrence kind");
93
+ }
94
+ }
95
+
96
+ export interface RecurrenceInput {
97
+ in_minutes?: unknown;
98
+ at?: unknown;
99
+ time?: unknown;
100
+ every_days?: unknown;
101
+ start_date?: unknown;
102
+ weekdays?: unknown;
103
+ }
104
+
105
+ function validDate(value: string): boolean {
106
+ return (
107
+ DATE.test(value) &&
108
+ new Date(`${value}T00:00:00Z`).toISOString().startsWith(value)
109
+ );
110
+ }
111
+
112
+ const MAX_MINUTES = 366 * 24 * 60;
113
+
114
+ /**
115
+ * Reads the tool fields that set timing: `in_minutes` or `at` for one run, or `time` with either
116
+ * `every_days` (and an optional `start_date`) or `weekdays`. Throws ScheduleError with a
117
+ * message the model can act on.
118
+ */
119
+ export function parseRecurrence(input: RecurrenceInput, now: Date): Recurrence {
120
+ const { in_minutes, at, time, every_days, start_date, weekdays } = input;
121
+ if (in_minutes !== undefined) {
122
+ if (
123
+ [at, time, every_days, start_date, weekdays].some((v) => v !== undefined)
124
+ )
125
+ throw new ScheduleError(
126
+ "in_minutes sets one run on its own; do not combine it with other timing",
127
+ );
128
+ if (
129
+ typeof in_minutes !== "number" ||
130
+ !Number.isInteger(in_minutes) ||
131
+ in_minutes < 1 ||
132
+ in_minutes > MAX_MINUTES
133
+ )
134
+ throw new ScheduleError(
135
+ "in_minutes must be a whole number of minutes, at least 1",
136
+ );
137
+ // Rounded up to the minute, so the run is never earlier than asked.
138
+ const target = new Date(
139
+ Math.ceil((now.getTime() + in_minutes * 60_000) / 60_000) * 60_000,
140
+ );
141
+ const [date = "", clock = ""] = zonedStamp(target).split(" ");
142
+ // A relative request in the second occurrence of a fall-back hour must not
143
+ // silently move to the first occurrence of that same wall-clock minute.
144
+ const first = zonedInstant(date, clock);
145
+ return first.getTime() === target.getTime()
146
+ ? { kind: "once", date, time: clock }
147
+ : { kind: "once", date, time: clock, instant: target.toISOString() };
148
+ }
149
+ if (at !== undefined) {
150
+ if (
151
+ time !== undefined ||
152
+ every_days !== undefined ||
153
+ weekdays !== undefined
154
+ )
155
+ throw new ScheduleError(
156
+ "give either at (one run) or time (repeating), not both",
157
+ );
158
+ const match =
159
+ typeof at === "string"
160
+ ? /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})$/.exec(at.trim())
161
+ : null;
162
+ const [, date = "", clock = ""] = match ?? [];
163
+ if (!match || !validDate(date) || !TIME.test(clock))
164
+ throw new ScheduleError(messages().atTimeError(timeZone()));
165
+ return { kind: "once", date, time: clock };
166
+ }
167
+ if (typeof time !== "string" || !TIME.test(time.trim()))
168
+ throw new ScheduleError(messages().repeatingTimeError(timeZone()));
169
+ const clock = time.trim();
170
+ if (weekdays !== undefined) {
171
+ if (every_days !== undefined || start_date !== undefined)
172
+ throw new ScheduleError(
173
+ "weekdays cannot be combined with every_days or start_date",
174
+ );
175
+ const days = Array.isArray(weekdays)
176
+ ? weekdays.map((d) => String(d).trim().toLowerCase().slice(0, 3))
177
+ : [];
178
+ if (
179
+ days.length === 0 ||
180
+ !days.every((d): d is Weekday =>
181
+ (WEEKDAYS as readonly string[]).includes(d),
182
+ )
183
+ )
184
+ throw new ScheduleError(
185
+ `weekdays must be a list of ${WEEKDAYS.join(", ")}`,
186
+ );
187
+ return {
188
+ kind: "weekly",
189
+ time: clock,
190
+ weekdays: WEEKDAYS.filter((d) => days.includes(d)),
191
+ };
192
+ }
193
+ const everyDays = every_days === undefined ? 1 : every_days;
194
+ if (
195
+ typeof everyDays !== "number" ||
196
+ !Number.isInteger(everyDays) ||
197
+ everyDays < 1 ||
198
+ everyDays > 365
199
+ )
200
+ throw new ScheduleError("every_days must be a whole number from 1 to 365");
201
+ const startDate =
202
+ start_date === undefined ? zonedDate(now) : String(start_date).trim();
203
+ if (!validDate(startDate))
204
+ throw new ScheduleError('start_date must be "YYYY-MM-DD"');
205
+ return { kind: "every", time: clock, everyDays, startDate };
206
+ }