@danypops/papyrus 0.13.3 → 0.13.4

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.
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os";
2
2
  import { readFileSync } from "node:fs";
3
- import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_DEPTH, CONTEXT_TREE_MAX_NODES } from "../../src/constants.ts";
3
+ import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN, CONTEXT_TREE_MAX_NODES } from "../../src/constants.ts";
4
4
  import type { Artifact } from "../../src/domain/artifact.ts";
5
5
  import type { TaskGraph } from "../../src/task-service.ts";
6
6
  import { discoverSkillDirectories, scanSkillCatalogFootprint, type SkillCatalogFootprint } from "./skill-catalog-footprint.ts";
@@ -162,7 +162,7 @@ export interface MessageHistoryTree {
162
162
  items: ContextSegmentItem[];
163
163
  /** Sum of tokens for entries on the CURRENT active path only -- what actually feeds the LLM's context right now, unlike content sitting in an abandoned /tree branch. */
164
164
  activeTokens: number;
165
- /** True if the walk hit CONTEXT_TREE_MAX_DEPTH or CONTEXT_TREE_MAX_NODES, or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
165
+ /** True if the walk hit CONTEXT_TREE_MAX_NODES or found a cycle -- the tree shown is a bounded prefix, not necessarily the complete session. */
166
166
  truncated: boolean;
167
167
  }
168
168
 
@@ -171,24 +171,49 @@ export interface MessageHistoryTree {
171
171
  * entries form a genuine tree via id/parentId, not just the linear current-branch path) to
172
172
  * estimate the conversation's context contribution AND surface branches explored via /tree
173
173
  * that are no longer on the active path -- content that cost real tokens to generate but is
174
- * NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_DEPTH /
175
- * CONTEXT_TREE_MAX_NODES): a session file is external, mutable state, and this deliberately
174
+ * NOT currently part of the context window. Bounded and cycle-safe (CONTEXT_TREE_MAX_NODES):
175
+ * a session file is external, mutable state, and this deliberately
176
176
  * hardens past a confirmed real gap in Pi's own getBranch() (no cycle guard at all) rather
177
177
  * than assuming the tree can never be malformed.
178
178
  */
