@danypops/papyrus 0.15.0 → 0.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.
@@ -13,19 +13,6 @@ const SEGMENT_COLORS: Record<ContextSegment["key"], ThemeColor> = {
13
13
  other: "muted",
14
14
  };
15
15
 
16
- /** Short, fixed-width column labels for the vertical deep-dive graph -- must match VERTICAL_BAR_WIDTH exactly so each label sits centered under its own bar. */
17
- const SEGMENT_SHORT_LABELS: Record<ContextSegment["key"], string> = {
18
- rules: "Rul",
19
- tasks: "Tsk",
20
- skills: "Skl",
21
- basePrompt: "Bse",
22
- messageHistory: "Msg",
23
- other: "Oth",
24
- };
25
-
26
- const VERTICAL_BAR_HEIGHT = 6;
27
- const VERTICAL_BAR_WIDTH = 3;
28
-
29
16
  /**
30
17
  * One row in the unified scrollable view. Every segment that has any real (nonzero) content
31
18
  * is fully expanded inline -- there is no separate "select a segment, then drill in" step.
@@ -117,16 +104,10 @@ class ContextViewport {
117
104
  } else {
118
105
  lines.push(theme.fg("dim", "No real usage reported yet — sizes below are Papyrus's own estimates only"));
119
106
  }
120
- lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined));
107
+ lines.push(renderContextBar(theme, this.breakdown.segments, contentWidth, this.breakdown.effectiveBudget ?? undefined, this.breakdown.totalTokens ?? undefined));
121
108
  if (this.breakdown.overshootTokens > 0) {
122
109
  lines.push(truncateToWidth(theme.fg("warning", `Estimates exceed real total by ~${this.breakdown.overshootTokens} tok — sizes below are approximate, not exact`), contentWidth, ""));
123
110
  }
124
- const verticalBars = renderContextVerticalBars(theme, this.breakdown.segments);
125
- if (verticalBars.length > 0) {
126
- lines.push("");
127
- lines.push(theme.fg("dim", "Composition of used tokens:"));
128
- for (const barLine of verticalBars) lines.push(truncateToWidth(barLine, contentWidth, ""));
129
- }
130
111
  lines.push("");
131
112
 
132
113
  this.visibleWindow().forEach(({ row, index }) => {
@@ -161,6 +142,26 @@ class ContextViewport {
161
142
  }
162
143
  }
163
144
 
145
+ /**
146
+ * Distributes `totalCells` proportionally across `weights` (parallel arrays), guaranteeing
147
+ * every genuinely-positive weight gets at least one cell when there is room for all of them
148
+ * to (totalCells >= weights.length) -- a real, nonzero segment must stay visible even when
149
+ * dwarfed by a much larger one, not round away to nothing. The largest resulting cell count
150
+ * absorbs whatever rounding leaves over or short, so the sum always equals totalCells exactly.
151
+ */
152
+ function distributeCells(weights: readonly number[], totalCells: number): number[] {
153
+ const sum = weights.reduce((a, b) => a + b, 0);
154
+ if (sum <= 0 || totalCells <= 0 || weights.length === 0) return weights.map(() => 0);
155
+ let cells = weights.map((weight) => Math.round((weight / sum) * totalCells));
156
+ if (totalCells >= weights.length) cells = cells.map((count) => (count === 0 ? 1 : count));
157
+ const diff = totalCells - cells.reduce((a, b) => a + b, 0);
158
+ if (diff !== 0) {
159
+ const maxIndex = cells.indexOf(Math.max(...cells));
160
+ cells[maxIndex] = (cells[maxIndex] ?? 0) + diff;
161
+ }
162
+ return cells;
163
+ }
164
+
164
165
  /**
165
166
  * Renders the context window as one horizontal stacked bar: one colored run of block
166
167
  * characters per USED segment, followed by a gray/dim run of "░" cells for the remaining,
@@ -169,23 +170,30 @@ class ContextViewport {
169
170
  * divide-by-zero, since 0 used really does mean the whole window is empty right now.
170
171
  *
171
172
  * `capacity` is the real denominator (Papyrus's own effectiveBudget, matching the percentage
172
- * already shown in the text line above this bar) that used-vs-unused is measured against. When
173
- * omitted, or when usage has already exceeded it (overshoot / near-compaction), the bar falls
174
- * back to filling 100% of its width proportionally among segments -- there is no "unused" left
175
- * to show gray for once real usage has met or passed the real budget.
173
+ * already shown in the text line above this bar). `usedTokens` is the real, ground-truth used
174
+ * amount (breakdown.totalTokens) the used-vs-unused split is measured against -- NOT the sum of
175
+ * `segments`' own estimates. That distinction is load-bearing: a live-reported bug showed a
176
+ * fully solid bar with zero gray even though the header read "55.9% of usable budget", because
177
+ * the old code compared `capacity` against the SUM of estimated segments, which independently
178
+ * overshot both the real total and the capacity itself (a session whose message-history
179
+ * estimate alone summed to over 1.5M tokens against a real ~550k total) -- the exact estimate-
180
+ * overshoot dishonesty `overshootTokens` exists to surface elsewhere was silently defeating the
181
+ * bar's own gray/used split. `usedTokens` defaults to the segment sum only when omitted, for
182
+ * callers with no real total available. Segments still split the USED portion proportionally to
183
+ * their own estimated share of each other (via distributeCells, which also guarantees a tiny
184
+ * nonzero segment stays visible rather than rounding to nothing next to a much larger one).
176
185
  */
