@danypops/papyrus 0.11.4 → 0.12.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 (46) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/domain-tools.ts +108 -52
  4. package/extension/src/index.ts +90 -37
  5. package/extension/src/notes.ts +14 -1
  6. package/extension/src/task-focus-events.ts +57 -0
  7. package/extension/src/tasks.ts +51 -15
  8. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  9. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  10. package/extension/src/tool-rendering/index.ts +107 -0
  11. package/extension/src/tool-rendering/render-model.ts +406 -0
  12. package/package.json +4 -2
  13. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  14. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  15. package/src/adapters/sqlite-artifact-store.ts +20 -11
  16. package/src/adapters/sqlite-discourse-store.ts +325 -0
  17. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  18. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  19. package/src/authority-registry.ts +115 -0
  20. package/src/cli.ts +904 -124
  21. package/src/constants.ts +38 -5
  22. package/src/conversation-journal-service.ts +87 -0
  23. package/src/db.ts +285 -33
  24. package/src/domain/artifact-event.ts +99 -0
  25. package/src/domain/conversation-journal.ts +168 -0
  26. package/src/domain/discourse-store.ts +142 -0
  27. package/src/domain/graph-projection.ts +74 -0
  28. package/src/domain/task-event.ts +4 -0
  29. package/src/domain-services.ts +133 -38
  30. package/src/graph-projection-service.ts +103 -0
  31. package/src/id-migration.ts +200 -0
  32. package/src/module-registry.ts +53 -0
  33. package/src/modules/docs.ts +77 -0
  34. package/src/modules/graph-projection.ts +82 -0
  35. package/src/modules/notes.ts +76 -0
  36. package/src/modules/rules.ts +81 -0
  37. package/src/modules/skills.ts +113 -0
  38. package/src/modules/tasks.ts +164 -0
  39. package/src/ops.ts +142 -15
  40. package/src/ports/artifact-scope-store.ts +20 -0
  41. package/src/ports/artifact-store.ts +10 -5
  42. package/src/ports/conversation-journal-store.ts +17 -0
  43. package/src/ports/graph-projection-store.ts +15 -0
  44. package/src/ports/task-focus-store.ts +62 -20
  45. package/src/service.ts +218 -223
  46. package/src/task-service.ts +70 -38