179
+ interface WalkFrame {
180
+ node: SessionTreeNodeLike;
181
+ parentIndex: number | null;
182
+ }
183
+
184
+ /**
185
+ * Iterative (not recursive) two-pass walk: an explicit-stack pre-order discovery pass
186
+ * followed by a reverse-order (children-before-parent) construction pass. A real, ordinary
187
+ * (non-branching) long-running session is one long linear chain, so recursion depth would
188
+ * equal entry count -- a session observed in production with 6,924 entries on its own active
189
+ * branch confirmed this is not a hypothetical concern; a naive recursive walk risks a real
190
+ * JavaScript call-stack overflow at that scale, independent of the CONTEXT_TREE_MAX_NODES
191
+ * bound entirely.
192
+ */
179
193
  export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike>, activeEntryIds: ReadonlySet<string>): MessageHistoryTree {
180
194
  const visited = new Set<string>();
181
195
  let truncated = false;
182
196
  let activeTokens = 0;
183
- let nodesVisited = 0;
184
197
 
185
- function visit(node: SessionTreeNodeLike, depth: number): ContextSegmentItem | null {
186
- if (nodesVisited >= CONTEXT_TREE_MAX_NODES || depth > CONTEXT_TREE_MAX_DEPTH) { truncated = true; return null; }
187
- if (visited.has(node.entry.id)) { truncated = true; return null; } // cycle guard
188
- visited.add(node.entry.id);
189
- nodesVisited++;
198
+ const order: WalkFrame[] = [];
199
+ const stack: WalkFrame[] = [...roots].reverse().map((root) => ({ node: root, parentIndex: null }));
200
+ while (stack.length > 0) {
201
+ const frame = stack.pop()!;
202
+ if (order.length >= CONTEXT_TREE_MAX_NODES) { truncated = true; break; }
203
+ if (visited.has(frame.node.entry.id)) { truncated = true; continue; } // cycle guard
204
+ visited.add(frame.node.entry.id);
205
+ const index = order.length;
206
+ order.push(frame);
207
+ const children = [...frame.node.children].reverse().map((child) => ({ node: child, parentIndex: index }));
208
+ stack.push(...children);
209
+ }
210
+ if (stack.length > 0) truncated = true; // node bound hit with more work still queued
190
211
 
191
- const entry = node.entry;
212
+ const childItemsByParent = new Map<number, ContextSegmentItem[]>();
213
+ const itemByIndex = new Map<number, ContextSegmentItem>();
214
+ for (let index = order.length - 1; index >= 0; index--) {
215
+ const frame = order[index]!;
216
+ const entry = frame.node.entry;
192
217
  const characters = entry.type === "message"
193
218
  ? messageContentCharacters(entry.message)
194
219
  : entry.type === "compaction" || entry.type === "branch_summary"
@@ -198,19 +223,29 @@ export function buildMessageHistoryTree(roots: ReadonlyArray<SessionTreeNodeLike
198
223
  const isActive = activeEntryIds.has(entry.id);
199
224
  if (isActive) activeTokens += tokens;
200
225
 
201
- const children = node.children
202
- .map((child) => visit(child, depth + 1))
203
- .filter((item): item is ContextSegmentItem => item !== null);
204
- if (tokens === 0 && children.length === 0) return null; // no content, no descendants with content -- nothing to show
226
+ const children = childItemsByParent.get(index) ?? [];
227
+ if (tokens === 0 && children.length === 0) continue; // no content, no descendants with content -- nothing to show
205
228
 
206
- return {
229
+ const item: ContextSegmentItem = {
207
230
  label: isActive ? entryLabel(entry) : `${entryLabel(entry)} (inactive branch)`,
208
231
  estimatedTokens: tokens,
209
232
  ...(children.length > 0 ? { children } : {}),
210
233
  };
234
+ itemByIndex.set(index, item);
235
+ if (frame.parentIndex !== null) {
236
+ const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
237
+ siblings.unshift(item); // reverse-order processing -- unshift restores original document order
238
+ childItemsByParent.set(frame.parentIndex, siblings);
239
+ }
211
240
  }
212
241
 
213
- const items = roots.map((root) => visit(root, 0)).filter((item): item is ContextSegmentItem => item !== null);
242
+ const items: ContextSegmentItem[] = [];
243
+ for (let index = 0; index < order.length; index++) {
244
+ if (order[index]!.parentIndex === null) {
245
+ const item = itemByIndex.get(index);
246
+ if (item) items.push(item);
247
+ }
248
+ }
214
249
  return { items, activeTokens, truncated };
215
250
  }
216
251
 
@@ -267,30 +302,64 @@ function sumItemTree(items: ContextSegmentItem[]): number {
267
302
  * (extension/src/task-widget.ts) for the identical multi-parent-DAG-in-a-bounded-view
268
303
  * problem, not a new inconsistency.
269
304
  */
305
+ interface TaskWalkFrame {
306
+ taskId: string;
307
+ parentIndex: number | null;
308
+ }
309
+
310
+ /** Same iterative two-pass shape as buildMessageHistoryTree, for the same reason: don't assume containment depth stays small just because it usually does. */
270
311
  export function buildTaskItemTree(graph: TaskGraph): ContextSegmentItem[] {
271
312
  const byId = new Map(graph.nodes.map((node) => [node.task.id, node]));
272
313
  const openIds = new Set(graph.nodes.filter((node) => node.task.status !== "done" && node.task.status !== "canceled").map((node) => node.task.id));
273
314
  const visited = new Set<string>();
274
315
 
275
- function visit(id: string, depth: number): ContextSegmentItem | null {
276
- if (depth > CONTEXT_TREE_MAX_DEPTH || visited.size >= CONTEXT_TREE_MAX_NODES || visited.has(id) || !openIds.has(id)) return null;
277
- visited.add(id);
278
- const node = byId.get(id);
279
- if (!node) return null;
280
- const characters = node.task.title.length + node.task.body.length;
281
- const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
282
- const children = node.childIds
283
- .filter((childId) => openIds.has(childId))
284
- .map((childId) => visit(childId, depth + 1))
285
- .filter((item): item is ContextSegmentItem => item !== null);
286
- return { label: node.task.title, estimatedTokens: tokens, ...(children.length > 0 ? { children } : {}) };
287
- }
288
-
289
316
  const rootIds = [...openIds].filter((id) => {
290
317
  const node = byId.get(id)!;
291
318
  return node.parentIds.length === 0 || !node.parentIds.some((parentId) => openIds.has(parentId));
292
319
  });
293
- return rootIds.map((id) => visit(id, 0)).filter((item): item is ContextSegmentItem => item !== null);
320
+
321
+ const order: TaskWalkFrame[] = [];
322
+ const stack: TaskWalkFrame[] = [...rootIds].reverse().map((taskId) => ({ taskId, parentIndex: null }));
323
+ while (stack.length > 0) {
324
+ const frame = stack.pop()!;
325
+ if (order.length >= CONTEXT_TREE_MAX_NODES) break;
326
+ if (visited.has(frame.taskId) || !openIds.has(frame.taskId)) continue; // cycle guard + open-only filter
327
+ visited.add(frame.taskId);
328
+ const index = order.length;
329
+ order.push(frame);
330
+ const node = byId.get(frame.taskId);
331
+ const children = [...(node?.childIds ?? [])].reverse()
332
+ .filter((childId) => openIds.has(childId))
333
+ .map((childId) => ({ taskId: childId, parentIndex: index }));
334
+ stack.push(...children);
335
+ }
336
+
337
+ const childItemsByParent = new Map<number, ContextSegmentItem[]>();
338
+ const itemByIndex = new Map<number, ContextSegmentItem>();
339
+ for (let index = order.length - 1; index >= 0; index--) {
340
+ const frame = order[index]!;
341
+ const node = byId.get(frame.taskId);
342
+ if (!node) continue;
343
+ const characters = node.task.title.length + node.task.body.length;
344
+ const tokens = Math.ceil(characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN);
345
+ const children = childItemsByParent.get(index) ?? [];
346
+ const item: ContextSegmentItem = { label: node.task.title, estimatedTokens: tokens, ...(children.length > 0 ? { children } : {}) };
347
+ itemByIndex.set(index, item);
348
+ if (frame.parentIndex !== null) {
349
+ const siblings = childItemsByParent.get(frame.parentIndex) ?? [];
350
+ siblings.unshift(item);
351
+ childItemsByParent.set(frame.parentIndex, siblings);
352
+ }
353
+ }
354
+
355
+ const items: ContextSegmentItem[] = [];
356
+ for (let index = 0; index < order.length; index++) {
357
+ if (order[index]!.parentIndex === null) {
358
+ const item = itemByIndex.get(index);
359
+ if (item) items.push(item);
360
+ }
361
+ }
362
+ return items;
294
363
  }
295
364
 
296
365
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.13.3",
3
+ "version": "0.13.4",
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"],
@@ -24,6 +24,7 @@
24
24
  "typebox": "*"
25
25
  },
26
26
  "devDependencies": {
27
+ "@earendil-works/pi-coding-agent": "^0.80.10",
27
28
  "bun-types": "latest",
28
29
  "typescript": "^5.7.3"
29
30
  },
package/src/constants.ts CHANGED
@@ -33,14 +33,19 @@ export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
33
33
  /**
34
34
  * Bounds for walking Pi's real session tree (getTree()) and Papyrus's own Task containment
35
35
  * tree when estimating /context's message-history and task segments. Both are genuine trees
36
- * built from external, mutable state (a session file; the live Task graph) -- depth and
37
- * total-node bounds are a defensive measure against a corrupted/adversarial parentId chain
38
- * forming an accidental cycle, matching the same cycle-safety discipline already applied to
39
- * ConversationJournal traversal and deliberately hardening past a real, confirmed gap in
40
- * Pi's own getBranch() (no cycle guard at all).
36
+ * built from external, mutable state (a session file; the live Task graph) -- the node bound
37
+ * is a defensive measure against a corrupted/adversarial parentId chain forming an accidental
38
+ * cycle, matching the same cycle-safety discipline already applied to ConversationJournal
39
+ * traversal and deliberately hardening past a real, confirmed gap in Pi's own getBranch() (no
40
+ * cycle guard at all). Set generously: a real, ordinary (non-branching) long-running session
41
+ * is one long linear chain, so a naively small bound truncates the walk after counting only a
42
+ * small fraction of the real conversation -- a session observed in production with 6,924
43
+ * entries on its own active branch confirmed an earlier, much smaller bound did exactly that,
44
+ * making the derived "unaccounted" remainder balloon to absorb almost the entire real total.
45
+ * The walk itself is iterative (an explicit stack), not recursive, specifically so a chain
46
+ * this long cannot also risk a real JavaScript call-stack overflow independent of this bound.
41
47
  */
42
- export const CONTEXT_TREE_MAX_DEPTH = 200;
43
- export const CONTEXT_TREE_MAX_NODES = 2000;
48
+ export const CONTEXT_TREE_MAX_NODES = 50_000;
44
49
 
45
50
  /**
46
51
  * A Papyrus Rule's condition+action+body is injected into EVERY relevant turn's system