177
- export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number): string {
178
- const total = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
179
- if (total <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
180
- const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
181
- const usedWidth = capacity !== undefined && capacity > total ? Math.min(width, Math.round((total / capacity) * width)) : width;
186
+ export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSegment>, width: number, capacity?: number, usedTokens?: number): string {
187
+ const estimatedSum = segments.reduce((sum, segment) => sum + segment.estimatedTokens, 0);
188
+ if (estimatedSum <= 0 || width <= 0) return theme.fg("dim", "░".repeat(Math.max(0, width)));
189
+ const realUsed = usedTokens ?? estimatedSum;
190
+ const usedWidth = capacity !== undefined ? Math.max(0, Math.min(width, Math.round((realUsed / capacity) * width))) : width;
182
191
 
183
- let used = 0;
192
+ const nonZero = segments.filter((segment) => segment.estimatedTokens > 0);
193
+ const cellCounts = distributeCells(nonZero.map((segment) => segment.estimatedTokens), usedWidth);
184
194
  let output = "";
185
195
  nonZero.forEach((segment, index) => {
186
- const isLast = index === nonZero.length - 1;
187
- const cells = isLast ? usedWidth - used : Math.round((segment.estimatedTokens / total) * usedWidth);
188
- used += cells;
196
+ const cells = cellCounts[index] ?? 0;
189
197
  if (cells > 0) output += theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(cells));
190
198
  });
191
199
  const emptyWidth = width - usedWidth;
@@ -193,36 +201,6 @@ export function renderContextBar(theme: Theme, segments: ReadonlyArray<ContextSe
193
201
  return output;
194
202
  }
195
203
 
196
- /**
197
- * Renders the "used" portion's own composition as a small vertical bar chart, one column per
198
- * segment with real content, scaled so the largest segment fills the full height -- the
199
- * "deep dive" graph, complementing the horizontal used-vs-unused bar above it. Any segment
200
- * with real (nonzero) tokens gets at least one filled row so it stays visible even next to a
201
- * much larger segment. Returns an empty array (nothing to render) when no segment has any
202
- * tokens yet, matching the same zero-noise principle as the row list below it.
203
- */
204
- export function renderContextVerticalBars(theme: Theme, segments: ReadonlyArray<ContextSegment>): string[] {
205
- const visible = segments.filter((segment) => segment.estimatedTokens > 0);
206
- if (visible.length === 0) return [];
207
- const max = Math.max(...visible.map((segment) => segment.estimatedTokens));
208
- const filledRows = new Map(visible.map((segment) => [segment.key, Math.max(1, Math.round((segment.estimatedTokens / max) * VERTICAL_BAR_HEIGHT))]));
209
-
210
- const lines: string[] = [];
211
- for (let row = 0; row < VERTICAL_BAR_HEIGHT; row++) {
212
- const rowsFromBottom = VERTICAL_BAR_HEIGHT - row;
213
- let line = "";
214
- for (const segment of visible) {
215
- const filled = (filledRows.get(segment.key) ?? 0) >= rowsFromBottom;
216
- line += `${filled ? theme.fg(SEGMENT_COLORS[segment.key], "█".repeat(VERTICAL_BAR_WIDTH)) : " ".repeat(VERTICAL_BAR_WIDTH)} `;
217
- }
218
- lines.push(line);
219
- }
220
- let legend = "";
221
- for (const segment of visible) legend += `${theme.fg(SEGMENT_COLORS[segment.key], SEGMENT_SHORT_LABELS[segment.key])} `;
222
- lines.push(legend);
223
- return lines;
224
- }
225
-
226
204
  /** Non-interactive fallback (print mode, RPC, etc.): the same unified row list, as plain text lines. */
227
205
  function fallbackReport(breakdown: ContextBreakdown): string {
228
206
  const totalLine = breakdown.totalTokens !== null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.15.0",
3
+ "version": "0.15.2",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
package/src/constants.ts CHANGED
@@ -30,7 +30,8 @@ export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
30
30
  * tree when estimating /context's message-history and task segments. Both are genuine trees
31
31
  * built from external, mutable state (a session file; the live Task graph) -- the node bound
32
32
  * is a defensive measure against a corrupted/adversarial parentId chain forming an accidental
33
- * cycle, matching the same cycle-safety discipline already applied to ConversationJournal
33
+ * cycle, matching the same cycle-safety discipline established by the (since-removed;
34
+ * see Doc "ConversationJournal design record") ConversationJournal domain's own reply-chain
34
35
  * traversal and deliberately hardening past a real, confirmed gap in Pi's own getBranch() (no
35
36
  * cycle guard at all). Set generously: a real, ordinary (non-branching) long-running session
36
37
  * is one long linear chain, so a naively small bound truncates the walk after counting only a
@@ -92,8 +93,9 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
92
93
  * to (existing Tasks/Rules/Docs via ordinary edges, not just its own static body/extra
93
94
  * fields), and a Skill can link to and invoke other Skills. Both traversals are bounded and
94
95
  * cycle-safe -- a skill-calls-skill edge cycle must not infinite-loop invocation, matching
95
- * the cycle-safety discipline already established for ConversationJournal reply chains and
96
- * task dependency graphs.
96
+ * the same cycle-safety discipline established by task dependency graphs and the
97
+ * (since-removed; see Doc "ConversationJournal design record") ConversationJournal domain's
98
+ * own reply chains.
97
99
  */
98
100
  export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
99
101
  export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
@@ -10,9 +10,10 @@
10
10
  * first-class concern here in a way it deliberately is NOT for the permanent
11
11
  * artifact_events/task_events audit trails: logs are meant to be rotated, not kept forever.
12
12
  *
13
- * Mirrors ConversationJournal's own discipline (idempotency via a caller-constructed
14
- * composite operationId, explicit non-silent truncation) since both are append-only,
15
- * externally-sourced record streams -- but logs have no reply structure and do carry a
13
+ * Mirrors the (since-removed; see Doc "ConversationJournal design record") ConversationJournal
14
+ * domain's own discipline (idempotency via a caller-constructed composite operationId,
15
+ * explicit non-silent truncation) since both are append-only, externally-sourced record
16
+ * streams -- but logs have no reply structure and do carry a
16
17
  * retention policy, which a durable conversation record deliberately does not.