@@ -0,0 +1,168 @@
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
+ }
@@ -0,0 +1,142 @@
1
+ import {
2
+ DISCOURSE_CONTENT_MAX_BYTES,
3
+ DISCOURSE_EVENT_RETENTION_DEFAULT,
4
+ DISCOURSE_EVENT_RETENTION_MAX,
5
+ DISCOURSE_QUERY_MAX_LIMIT,
6
+ } from "../constants.ts";
7
+
8
+ /** Papyrus-owned Doc subtypes reserved for the Discourse persistence adapter. */
9
+ export const DISCOURSE_THREAD_SUBTYPE = "context-thread";
10
+ export const DISCOURSE_MESSAGE_SUBTYPE = "context-message";
11
+ export const DISCOURSE_RELATIONS = new Set(["reply_to", "discusses"]);
12
+
13
+ export type JsonPrimitive = string | number | boolean | null;
14
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
15
+ export interface ArtifactReference { kind: string; id: string }
16
+ export interface ThreadAddress { forumId: string; topicId: string; threadId: string }
17
+ export interface AppendPostCommand extends ThreadAddress {
18
+ schemaVersion: "discourse.command.v1";
19
+ operationId: string;
20
+ authorId: string;
21
+ content: JsonValue;
22
+ correlationId?: string;
23
+ causationId?: string;
24
+ replyToPostId?: string;
25
+ references?: ArtifactReference[];
26
+ }
27
+ export interface Post extends ThreadAddress {
28
+ id: string;
29
+ authorId: string;
30
+ content: JsonValue;
31
+ timestamp: number;
32
+ sequence: number;
33
+ operationId: string;
34
+ correlationId?: string;
35
+ causationId?: string;
36
+ replyToPostId?: string;
37
+ references: ArtifactReference[];
38
+ }
39
+ export type DiscourseEventType = "post-added" | "thread-changed" | "question-opened" | "question-answered" | "subscription-resync-required";
40
+ export interface DiscourseEvent extends ThreadAddress {
41
+ schemaVersion: "discourse.event.v1";
42
+ type: DiscourseEventType;
43
+ sequence: number;
44
+ timestamp: number;
45
+ postId?: string;
46
+ operationId?: string;
47
+ correlationId?: string;
48
+ causationId?: string;
49
+ responseId?: string;
50
+ retainedFromSequence?: number;
51
+ }
52
+ export interface Page<T> {
53
+ items: T[];
54
+ truncated: boolean;
55
+ nextSequence?: number;
56
+ completeness: "complete" | "truncated";
57
+ }
58
+ export interface TopicSummary { forumId: string; topicId: string; threadCount: number; postCount: number; lastActivity: number }
59
+ export interface ThreadSummary extends ThreadAddress { postCount: number; participantIds: string[]; lastActivity: number }
60
+ export interface OpenQuestion { responseId: string; post: Post }
61
+ export interface ProjectionRecord { sequence: number; post: Post }
62
+
63
+ export function isDiscourseSubtype(subtype: string | undefined): boolean {
64
+ return subtype === DISCOURSE_THREAD_SUBTYPE || subtype === DISCOURSE_MESSAGE_SUBTYPE;
65
+ }
66
+
67
+ function record(value: unknown, name: string): Record<string, unknown> {
68
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${name} must be an object`);
69
+ return value as Record<string, unknown>;
70
+ }
71
+
72
+ export function requiredString(value: unknown, name: string): string {
73
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} is required`);
74
+ return value;
75
+ }
76
+
77
+ export function optionalString(value: unknown, name: string): string | undefined {
78
+ if (value === undefined) return undefined;
79
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
80
+ return value;
81
+ }
82
+
83
+ export function nonNegativeInteger(value: unknown, name: string): number {
84
+ if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative safe integer`);
85
+ return value as number;
86
+ }
87
+
88
+ export function queryLimit(value: unknown): number {
89
+ const limit = nonNegativeInteger(value, "limit");
90
+ if (limit < 1 || limit > DISCOURSE_QUERY_MAX_LIMIT) throw new Error(`limit must be between 1 and ${DISCOURSE_QUERY_MAX_LIMIT}`);
91
+ return limit;
92
+ }
93
+
94
+ export function eventRetention(value: unknown): number {
95
+ if (value === undefined) return DISCOURSE_EVENT_RETENTION_DEFAULT;
96
+ const retention = nonNegativeInteger(value, "event_retention");
97
+ if (retention < 1 || retention > DISCOURSE_EVENT_RETENTION_MAX) {
98
+ throw new Error(`event_retention must be between 1 and ${DISCOURSE_EVENT_RETENTION_MAX}`);
99
+ }
100
+ return retention;
101
+ }
102
+
103
+ function jsonValue(value: unknown, name: string): JsonValue {
104
+ const encoded = JSON.stringify(value);
105
+ if (encoded === undefined) throw new Error(`${name} must be JSON-serializable`);
106
+ if (new TextEncoder().encode(encoded).byteLength > DISCOURSE_CONTENT_MAX_BYTES) {
107
+ throw new Error(`${name} cannot exceed ${DISCOURSE_CONTENT_MAX_BYTES} bytes`);
108
+ }
109
+ return JSON.parse(encoded) as JsonValue;
110
+ }
111
+
112
+ export function appendCommand(value: unknown): AppendPostCommand {
113
+ const input = record(value, "command");
114
+ if (input["schemaVersion"] !== "discourse.command.v1") throw new Error("unsupported Discourse command schema");
115
+ const referencesValue = input["references"] ?? [];
116
+ if (!Array.isArray(referencesValue)) throw new Error("references must be an array");
117
+ const references = referencesValue.map((entry, index) => {
118
+ const reference = record(entry, `references[${index}]`);
119
+ return { kind: requiredString(reference["kind"], `references[${index}].kind`), id: requiredString(reference["id"], `references[${index}].id`) };
120
+ });
121
+ return {
122
+ schemaVersion: "discourse.command.v1",
123
+ operationId: requiredString(input["operationId"], "operationId"),
124
+ forumId: requiredString(input["forumId"], "forumId"),
125
+ topicId: requiredString(input["topicId"], "topicId"),
126
+ threadId: requiredString(input["threadId"], "threadId"),
127
+ authorId: requiredString(input["authorId"], "authorId"),
128
+ content: jsonValue(input["content"], "content"),
129
+ ...(optionalString(input["correlationId"], "correlationId") ? { correlationId: input["correlationId"] as string } : {}),
130
+ ...(optionalString(input["causationId"], "causationId") ? { causationId: input["causationId"] as string } : {}),
131
+ ...(optionalString(input["replyToPostId"], "replyToPostId") ? { replyToPostId: input["replyToPostId"] as string } : {}),
132
+ references,
133
+ };
134
+ }
135
+
136
+ export function threadAddress(value: Record<string, unknown>): ThreadAddress {
137
+ return {
138
+ forumId: requiredString(value["forumId"], "forumId"),
139
+ topicId: requiredString(value["topicId"], "topicId"),
140
+ threadId: requiredString(value["threadId"], "threadId"),
141
+ };
142
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * domain/graph-projection.ts — the generic graph projection protocol for external bounded
3
+ * contexts (step 6 of the incremental refactor in
4
+ * reducing-papyrus-consumer-change-amplification-with-modules--pvdo), sketched in
5
+ * papyrus-full-context-mesh-and-domain-storage-ownership-bound-qhzp.
6
+ *
7
+ * An external bounded context owns its own operational state and command authority. It
8
+ * never becomes a Papyrus-native module just to appear in the graph. Instead it publishes
9
+ * bounded, sequenced batches of the context-bearing identities and edges it wants durably
10
+ * materialized in the Context Mesh. Papyrus is an idempotent materialized read model for
11
+ * that producer's data, not its command database.
12
+ *
13
+ * Deliberately out of scope for this walking skeleton, each a separate follow-up:
14
+ * - Producer identity is a request field (`producerId`), not yet derived from scoped
15
+ * authentication -- the decision doc's constraint that "producer identity comes from
16
+ * scoped authentication, not request payloads" is not yet met, since Papyrus's daemon
17
+ * auth model today is one shared bearer token, not per-caller identity. Tracked as a
18
+ * known gap, not silently assumed solved.
19
+ * - Edges reference other artifacts only via an externalId already projected by the SAME
20
+ * producer in this batch or a prior one. Linking a projected artifact to an existing
21
+ * Papyrus-native artifact (e.g. "this Discourse thread discusses this Task") is not yet
22
+ * supported here -- a real, needed capability, but a second batch shape / edge kind, not
23
+ * assumed away.
24
+ * - "Lag" is reported as Papyrus's own last-applied checkpoint; Papyrus has no way to know
25
+ * a producer's true current sequence, so relative lag must be computed by the caller
26
+ * (who does know its own latest sequence) by diffing against this checkpoint.
27
+ */
28
+
29
+ export const GRAPH_PROJECTION_SCHEMA_VERSION = "papyrus.graph-projection/v1";
30
+
31
+ export interface ProjectedArtifact {
32
+ /** The producer's own stable identity for this entity -- never a Papyrus artifact id. */
33
+ readonly externalId: string;
34
+ readonly kind: string;
35
+ readonly subtype?: string;
36
+ readonly title: string;
37
+ readonly body?: string;
38
+ readonly labels?: readonly string[];
39
+ readonly extra?: Record<string, unknown>;
40
+ }
41
+
42
+ export interface ProjectedEdge {
43
+ readonly from: string; // externalId, resolved against this producer's identity map
44
+ readonly relation: string;
45
+ readonly to: string; // externalId, resolved against this producer's identity map
46
+ }
47
+
48
+ export interface GraphProjectionBatch {
49
+ readonly schemaVersion: typeof GRAPH_PROJECTION_SCHEMA_VERSION;
50
+ readonly producerId: string;
51
+ readonly batchId: string;
52
+ /** Monotonic per-producer sequence, starting at 1. Enforced gapless -- see GraphProjection.apply. */
53
+ readonly sequence: number;
54
+ readonly artifacts: readonly ProjectedArtifact[];
55
+ readonly edges: readonly ProjectedEdge[];
56
+ }
57
+
58
+ export interface ProjectionCheckpoint {
59
+ readonly producerId: string;
60
+ readonly lastSequence: number;
61
+ readonly lastBatchId: string;
62
+ readonly appliedAt: string;
63
+ }
64
+
65
+ export interface GraphProjectionResult {
66
+ readonly producerId: string;
67
+ readonly batchId: string;
68
+ readonly sequence: number;
69
+ readonly artifactsUpserted: number;
70
+ readonly artifactsCreated: number;
71
+ readonly edgesUpserted: number;
72
+ /** True only when this exact batch was already applied and this call was a safe no-op replay. */
73
+ readonly alreadyApplied: boolean;
74
+ }
@@ -24,6 +24,10 @@ export const TASK_EVENT_TYPES = [
24
24
  "retried",
25
25
  "completed",
26
26
  "canceled",
27
+ "dependency_added",
28
+ "dependency_removed",
29
+ "containment_added",
30
+ "containment_removed",
27
31
  ] as const;
28
32
 
29
33
  export type TaskEventType = typeof TASK_EVENT_TYPES[number];