@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure session identity primitives.
3
+ *
4
+ * A session identity is a small personal handle for one Pi session. It is deliberately
5
+ * independent from the active persona, an agent role, and the Exocom registry. The extension
6
+ * adapter owns persistence and host lifecycle; this module only defines the bounded data shape and
7
+ * the rules used when data crosses that boundary.
8
+ */
9
+
10
+ import { sanitizeDisplayLabel } from "./display-label.ts";
11
+ import { stripTerminalControls } from "./fence.ts";
12
+
13
+ export const IDENTITY_ENTRY_CUSTOM_TYPE = "pi-persona:identity";
14
+ export const IDENTITY_CONTEXT_CUSTOM_TYPE = "pi-persona:identity-context";
15
+ export const MAX_IDENTITY_NAME_CHARS = 32;
16
+
17
+ export interface SessionIdentityEntryData {
18
+ version: 1;
19
+ sessionId: string;
20
+ name: string;
21
+ }
22
+
23
+ const GENERIC_IDENTITY_NAMES = new Set(["unnamed", "agent", "supervisor", "pi"]);
24
+
25
+ /** Canonical comparison key for a human-provided label. */
26
+ export function identityNameKey(value: unknown): string {
27
+ if (typeof value !== "string") return "";
28
+ return stripTerminalControls(value)
29
+ .normalize("NFKC")
30
+ .trim()
31
+ .toLocaleLowerCase("en-US")
32
+ .replace(/[\s_-]+/gu, "-");
33
+ }
34
+
35
+ /** Whether a name is reserved by the generic runtime or by a discovered persona/agent. */
36
+ export function isReservedIdentityName(name: unknown, reservedNames: readonly string[] = []): boolean {
37
+ const key = identityNameKey(name);
38
+ if (!key) return false;
39
+ if (GENERIC_IDENTITY_NAMES.has(key)) return true;
40
+ return reservedNames.some((reserved) => {
41
+ const reservedKey = identityNameKey(reserved);
42
+ return reservedKey !== "" && (reservedKey === key || identityNameKey(sanitizeDisplayLabel(reserved, "", MAX_IDENTITY_NAME_CHARS)) === key);
43
+ });
44
+ }
45
+
46
+ /**
47
+ * Reduce a candidate to a short, printable display label. `undefined` means the value cannot be
48
+ * used as an identity. The explicit empty fallback is intentional: `sanitizeDisplayLabel`'s
49
+ * normal fallback is useful for UI labels but would turn an empty identity into "agent".
50
+ */
51
+ export function sanitizeIdentityName(value: unknown, reservedNames: readonly string[] = []): string | undefined {
52
+ if (typeof value !== "string") return undefined;
53
+ const visible = stripTerminalControls(value.slice(0, 4096)).normalize("NFKC").trim();
54
+ if (!visible) return undefined;
55
+ const safe = sanitizeDisplayLabel(visible, "", MAX_IDENTITY_NAME_CHARS);
56
+ if (!safe || isReservedIdentityName(safe, reservedNames)) return undefined;
57
+ return safe;
58
+ }
59
+
60
+ /** Make a distinct provisional handle from a session id without consulting any name catalog. */
61
+ export function provisionalIdentityName(sessionId: string): string {
62
+ const alphanumeric = sessionId.normalize("NFKC").replace(/[^A-Za-z0-9]/g, "");
63
+ return `pi-${(alphanumeric.slice(-8) || "unknown")}`;
64
+ }
65
+
66
+ /** Read the newest valid identity entry on the selected session branch. */
67
+ export function findStoredIdentity(
68
+ entries: readonly unknown[],
69
+ sessionId: string,
70
+ reservedNames: readonly string[] = [],
71
+ ): string | undefined {
72
+ if (!sessionId) return undefined;
73
+ let found: string | undefined;
74
+ for (const entry of entries) {
75
+ if (!entry || typeof entry !== "object") continue;
76
+ const item = entry as { type?: unknown; customType?: unknown; data?: unknown };
77
+ if (item.type !== "custom" || item.customType !== IDENTITY_ENTRY_CUSTOM_TYPE) continue;
78
+ if (!item.data || typeof item.data !== "object") continue;
79
+ const data = item.data as Partial<SessionIdentityEntryData>;
80
+ if (data.version !== 1 || data.sessionId !== sessionId || typeof data.name !== "string") continue;
81
+ const safe = sanitizeIdentityName(data.name, reservedNames);
82
+ if (safe) found = safe;
83
+ }
84
+ return found;
85
+ }
86
+
87
+ export function makeIdentityEntry(sessionId: string, name: string): SessionIdentityEntryData {
88
+ return { version: 1, sessionId, name };
89
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Time awareness — the readings that give the model back a clock, and the one rule that decides
3
+ * how precise each of them may be.
4
+ *
5
+ * Nothing in pi's base prompt carries a date or a time, so from inside a turn five minutes of work
6
+ * and five hours look identical: a supervisor cannot tell how long it has been on a problem, and a
7
+ * peer reply that took twenty minutes reads exactly like an instant one. This module renders the
8
+ * durations that fix that. Pure (every clock is an argument), so none of it needs a real one.
9
+ *
10
+ * The rule, which is why the tail and session-anchor renderings have different granularities:
11
+ *
12
+ * - Text APPENDED to the conversation TAIL (a tool result, a delivered message) is written once
13
+ * and never re-sent as the provider's cached prefix, so precision there is free —
14
+ * {@link formatDuration} and {@link peerSentLabel} may say "2m 5s".
15
+ * - Text in the SYSTEM PROMPT is re-sent every turn and IS the cached prefix. The session anchor
16
+ * therefore uses a deliberately coarse ladder ({@link sessionElapsedLabel}) that steps a couple
17
+ * of dozen times a DAY. The fresh current clock is delivered separately in the per-run tail, so
18
+ * it can carry seconds without invalidating the cached system prefix. The companion
19
+ * pi-persona-mind module makes the same trade for its stable age reading.
20
+ *
21
+ * Both readings state an elapsed time as fact, so both refuse the inputs that would turn that
22
+ * statement into a lie — see {@link parseInstant} and the plausibility bound each one applies.
23
+ */
24
+
25
+ /**
26
+ * Compact wall-clock duration for a TAIL surface: "<1s", "43s", "2m 5s", "2h 5m", "3d 4h". Two
27
+ * units at most — this rides on reports the supervisor reads every time a leg lands.
28
+ */
29
+ export function formatDuration(ms: number): string {
30
+ if (!Number.isFinite(ms) || ms < 0) return "?"; // corrupt/absent input — never render "NaNs"
31
+ const seconds = Math.floor(ms / 1_000);
32
+ if (seconds < 1) return "<1s";
33
+ if (seconds < 60) return `${seconds}s`;
34
+ const minutes = Math.floor(seconds / 60);
35
+ if (minutes < 60) {
36
+ const rest = seconds % 60;
37
+ return rest > 0 ? `${minutes}m ${rest}s` : `${minutes}m`;
38
+ }
39
+ const hours = Math.floor(minutes / 60);
40
+ if (hours < 24) {
41
+ const rest = minutes % 60;
42
+ return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
43
+ }
44
+ const days = Math.floor(hours / 24);
45
+ const rest = hours % 24;
46
+ return rest > 0 ? `${days}d ${rest}h` : `${days}d`;
47
+ }
48
+
49
+ /**
50
+ * An ISO-8601 instant that names exactly one moment: a calendar date — which ECMA-262 fixes to UTC
51
+ * midnight — optionally followed by a time that MUST carry "Z" or a numeric offset. Anchored at
52
+ * both ends and free of nested quantifiers, so it stays linear on hostile input; matching a zone
53
+ * loosely at the tail would read the "-20" of a bare date as an offset.
54
+ */
55
+ const QUALIFIED_INSTANT = /^[+-]?\d{4,6}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:?\d{2}|[+-]\d{2}))?$/;
56
+
57
+ /**
58
+ * Parse an instant that is about to be stated as fact, or NaN.
59
+ *
60
+ * `Date.parse` silently reads a date-time with no zone ("2026-08-20T11:40:00") as the RECEIVER's
61
+ * LOCAL time, so the identical string yields a different instant on every machine — two peers
62
+ * would print two different ages for the same message, each equally confident. An unqualified
63
+ * string is not a slightly-wrong time, it is an unknown one, so it is refused here rather than
64
+ * guessed at.
65
+ */
66
+ export function parseInstant(value: unknown): number {
67
+ if (typeof value !== "string" || !QUALIFIED_INSTANT.test(value)) return Number.NaN;
68
+ return Date.parse(value);
69
+ }
70
+
71
+ /** What the delivery header says when the peer's own timestamp cannot be believed. */
72
+ const UNKNOWN_SENT = "sent at an unknown time (peer clock)";
73
+ /** Two machines' clocks routinely differ by seconds. That is skew, not a claim about the future. */
74
+ const CLOCK_SKEW_TOLERANCE_MS = 60_000;
75
+ /** The widest transit age this header will state as fact. An exocom peer is a LIVE instance in
76
+ * this workspace; past this, the sender's clock — not the network — is the explanation. */
77
+ const MAX_PLAUSIBLE_SENT_AGE_MS = 30 * 86_400_000;
78
+ /** The widest session age the anchor will state as fact. A header a year old is not a long
79
+ * session, it is a corrupt or misread one; every real session sits far below this. */
80
+ const MAX_PLAUSIBLE_SESSION_AGE_MS = 365 * 86_400_000;
81
+
82
+ /**
83
+ * How long ago an inbound peer message was sent, for the delivery header.
84
+ *
85
+ * `ts` is authenticated as coming from that peer (it is inside the signed canonical payload), but
86
+ * its VALUE is the peer's own: its clock may be wrong, or it may simply lie. So the age is measured
87
+ * against the RECEIVER's clock, and anything we cannot believe — a future time beyond ordinary
88
+ * skew, an absurd age, a string with no zone, an unparseable one — degrades to one honest label
89
+ * instead of being rendered as fact ("sent 1096d ago" is a peer-authored claim wearing the
90
+ * harness's voice). Inside the believable window the age is still the peer's claim, only a
91
+ * plausible one: bounding the range narrows what a peer can assert, it does not authenticate it.
92
+ * The result is always a single line: the header it lands on must stay one.
93
+ */
94
+ export function peerSentLabel(ts: unknown, now: number): string {
95
+ const sent = parseInstant(ts);
96
+ if (!Number.isFinite(sent) || !Number.isFinite(now)) return UNKNOWN_SENT;
97
+ const age = now - sent;
98
+ if (age < -CLOCK_SKEW_TOLERANCE_MS || age > MAX_PLAUSIBLE_SENT_AGE_MS) return UNKNOWN_SENT;
99
+ // Tolerated skew lands here too: a message "from the future" by a few seconds just arrived.
100
+ if (age <= 1_000) return "sent just now";
101
+ return `sent ${formatDuration(age)} ago`;
102
+ }
103
+
104
+ /**
105
+ * The session anchor's elapsed reading — COARSE on purpose, because it is re-sent every turn as
106
+ * part of the cached system prefix.
107
+ *
108
+ * The ladder: one bucket for the first quarter hour, then quarter hours to the first hour, then
109
+ * whole hours, then days-plus-hours. Worst case for a DAY of continuous work is 27 changes (3
110
+ * quarter-hour steps + 23 hourly ones + the step into day form), i.e. 27 prefix invalidations where a
111
+ * minute-granular label would force 1440 — 15 of them in the first 12 hours, and 24/day from day two
112
+ * on. Each of those 27 buys real signal, since "how long have I been on this"
113
+ * is a pacing question and nobody paces on minutes after the first hour. Inside any one bucket the
114
+ * rendered anchor is byte-identical, so an hour of turns re-sends the same bytes.
115
+ */
116
+ export function sessionElapsedLabel(elapsedMs: number): string {
117
+ if (!Number.isFinite(elapsedMs)) return "<15m";
118
+ // A clock that moved backwards (an NTP correction, a session file written by a machine running
119
+ // ahead of this one) makes `elapsedMs` negative, which falls into the floor bucket below. It
120
+ // understates; it never invents hours that did not pass.
121
+ const minutes = Math.floor(elapsedMs / 60_000);
122
+ if (minutes < 15) return "<15m";
123
+ if (minutes < 60) return `${Math.floor(minutes / 15) * 15}m`;
124
+ const hours = Math.floor(minutes / 60);
125
+ if (hours < 24) return `${hours}h`;
126
+ return `${Math.floor(hours / 24)}d ${hours % 24}h`;
127
+ }
128
+
129
+ /** The shape this module needs from pi's `SessionHeader` — nothing else, so it stays pure. */
130
+ export interface SessionAnchorHeader {
131
+ timestamp?: unknown;
132
+ }
133
+
134
+ export interface ClockSnapshotLocal {
135
+ /** IANA/local display name. Omit to use the explicit UTC default. */
136
+ timeZone?: string;
137
+ /** Local UTC offset in minutes east of UTC. Omit to use the explicit UTC default. */
138
+ offsetMinutes?: number;
139
+ }
140
+
141
+ function pad2(value: number): string {
142
+ return String(value).padStart(2, "0");
143
+ }
144
+
145
+ /**
146
+ * Fresh per-run clock text. The epoch and optional local projection are injected so this helper is
147
+ * deterministic and stays independent of the host timezone. Callers that want the operator's
148
+ * local zone should resolve it at the runtime boundary and pass both fields.
149
+ */
150
+ export function buildClockSnapshot(now: number, local?: ClockSnapshotLocal): string | undefined {
151
+ if (!Number.isFinite(now)) return undefined;
152
+ const date = new Date(now);
153
+ if (!Number.isFinite(date.getTime())) return undefined;
154
+ const utc = date.toISOString().replace(/\.\d{3}Z$/, "Z");
155
+ const offsetMinutes = local?.offsetMinutes ?? 0;
156
+ if (!Number.isInteger(offsetMinutes) || Math.abs(offsetMinutes) > 24 * 60) return undefined;
157
+ const offsetSign = offsetMinutes >= 0 ? "+" : "-";
158
+ const absoluteOffset = Math.abs(offsetMinutes);
159
+ const offset = `${offsetSign}${pad2(Math.floor(absoluteOffset / 60))}:${pad2(absoluteOffset % 60)}`;
160
+ // Project through UTC getters after applying the selected offset.
161
+ const localDate = new Date(now + offsetMinutes * 60_000);
162
+ if (!Number.isFinite(localDate.getTime())) return undefined;
163
+ const zone = local?.timeZone ?? "UTC";
164
+ const localIso = `${localDate.getUTCFullYear()}-${pad2(localDate.getUTCMonth() + 1)}-${pad2(localDate.getUTCDate())}` +
165
+ `T${pad2(localDate.getUTCHours())}:${pad2(localDate.getUTCMinutes())}:${pad2(localDate.getUTCSeconds())}${offset}`;
166
+ return `Current clock: UTC ${utc}; local ${localIso} (${zone}; UTC${offset}).`;
167
+ }
168
+
169
+ /**
170
+ * The one-line session time anchor for the cached system prompt, or undefined when there is no
171
+ * believable start to report.
172
+ *
173
+ * WHY the system prompt: the prompt is re-composed and re-sent every turn rather than summarized,
174
+ * so an anchor placed there cannot be compacted away — the same fact placed in the conversation
175
+ * tail would be. WHY the header's timestamp: it is the first entry of the append-only session file,
176
+ * so after `/resume` it is still the ORIGINAL start. That is what makes this answer "how long have
177
+ * I been on this problem" across restarts and not merely across turns. (Branching mints a NEW
178
+ * session file with a fresh header, so the reading restarts there — correctly, since that is a new
179
+ * session; the line claims the life of THIS session, not of the one it was cut from.)
180
+ *
181
+ * The absolute start is a constant for the life of the session, so it costs the prompt cache
182
+ * nothing and it is the half that actually survives; the elapsed reading is bucketed
183
+ * ({@link sessionElapsedLabel}) so it flips rarely. Use {@link buildClockSnapshot} for the fresh
184
+ * second-precision reading in a per-run tail.
185
+ */
186
+ export function buildSessionAnchor(header: SessionAnchorHeader | null | undefined, now: number): string | undefined {
187
+ const started = parseInstant(header?.timestamp);
188
+ if (!Number.isFinite(started) || !Number.isFinite(now)) return undefined;
189
+ // Parseable is not believable. An epoch-zero or year-0001 header parses cleanly and would put
190
+ // "you have been on it 20685d 12h" into the system prompt as fact; a start in the future is the
191
+ // same failure with the sign flipped. Both are corruption, not a long session — so neither gets
192
+ // an anchor, by the same rule that governs the unparseable case above.
193
+ const elapsed = now - started;
194
+ if (elapsed < -CLOCK_SKEW_TOLERANCE_MS || elapsed > MAX_PLAUSIBLE_SESSION_AGE_MS) return undefined;
195
+ // Minute precision, UTC: unambiguous wherever the session is resumed, and constant — seconds
196
+ // would add churn to the one part of this line that is supposed to never change.
197
+ const absolute = `${new Date(started).toISOString().slice(0, 16)}Z`;
198
+ return (
199
+ `[pi-persona] Session clock — this session started ${absolute} and you have been on it ${sessionElapsedLabel(elapsed)}. ` +
200
+ "That is the whole life of THIS session, and it survives compaction and restarts; the reading is deliberately coarse, so pace yourself by it rather than measure with it."
201
+ );
202
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `TimerScheduler` — the pure kernel behind the supervisor-armable `timer` tool.
3
+ *
4
+ * Problem it solves: a supervisor that must WAIT for a fixed wall-clock moment (a scheduled event,
5
+ * a service window, a scheduled re-check) has no cheap way to "sleep and resume". The
6
+ * MCP/transport layer caps a blocking tool call at ~20-60s, so a 50-minute wait degenerates into
7
+ * dozens of token-burning polls. The fix is an ALARM, not a sleep: arm a timer that, when it
8
+ * expires, WAKES the session by injecting a follow-up (the extension routes the fire through the
9
+ * same idle-delivery notifier that surfaces async-run completions — an idle delivery always starts
10
+ * a fresh turn, so the supervisor resumes on its own).
11
+ *
12
+ * This module is pure (injected clock + setTimer/clearTimer + an onFire sink), so scheduling,
13
+ * validation, cancellation and listing are unit-tested without a live model or real wall clock.
14
+ * The extension owns the side effects (the actual timer + the wake delivery).
15
+ */
16
+
17
+ /** A scheduled wakeup. `message` is what gets injected into the session when it fires. */
18
+ export interface TimerEntry {
19
+ id: string;
20
+ label: string;
21
+ /** Absolute wall-clock fire time (epoch ms). */
22
+ fireAtEpochMs: number;
23
+ /** When the timer was armed (epoch ms) — for the "armed Xs ago" view. */
24
+ armedAtEpochMs: number;
25
+ /** Receiver wall-clock observed when this fired. Present on the wake copy, absent while armed. */
26
+ observedAtEpochMs?: number;
27
+ /** The follow-up text delivered to the session on fire. */
28
+ message: string;
29
+ }
30
+
31
+ export interface TimerArmRequest {
32
+ /** Human label for the alarm (e.g. "status review"). Optional; defaults to the id. */
33
+ label?: string;
34
+ /** The follow-up injected into the session when the timer fires. Required, non-empty. */
35
+ message: string;
36
+ /** Fire after this many ms from now. Exactly one of delayMs / atEpochMs must be given. */
37
+ delayMs?: number;
38
+ /** Fire at this absolute epoch-ms. Exactly one of delayMs / atEpochMs must be given. */
39
+ atEpochMs?: number;
40
+ }
41
+
42
+ /** A read-only snapshot of an armed timer, with the live remaining time. */
43
+ export interface TimerView {
44
+ id: string;
45
+ label: string;
46
+ fireAtEpochMs: number;
47
+ remainingMs: number;
48
+ message: string;
49
+ }
50
+
51
+ export interface TimerArmResult {
52
+ ok: boolean;
53
+ error?: string;
54
+ entry?: TimerEntry;
55
+ /** Delay measured by the scheduler's injected clock at arm time. */
56
+ remainingMs?: number;
57
+ }
58
+
59
+ export interface TimerSchedulerDeps {
60
+ /** Current wall-clock (epoch ms). Injected so tests control the clock. */
61
+ now: () => number;
62
+ /** Schedule a callback after `ms`; returns an opaque handle. */
63
+ setTimer: (fn: () => void, ms: number) => unknown;
64
+ /** Cancel a scheduled callback by its handle. */
65
+ clearTimer: (handle: unknown) => void;
66
+ /** Called exactly once when a timer expires (never for a cancelled one). */
67
+ onFire: (entry: TimerEntry) => void;
68
+ /** Minimum accepted delay (ms). Default 1000 — sub-second alarms are pointless here. */
69
+ minDelayMs?: number;
70
+ /** Maximum accepted delay (ms). Default 24h — guards against a runaway/typo alarm. */
71
+ maxDelayMs?: number;
72
+ /** Maximum simultaneously armed timers. Default 32 — the tool is model-callable, so a
73
+ * looping supervisor must not pin thousands of live OS timers. */
74
+ maxArmed?: number;
75
+ /** Maximum follow-up message length (chars). Default 4000 — the stored message is
76
+ * re-injected into context at fire time, so it is a budget, not a document. */
77
+ maxMessageChars?: number;
78
+ }
79
+
80
+ const DEFAULT_MIN_DELAY_MS = 1_000;
81
+ const DEFAULT_MAX_DELAY_MS = 24 * 60 * 60 * 1_000;
82
+ const DEFAULT_MAX_ARMED = 32;
83
+ const DEFAULT_MAX_MESSAGE_CHARS = 4_000;
84
+
85
+ interface Scheduled {
86
+ entry: TimerEntry;
87
+ handle: unknown;
88
+ }
89
+
90
+ export class TimerScheduler {
91
+ private readonly deps: TimerSchedulerDeps;
92
+ private readonly minDelayMs: number;
93
+ private readonly maxDelayMs: number;
94
+ private readonly maxArmed: number;
95
+ private readonly maxMessageChars: number;
96
+ private readonly timers = new Map<string, Scheduled>();
97
+ private seq = 0;
98
+
99
+ constructor(deps: TimerSchedulerDeps) {
100
+ this.deps = deps;
101
+ this.minDelayMs = deps.minDelayMs ?? DEFAULT_MIN_DELAY_MS;
102
+ this.maxDelayMs = deps.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
103
+ this.maxArmed = deps.maxArmed ?? DEFAULT_MAX_ARMED;
104
+ this.maxMessageChars = deps.maxMessageChars ?? DEFAULT_MAX_MESSAGE_CHARS;
105
+ }
106
+
107
+ /** Arm a new alarm. Returns the entry on success, or a reason on rejection. */
108
+ arm(req: TimerArmRequest): TimerArmResult {
109
+ const message = (req.message ?? "").trim();
110
+ if (!message) return { ok: false, error: "timer needs a non-empty message (the follow-up injected when it fires)." };
111
+ if (message.length > this.maxMessageChars) {
112
+ return { ok: false, error: `timer message is too long (${message.length} chars; maximum ${this.maxMessageChars}). The follow-up is re-injected into context at fire time — keep it a pointer, not a document.` };
113
+ }
114
+ if (this.timers.size >= this.maxArmed) {
115
+ return { ok: false, error: `too many armed timers (${this.timers.size}; maximum ${this.maxArmed}). Cancel or let some fire before arming more.` };
116
+ }
117
+
118
+ const hasDelay = req.delayMs !== undefined;
119
+ const hasAt = req.atEpochMs !== undefined;
120
+ if (hasDelay === hasAt) {
121
+ return { ok: false, error: "timer needs exactly one of { delayMs } or { atEpochMs }." };
122
+ }
123
+
124
+ const now = this.deps.now();
125
+ if (!Number.isFinite(now)) return { ok: false, error: "timer clock is not a finite number." };
126
+ const fireAt = hasDelay ? now + (req.delayMs as number) : (req.atEpochMs as number);
127
+ if (!Number.isFinite(fireAt)) return { ok: false, error: "timer fire time is not a finite number." };
128
+
129
+ const delay = fireAt - now;
130
+ if (delay < this.minDelayMs) {
131
+ return { ok: false, error: `timer fires too soon (in ${delay}ms; minimum ${this.minDelayMs}ms). For an absolute time, check it is in the future.` };
132
+ }
133
+ if (delay > this.maxDelayMs) {
134
+ return { ok: false, error: `timer fires too far out (in ${delay}ms; maximum ${this.maxDelayMs}ms).` };
135
+ }
136
+
137
+ const id = `timer-${++this.seq}`;
138
+ const entry: TimerEntry = {
139
+ id,
140
+ label: (req.label ?? "").trim() || id,
141
+ fireAtEpochMs: fireAt,
142
+ armedAtEpochMs: now,
143
+ message,
144
+ };
145
+ const handle = this.deps.setTimer(() => this.fire(id), delay);
146
+ this.timers.set(id, { entry, handle });
147
+ return { ok: true, entry, remainingMs: delay };
148
+ }
149
+
150
+ /** Cancel an armed alarm by id. Returns false if unknown (already fired or never existed). */
151
+ cancel(id: string): boolean {
152
+ const scheduled = this.timers.get(id);
153
+ if (!scheduled) return false;
154
+ this.deps.clearTimer(scheduled.handle);
155
+ this.timers.delete(id);
156
+ return true;
157
+ }
158
+
159
+ /** Cancel every armed alarm (reload/dispose hygiene — never leak a timer across sessions). */
160
+ cancelAll(): void {
161
+ for (const scheduled of this.timers.values()) this.deps.clearTimer(scheduled.handle);
162
+ this.timers.clear();
163
+ }
164
+
165
+ /** Snapshot of all armed alarms, soonest first, with live remaining time. */
166
+ list(): TimerView[] {
167
+ const now = this.deps.now();
168
+ return [...this.timers.values()]
169
+ .map((s) => ({
170
+ id: s.entry.id,
171
+ label: s.entry.label,
172
+ fireAtEpochMs: s.entry.fireAtEpochMs,
173
+ remainingMs: Math.max(0, s.entry.fireAtEpochMs - now),
174
+ message: s.entry.message,
175
+ }))
176
+ .sort((a, b) => a.fireAtEpochMs - b.fireAtEpochMs);
177
+ }
178
+
179
+ /** Number of currently armed alarms. */
180
+ get size(): number {
181
+ return this.timers.size;
182
+ }
183
+
184
+ /** Current wall-clock reading from the scheduler's injected clock. */
185
+ now(): number {
186
+ return this.deps.now();
187
+ }
188
+
189
+ private fire(id: string): void {
190
+ const scheduled = this.timers.get(id);
191
+ if (!scheduled) return; // cancelled between expiry and callback — nothing to do
192
+ this.timers.delete(id);
193
+ const observedAtEpochMs = this.deps.now();
194
+ this.deps.onFire(Number.isFinite(observedAtEpochMs) ? { ...scheduled.entry, observedAtEpochMs } : scheduled.entry);
195
+ }
196
+ }
197
+
198
+ /** Render a fired-timer batch into one wake message for the supervisor. Exported for reuse + test. */
199
+ export function renderTimerFire(entries: TimerEntry[]): string {
200
+ if (entries.length === 0) return "";
201
+ const head = `[pi-persona] ⏰ ${entries.length} timer${entries.length === 1 ? "" : "s"} fired — resuming:`;
202
+ const blocks = entries.map((e) => {
203
+ const due = new Date(e.fireAtEpochMs).toISOString();
204
+ const observed = Number.isFinite(e.observedAtEpochMs)
205
+ ? `; observed ${new Date(e.observedAtEpochMs as number).toISOString()}`
206
+ : "";
207
+ return `• ${e.label} (${e.id}) — due ${due}${observed}: ${e.message}`;
208
+ });
209
+ return [head, ...blocks].join("\n");
210
+ }
211
+
212
+ /** Compact human-readable duration for the `list` view (e.g. "49m 12s", "1h 03m"). */
213
+ export function formatRemaining(ms: number): string {
214
+ const s = Math.max(0, Math.round(ms / 1000));
215
+ const h = Math.floor(s / 3600);
216
+ const m = Math.floor((s % 3600) / 60);
217
+ const sec = s % 60;
218
+ if (h > 0) return `${h}h ${String(m).padStart(2, "0")}m`;
219
+ if (m > 0) return `${m}m ${String(sec).padStart(2, "0")}s`;
220
+ return `${sec}s`;
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared primitive types and small validators used across pi-persona.
3
+ *
4
+ * Pure module — no Pi imports. Keep this to genuinely cross-cutting primitives;
5
+ * module-specific types live with their module.
6
+ */
7
+
8
+ import type { Permission } from "./permissions.ts";
9
+
10
+ export type { Permission };
11
+
12
+ /** Pi reasoning/effort levels (mirrors `@earendil-works/pi-ai`'s `ThinkingLevel`). Keep in lockstep
13
+ * with upstream: `max` was added in pi 0.80.6 (above `xhigh`). If pi's union widens again, add the
14
+ * new level here too — `pi.getThinkingLevel()` returns the upstream union, so a missing member here
15
+ * is a hard compile error where the host reads it (see `PersonaHost.getThinkingLevel`). */
16
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
17
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
18
+
19
+ export function isThinkingLevel(x: unknown): x is ThinkingLevel {
20
+ return typeof x === "string" && (THINKING_LEVELS as readonly string[]).includes(x);
21
+ }
22
+
23
+ /** How a persona/agent body combines with the base system prompt. */
24
+ export type SystemPromptMode = "append" | "replace";
25
+
26
+ export function asSystemPromptMode(x: unknown, fallback: SystemPromptMode): SystemPromptMode {
27
+ return x === "append" || x === "replace" ? x : fallback;
28
+ }