17
18
  */
18
19
 
@@ -49,8 +50,9 @@ export interface LogEntry {
49
50
  /**
50
51
  * Idempotency key. Must be a composite the caller constructs (e.g. `${sessionId}:${turn}`),
51
52
  * never a bare upstream id alone -- a source's own local ids are commonly unique only
52
- * within one recording run, not globally, matching ConversationJournal's identical
53
- * operationId discipline and the concrete case it generalizes from (Pi's /tree lessons).
53
+ * within one recording run, not globally, matching the same operationId discipline
54
+ * established by the (since-removed) ConversationJournal domain and the concrete case
55
+ * it generalizes from (Pi's /tree lessons).
54
56
  */
55
57
  readonly operationId: string;
56
58
  readonly sessionId?: string;
@@ -388,8 +388,9 @@ function skillInvocationBody(skill: Artifact): string {
388
388
  * workflow execution already uses for skill-to-task edges): invoking the parent recursively
389
389
  * composes the linked skill's own invocation. Bounded and cycle-safe -- a skill-calls-skill
390
390
  * edge cycle degrades to a marker instead of infinite-looping, matching the cycle-safety
391
- * discipline already established for ConversationJournal reply chains and task dependency
392
- * graphs. `visited` and `depth` are recursion-internal; callers should not pass them.
391
+ * discipline established by task dependency graphs and the (since-removed; see Doc
392
+ * "ConversationJournal design record") ConversationJournal domain's own reply chains.
393
+ * `visited` and `depth` are recursion-internal; callers should not pass them.
393
394
  */
394
395
  export function skillInvocation(artifacts: ArtifactStore, id: string, visited: Set<string> = new Set(), depth = 0): string {
395
396
  const skill = requireKind(artifacts, id, "skill");
@@ -1,10 +1,11 @@
1
1
  import type { LogEntry, LogSource } from "../domain/log-entry.ts";
2
2
 
3
3
  /**
4
- * Persistence port for the `log` domain. Deliberately minimal, matching
5
- * ConversationJournalStore's own split: this is dumb storage (idempotency-key lookup,
6
- * insert, bounded-at-the-service-layer reads) plus one operation the store must own because
7
- * only it knows real row counts -- retention trimming.
4
+ * Persistence port for the `log` domain. Deliberately minimal, matching the split established
5
+ * by the (since-removed; see Doc "ConversationJournal design record") ConversationJournalStore:
6
+ * this is dumb storage (idempotency-key lookup, insert, bounded-at-the-service-layer reads)
7
+ * plus one operation the store must own because only it knows real row counts -- retention
8
+ * trimming.
8
9
  */
9
10
  export interface LogStore {
10
11
  ensureSource(sourceId: string, label: string, projectRoot: string | null): LogSource;
@@ -1,48 +0,0 @@
1
- import type { JournalPost, JournalThread } from "../domain/conversation-journal.ts";
2
- import type { ConversationJournalStore } from "../ports/conversation-journal-store.ts";
3
-
4
- /**
5
- * Bounded in-memory conformance fixture -- the reference implementation the
6
- * conversationJournalConformanceSuite is proven against first, before any real
7
- * persistence backend needs to satisfy the same contract.
8
- */
9
- export class InMemoryConversationJournalStore implements ConversationJournalStore {
10
- private readonly threads = new Map<string, JournalThread>();
11
- private readonly posts = new Map<string, JournalPost>();
12
- private readonly postIdsByOperationId = new Map<string, string>();
13
- private readonly postIdsByThread = new Map<string, string[]>();
14
-
15
- ensureThread(threadId: string): JournalThread {
16
- const existing = this.threads.get(threadId);
17
- if (existing) return existing;
18
- const thread: JournalThread = { id: threadId, createdAt: new Date().toISOString() };
19
- this.threads.set(threadId, thread);
20
- return thread;
21
- }
22
-
23
- getThread(threadId: string): JournalThread | undefined {
24
- return this.threads.get(threadId);
25
- }
26
-
27
- findPostByOperationId(operationId: string): JournalPost | undefined {
28
- const postId = this.postIdsByOperationId.get(operationId);
29
- return postId ? this.posts.get(postId) : undefined;
30
- }
31
-
32
- insertPost(post: JournalPost): void {
33
- this.posts.set(post.id, post);
34
- this.postIdsByOperationId.set(post.operationId, post.id);
35
- const ids = this.postIdsByThread.get(post.threadId) ?? [];
36
- ids.push(post.id);
37
- this.postIdsByThread.set(post.threadId, ids);
38
- }
39
-
40
- getPost(id: string): JournalPost | undefined {
41
- return this.posts.get(id);
42
- }
43
-
44
- postsForThread(threadId: string): readonly JournalPost[] {
45
- const ids = this.postIdsByThread.get(threadId) ?? [];
46
- return ids.map((id) => this.posts.get(id)!);
47
- }
48
- }
@@ -1,87 +0,0 @@
1
- /**
2
- * conversation-journal-service.ts — host-neutral ConversationJournal application layer.
3
- *
4
- * Owns idempotency (checking operationId before insert) and bounds enforcement; the
5
- * ConversationJournalStore port underneath is dumb storage. See
6
- * src/domain/conversation-journal.ts for the domain shapes and the design rationale.
7
- */
8
- import {
9
- ancestorChain,
10
- boundContent,
11
- buildThreadTree,
12
- CONVERSATION_JOURNAL_READ_MAX_POSTS,
13
- validateAppendPostCommand,
14
- type AppendPostCommand,
15
- type AppendPostResult,
16
- type JournalPost,
17
- type JournalThread,
18
- type ReadThreadQuery,
19
- type ThreadPage,
20
- type ThreadTreeNode,
21
- } from "./domain/conversation-journal.ts";
22
- import type { ConversationJournalStore } from "./ports/conversation-journal-store.ts";
23
-
24
- export class ConversationJournalService {
25
- constructor(private readonly store: ConversationJournalStore) {}
26
-
27
- appendPost(command: AppendPostCommand): AppendPostResult {
28
- validateAppendPostCommand(command);
29
-
30
- const existing = this.store.findPostByOperationId(command.operationId);
31
- if (existing) return { post: existing, replayed: true };
32
-
33
- if (command.replyToPostId !== undefined && !this.store.getPost(command.replyToPostId)) {
34
- throw new Error(`replyToPostId "${command.replyToPostId}" not found`);
35
- }
36
-
37
- this.store.ensureThread(command.threadId);
38
- const { content, truncated } = boundContent(command.content);
39
- const post: JournalPost = {
40
- id: crypto.randomUUID(),
41
- threadId: command.threadId,
42
- ...(command.replyToPostId !== undefined ? { replyToPostId: command.replyToPostId } : {}),
43
- authorId: command.authorId,
44
- content,
45
- truncated,
46
- timestamp: new Date().toISOString(),
47
- sourceSessionId: command.sourceSessionId,
48
- operationId: command.operationId,
49
- references: command.references ? [...command.references] : [],
50
- };
51
- this.store.insertPost(post);
52
- return { post, replayed: false };
53
- }
54
-
55
- getThread(threadId: string): JournalThread | undefined {
56
- return this.store.getThread(threadId);
57
- }
58
-
59
- getPost(id: string): JournalPost | undefined {
60
- return this.store.getPost(id);
61
- }
62
-
63
- /** Bounded, explicit-completeness thread read. Never silently drops posts past the bound. */
64
- readThread(query: ReadThreadQuery): ThreadPage {
65
- const limit = query.limit;
66
- if (!Number.isInteger(limit) || limit < 1 || limit > CONVERSATION_JOURNAL_READ_MAX_POSTS) {
67
- throw new Error(`readThread limit must be between 1 and ${CONVERSATION_JOURNAL_READ_MAX_POSTS}`);
68
- }
69
- const all = [...this.store.postsForThread(query.threadId)].sort(
70
- (left, right) => left.timestamp.localeCompare(right.timestamp) || left.id.localeCompare(right.id),
71
- );
72
- const truncated = all.length > limit;
73
- return { posts: all.slice(0, limit), truncated };
74
- }
75
-
76
- /** Reconstructed reply tree for a thread, bounded and orphan/cycle-safe -- see buildThreadTree. */
77
- readThreadTree(threadId: string): ThreadTreeNode[] {
78
- return buildThreadTree(this.store.postsForThread(threadId));
79
- }
80
-
81
- /** Root-first ancestor chain for one post -- the host-neutral equivalent of Pi's getBranch(). */
82
- ancestorsOf(postId: string): JournalPost[] {
83
- const posts = this.store.postsForThread(this.store.getPost(postId)?.threadId ?? "");
84
- const byId = new Map(posts.map((post) => [post.id, post]));
85
- return ancestorChain(postId, byId);
86
- }
87
- }
@@ -1,168 +0,0 @@
1
- /**
2
- * domain/conversation-journal.ts — host-neutral ConversationJournal domain.
3
- *
4
- * See decision-discourse-as-a-layer-above-sessions-separate-conver-p832 and
5
- * pis-tree-implementation-concrete-lessons-for-the-discourse-s-wnrs (Papyrus docs) for the
6
- * design this implements and the concrete lessons behind each choice below.
7
- *
8
- * A Thread is the conversation's own stable identity, independent of whichever host
9
- * process/session recorded any given Post into it. This module and its package source
10
- * must never mention host runtime names (no "Pi") -- sourceSessionId is a generic
11
- * provenance field on a Post, not a host-specific concept, and the host decides what
12
- * value to put there.
13
- */
14
-
15
- export const CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS = 20_000;
16
- export const CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH = 256;
17
- export const CONVERSATION_JOURNAL_MAX_REFERENCES_PER_POST = 50;
18
- /** Bounds a single readThread call; retention/eviction beyond this is a host/persistence concern, not this domain's. */
19
- export const CONVERSATION_JOURNAL_READ_MAX_POSTS = 500;
20
- /** Bounds reply-chain traversal so a cycle (accidental or adversarial) cannot infinite-loop a tree build -- see the Pi /tree lessons doc for why this must not be assumed away. */
21
- export const CONVERSATION_JOURNAL_MAX_TRAVERSAL_DEPTH = 10_000;
22
-
23
- export type JournalAuthor = "human" | "agent";
24
-
25
- /** A reference to an artifact owned outside this journal -- verified by the host, never asserted. */
26
- export interface ArtifactReference {
27
- readonly kind: string;
28
- readonly id: string;
29
- }
30
-
31
- export interface JournalThread {
32
- readonly id: string;
33
- readonly createdAt: string;
34
- }
35
-
36
- export interface JournalPost {
37
- readonly id: string;
38
- readonly threadId: string;
39
- /** Absent only for a thread's first post. */
40
- readonly replyToPostId?: string;
41
- readonly authorId: JournalAuthor;
42
- readonly content: string;
43
- /** True when content was cut to CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS -- never silently. */
44
- readonly truncated: boolean;
45
- readonly timestamp: string;
46
- /** Which host session/process recorded this post. Provenance, never this domain's top-level container -- see the layering decision doc. */
47
- readonly sourceSessionId: string;
48
- /**
49
- * Idempotency key. Must be a composite of (sourceSessionId, a host-local entry id),
50
- * constructed by the caller -- never a bare host entry id alone. A host's own entry
51
- * ids are commonly unique only within one recording session, not globally; using one
52
- * alone as a global idempotency key risks a false dedup collision between two
53
- * unrelated sessions. See the Pi /tree lessons doc for the concrete case this
54
- * generalizes from.
55
- */
56
- readonly operationId: string;
57
- readonly references: readonly ArtifactReference[];
58
- }
59
-
60
- export interface AppendPostCommand {
61
- readonly threadId: string;
62
- readonly replyToPostId?: string;
63
- readonly authorId: JournalAuthor;
64
- readonly content: string;
65
- readonly sourceSessionId: string;
66
- readonly operationId: string;
67
- readonly references?: readonly ArtifactReference[];
68
- }
69
-
70
- export interface AppendPostResult {
71
- readonly post: JournalPost;
72
- /** True when this exact operationId was already journaled and this call was a safe no-op replay. */
73
- readonly replayed: boolean;
74
- }
75
-
76
- export interface ReadThreadQuery {
77
- readonly threadId: string;
78
- readonly limit: number;
79
- }
80
-
81
- export interface ThreadPage {
82
- readonly posts: readonly JournalPost[];
83
- /** True when more posts exist beyond `limit` -- never silently drop the tail without saying so. */
84
- readonly truncated: boolean;
85
- }
86
-
87
- /** One node of a reconstructed reply tree; see buildThreadTree. */
88
- export interface ThreadTreeNode {
89
- readonly post: JournalPost;
90
- readonly children: ThreadTreeNode[];
91
- }
92
-
93
- function requireBounded(value: string, label: string, maxLength: number): string {
94
- if (value.length === 0) throw new Error(`${label} is required`);
95
- if (value.length > maxLength) throw new Error(`${label} exceeds ${maxLength} characters`);
96
- return value;
97
- }
98
-
99
- export function validateAppendPostCommand(command: AppendPostCommand): void {
100
- requireBounded(command.threadId, "threadId", CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH);
101
- requireBounded(command.sourceSessionId, "sourceSessionId", CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH);
102
- requireBounded(command.operationId, "operationId", CONVERSATION_JOURNAL_SOURCE_ID_MAX_LENGTH * 2);
103
- if (command.content.length === 0) throw new Error("content is required");
104
- if (command.authorId !== "human" && command.authorId !== "agent") throw new Error('authorId must be "human" or "agent"');
105
- const references = command.references ?? [];
106
- if (references.length > CONVERSATION_JOURNAL_MAX_REFERENCES_PER_POST) {
107
- throw new Error(`a post is bounded to ${CONVERSATION_JOURNAL_MAX_REFERENCES_PER_POST} references; got ${references.length}`);
108
- }
109
- }
110
-
111
- /** Applies the explicit truncation bound. Never silently drops the truncation fact -- callers must surface `truncated`. */
112
- export function boundContent(content: string): { content: string; truncated: boolean } {
113
- if (content.length <= CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS) return { content, truncated: false };
114
- return { content: content.slice(0, CONVERSATION_JOURNAL_CONTENT_MAX_CHARACTERS), truncated: true };
115
- }
116
-
117
- /**
118
- * Reconstructs the reply tree from a flat list of posts (as returned by one bounded
119
- * readThread call). A post whose replyToPostId does not resolve within this same list --
120
- * because it is genuinely a thread root, or because an ancestor aged out under a
121
- * retention policy -- degrades to being treated as a root of its own sub-tree, exactly
122
- * like Pi's own getTree() treats an orphaned entry. This never throws on that account.
123
- *
124
- * Bounded and cycle-safe: a post already visited while walking up cannot be revisited,
125
- * so a malformed or adversarial replyToPostId cycle cannot infinite-loop this function --
126
- * see the Pi /tree lessons doc for why that guard must be explicit, not assumed.
127
- */
128
- export function buildThreadTree(posts: readonly JournalPost[]): ThreadTreeNode[] {
129
- const nodesById = new Map<string, ThreadTreeNode>();
130
- for (const post of posts) nodesById.set(post.id, { post, children: [] });
131
-
132
- const roots: ThreadTreeNode[] = [];
133
- for (const post of posts) {
134
- const node = nodesById.get(post.id)!;
135
- const parent = post.replyToPostId ? nodesById.get(post.replyToPostId) : undefined;
136
- if (parent) parent.children.push(node);
137
- else roots.push(node);
138
- }
139
-
140
- for (const node of nodesById.values()) {
141
- node.children.sort((left, right) => left.post.timestamp.localeCompare(right.post.timestamp) || left.post.id.localeCompare(right.post.id));
142
- }
143
- roots.sort((left, right) => left.post.timestamp.localeCompare(right.post.timestamp) || left.post.id.localeCompare(right.post.id));
144
- return roots;
145
- }
146
-
147
- /**
148
- * Walks from a post back toward its thread root via replyToPostId, root-first order --
149
- * the host-neutral equivalent of Pi's getBranch(). Bounded by
150
- * CONVERSATION_JOURNAL_MAX_TRAVERSAL_DEPTH and tracks visited ids explicitly so a cycle
151
- * cannot infinite-loop this walk, unlike Pi's own getBranch() (a documented gap in Pi,
152
- * not something to assume away here).
153
- */
154
- export function ancestorChain(postId: string, postsById: ReadonlyMap<string, JournalPost>): JournalPost[] {
155
- const chain: JournalPost[] = [];
156
- const visited = new Set<string>();
157
- let currentId: string | undefined = postId;
158
- while (currentId !== undefined) {
159
- if (visited.has(currentId)) break; // cycle guard
160
- if (chain.length >= CONVERSATION_JOURNAL_MAX_TRAVERSAL_DEPTH) break; // depth guard
161
- visited.add(currentId);
162
- const post = postsById.get(currentId);
163
- if (!post) break; // orphan: stop here, do not error
164
- chain.push(post);
165
- currentId = post.replyToPostId;
166
- }
167
- return chain.reverse();
168
- }
@@ -1,17 +0,0 @@
1
- import type { JournalPost, JournalThread } from "../domain/conversation-journal.ts";
2
-
3
- /**
4
- * Persistence port for ConversationJournal. Deliberately minimal and host-neutral: no
5
- * mention of any host runtime, no query beyond what a bounded thread read needs. Idempotency
6
- * (checking operationId before insert) is the service's job, not the store's -- this port is
7
- * dumb storage, matching Discourse's own store/service split (see the layering decision doc).
8
- */
9
- export interface ConversationJournalStore {
10
- ensureThread(threadId: string): JournalThread;
11
- getThread(threadId: string): JournalThread | undefined;
12
- findPostByOperationId(operationId: string): JournalPost | undefined;
13
- insertPost(post: JournalPost): void;
14
- getPost(id: string): JournalPost | undefined;
15
- /** All posts for one thread, unbounded at the store layer -- the service applies the read bound. */
16
- postsForThread(threadId: string): readonly JournalPost[];
17
- }