@danypops/papyrus 0.60.1 → 0.60.3

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.
@@ -0,0 +1,231 @@
1
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
2
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
3
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
4
+ import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
5
+ import { binderTree } from "../binder/binder-service.ts";
6
+ import { bindersOperations } from "../modules/binders.ts";
7
+ import type { ProjectRegistryStore } from "../project-registry/project-registry-store.ts";
8
+ import { normalizeProjectRoot } from "../project-registry/scope-source.ts";
9
+ import type { ScopeGroupStore } from "../scope-group/scope-group-store.ts";
10
+ import { booleanProp, createOperationDefiner, numberProp, resolveArtifactIdWidened, stringProp, validationError } from "./shared.ts";
11
+
12
+ const OWNER = "binders";
13
+
14
+ function resolveBinderId(
15
+ artifacts: ArtifactStore,
16
+ scopes: ArtifactScopeStore,
17
+ projectRoot: string | undefined,
18
+ id: unknown,
19
+ name: unknown,
20
+ ): string {
21
+ if (typeof id === "string" && id.length > 0) return id;
22
+ if (typeof name !== "string" || name.trim().length === 0) throw validationError("id or name is required");
23
+ const alias = artifacts.getByAlias(name.trim());
24
+ if (alias?.kind === "binder" && (projectRoot === undefined || scopes.appliesToProjectRoot(alias.id, normalizeProjectRoot(projectRoot)))) {
25
+ return alias.id;
26
+ }
27
+ const tree = binderTree(artifacts, scopes, { projectRoot });
28
+ const pathNeedle = name.trim().startsWith("/") ? name.trim() : `/${name.trim()}`;
29
+ const pathMatches = tree.nodes.filter((node) => node.path.toLowerCase() === pathNeedle.toLowerCase());
30
+ if (pathMatches.length === 1) return pathMatches[0]!.binder.id;
31
+ const titleMatches = tree.nodes.filter((node) => node.binder.title.trim().toLowerCase() === name.trim().toLowerCase());
32
+ if (titleMatches.length === 1) return titleMatches[0]!.binder.id;
33
+ if (titleMatches.length > 1 || pathMatches.length > 1) {
34
+ const matches = pathMatches.length > 0 ? pathMatches : titleMatches;
35
+ throw validationError(`binder name "${name}" is ambiguous: ${matches.map((node) => node.path).join(", ")} -- use a path or id`);
36
+ }
37
+ throw validationError(`no binder named "${name}" found in this project context`);
38
+ }
39
+
40
+ function resolveAnyArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
41
+ if (typeof id === "string" && id.length > 0) return id;
42
+ if (typeof name !== "string" || name.trim().length === 0) throw validationError("artifact_id or artifact_name is required");
43
+ return resolveArtifactIdWidened(artifacts, name, () => artifacts.query({ text: name }));
44
+ }
45
+
46
+ export function registerBindersVehicleOperations(
47
+ registry: VehicleRegistry,
48
+ artifacts: ArtifactStore & ArtifactTrashStore,
49
+ scopes: ArtifactScopeStore,
50
+ projectRegistry: ProjectRegistryStore,
51
+ scopeGroups: ScopeGroupStore,
52
+ ): void {
53
+ const operations = new Map(
54
+ bindersOperations(artifacts, scopes, projectRegistry, scopeGroups).map((operation) => [operation.name, operation]),
55
+ );
56
+ const call = (name: string, input: Record<string, unknown>): unknown => operations.get(name)!.execute(input);
57
+ const define = createOperationDefiner(registry, OWNER, "binders", ["binders:read", "binders:write"], call);
58
+ const arrayProp = { type: "array" } as unknown as { type: string };
59
+ const withBinderId = (input: Record<string, unknown>, idKey = "id", nameKey = "name") => ({
60
+ ...input,
61
+ [idKey]: resolveBinderId(artifacts, scopes, input.project_root as string | undefined, input[idKey], input[nameKey]),
62
+ });
63
+
64
+ define(
65
+ "create",
66
+ "Creates a filesystem-style Binder. Binders organize artifacts without changing Task containment or Playbook execution. Direct labels on a Binder are inherited additively by descendants at read time. Prefer parent_name (a title, alias, or /path) over parent_id.",
67
+ "local-write",
68
+ { title: stringProp, labels: arrayProp, parent_id: stringProp, parent_name: stringProp, project_root: stringProp, projects: arrayProp },
69
+ ["title"],
70
+ (input) => ({
71
+ ...input,
72
+ ...(input.parent_id || input.parent_name
73
+ ? { parent_id: resolveBinderId(artifacts, scopes, input.project_root as string | undefined, input.parent_id, input.parent_name) }
74
+ : {}),
75
+ }),
76
+ );
77
+ define(
78
+ "list",
79
+ "Lists Binders. project_root alone is exact-membership audit scope; project_root plus applicable:true includes global Binders and Binders applicable to the project. Returns lean summaries unless full:true.",
80
+ "read",
81
+ { text: stringProp, limit: numberProp, project_root: stringProp, applicable: booleanProp, full: booleanProp },
82
+ [],
83
+ (input) => input,
84
+ );
85
+ define(
86
+ "tree",
87
+ "Returns the project-context Binder tree plus placements and direct/inherited/effective labels for the bounded artifact_ids supplied. Label inheritance is computed, not copied into artifact labels.",
88
+ "read",
89
+ { project_root: stringProp, artifact_ids: arrayProp },
90
+ [],
91
+ (input) => input,
92
+ );
93
+ define(
94
+ "show",
95
+ "Shows one Binder node by id, alias, title, or /path, including its path and inherited/effective labels.",
96
+ "read",
97
+ { id: stringProp, name: stringProp, project_root: stringProp },
98
+ [],
99
+ (input) => withBinderId(input),
100
+ );
101
+ define(
102
+ "update",
103
+ "Renames a Binder and/or replaces its direct labels. Inherited labels on descendants update immediately because they are computed dynamically.",
104
+ "local-write",
105
+ { id: stringProp, name: stringProp, title: stringProp, labels: arrayProp, project_root: stringProp },
106
+ [],
107
+ (input) => withBinderId(input),
108
+ );
109
+ define(
110
+ "move",
111
+ "Moves a Binder under parent_id/parent_name, or to the project-context root when neither is supplied. Rejects cycles and duplicate sibling names.",
112
+ "local-write",
113
+ { id: stringProp, name: stringProp, parent_id: stringProp, parent_name: stringProp, project_root: stringProp },
114
+ [],
115
+ (input) => {
116
+ const resolved = withBinderId(input);
117
+ return {
118
+ ...resolved,
119
+ ...(input.parent_id || input.parent_name
120
+ ? { parent_id: resolveBinderId(artifacts, scopes, input.project_root as string | undefined, input.parent_id, input.parent_name) }
121
+ : {}),
122
+ };
123
+ },
124
+ );
125
+ define(
126
+ "file",
127
+ "Files one non-Binder artifact in a Binder for this project context, replacing its previous visible placement. Prefer binder_name/artifact_name over ids when unambiguous.",
128
+ "local-write",
129
+ {
130
+ binder_id: stringProp,
131
+ binder_name: stringProp,
132
+ artifact_id: stringProp,
133
+ artifact_name: stringProp,
134
+ project_root: stringProp,
135
+ },
136
+ [],
137
+ (input) => ({
138
+ ...input,
139
+ binder_id: resolveBinderId(artifacts, scopes, input.project_root as string | undefined, input.binder_id, input.binder_name),
140
+ artifact_id: resolveAnyArtifactId(artifacts, input.artifact_id, input.artifact_name),
141
+ }),
142
+ );
143
+ define(
144
+ "unfile",
145
+ "Moves one artifact to this project context's Binder root. This changes organization only, never Task containment or dependencies.",
146
+ "local-write",
147
+ { artifact_id: stringProp, artifact_name: stringProp, project_root: stringProp },
148
+ [],
149
+ (input) => ({ ...input, artifact_id: resolveAnyArtifactId(artifacts, input.artifact_id, input.artifact_name) }),
150
+ );
151
+ define(
152
+ "remove",
153
+ "Trashes an empty Binder. A non-empty Binder is rejected until its contents are moved or unfiled.",
154
+ "local-write",
155
+ { id: stringProp, name: stringProp, project_root: stringProp, reason: stringProp },
156
+ [],
157
+ (input) => withBinderId(input),
158
+ );
159
+ define(
160
+ "scope",
161
+ "Shows a Binder's project/scope-group scope.",
162
+ "read",
163
+ { id: stringProp, name: stringProp, project_root: stringProp },
164
+ [],
165
+ (input) => withBinderId(input),
166
+ );
167
+ define(
168
+ "set_global",
169
+ "Makes a Binder apply in every project.",
170
+ "local-write",
171
+ { id: stringProp, name: stringProp, project_root: stringProp },
172
+ [],
173
+ (input) => withBinderId(input),
174
+ );
175
+ define(
176
+ "set_none",
177
+ "Hides a Binder from every project context.",
178
+ "local-write",
179
+ { id: stringProp, name: stringProp, project_root: stringProp },
180
+ [],
181
+ (input) => withBinderId(input),
182
+ );
183
+ define(
184
+ "add_project",
185
+ "Adds one registered project to a Binder's explicit scope.",
186
+ "local-write",
187
+ { id: stringProp, name: stringProp, project: stringProp, project_root: stringProp },
188
+ ["project"],
189
+ (input) => withBinderId(input),
190
+ );
191
+ define(
192
+ "remove_project",
193
+ "Removes one project from a Binder's explicit scope; removing the final member is rejected.",
194
+ "local-write",
195
+ { id: stringProp, name: stringProp, project: stringProp, project_root: stringProp },
196
+ ["project"],
197
+ (input) => withBinderId(input),
198
+ );
199
+ define(
200
+ "replace_projects",
201
+ "Replaces a Binder's project membership with a bounded non-empty list.",
202
+ "local-write",
203
+ { id: stringProp, name: stringProp, projects: arrayProp, project_root: stringProp },
204
+ ["projects"],
205
+ (input) => withBinderId(input),
206
+ );
207
+ define(
208
+ "add_group",
209
+ "Adds one nested scope group to a Binder's explicit scope.",
210
+ "local-write",
211
+ { id: stringProp, name: stringProp, group: stringProp, project_root: stringProp },
212
+ ["group"],
213
+ (input) => withBinderId(input),
214
+ );
215
+ define(
216
+ "remove_group",
217
+ "Removes one scope group from a Binder's explicit scope; removing the final member is rejected.",
218
+ "local-write",
219
+ { id: stringProp, name: stringProp, group: stringProp, project_root: stringProp },
220
+ ["group"],
221
+ (input) => withBinderId(input),
222
+ );
223
+ define(
224
+ "replace_groups",
225
+ "Replaces a Binder's scope-group membership with a bounded non-empty list.",
226
+ "local-write",
227
+ { id: stringProp, name: stringProp, groups: arrayProp, project_root: stringProp },
228
+ ["groups"],
229
+ (input) => withBinderId(input),
230
+ );
231
+ }
@@ -60,6 +60,21 @@ export function registerNotesVehicleOperations(registry: VehicleRegistry, notes:
60
60
  (input) => input,
61
61
  );
62
62
 
63
+ define(
64
+ "list_page",
65
+ "Cursor-paginates a stable Note inventory. Omit project_root only for an intentional cross-project audit; use nextCursor until absent.",
66
+ "read",
67
+ {
68
+ project_root: stringProp,
69
+ status: { type: "string", enum: ["draft", "active", "archived"] },
70
+ text: stringProp,
71
+ limit: numberProp,
72
+ cursor: stringProp,
73
+ },
74
+ [],
75
+ (input) => input,
76
+ );
77
+
63
78
  define(
64
79
  "show",
65
80
  "Shows one note by id or title.",
@@ -21,6 +21,7 @@ import type { TaskScopeStore } from "../task/scope/task-scope-store.ts";
21
21
  import type { Tasks } from "../task/task-service.ts";
22
22
  import { registerArtifactTrashOperations } from "./artifact-trash.ts";
23
23
  import { registerBatchVehicleOperation } from "./batch.ts";
24
+ import { registerBindersVehicleOperations } from "./binders.ts";
24
25
  import { registerDiscussVehicleOperations } from "./discuss.ts";
25
26
  import { registerDocsVehicleOperations } from "./docs.ts";
26
27
  import { registerNotesVehicleOperations } from "./notes.ts";
@@ -56,6 +57,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
56
57
  // session_id, a correlation id, not a secret -- see session-identity-service.ts).
57
58
  registry.setExposeHandlerFailureDetails(true);
58
59
  registerNotesVehicleOperations(registry, deps.notes, deps.artifacts);
60
+ registerBindersVehicleOperations(registry, deps.artifacts, deps.scopes, deps.projectRegistry, deps.scopeGroups);
59
61
  registerRulesVehicleOperations(registry, deps.artifacts, deps.scopes, deps.projectRegistry, deps.scopeGroups);
60
62
  registerDocsVehicleOperations(registry, deps.artifacts, deps.scopes, deps.authority, deps.projectRegistry, deps.scopeGroups);
61
63
  registerPlaybooksVehicleOperations(registry, {
@@ -246,6 +246,7 @@ const readSchemaProps = {
246
246
 
247
247
  /** list-only: opts into full Artifact bodies instead of the lean summarizeArtifact() default (modules/tasks.ts). */
248
248
  const listSchemaProps = { ...readSchemaProps, full: booleanProp };
249
+ const listPageSchemaProps = { ...listSchemaProps, cursor: stringProp };
249
250
 
250
251
  /** Same gate/checklist narrative lines the removed tool built client-side. */
251
252
  function completionContentText(labels: Map<string, string>, result: TaskCompletion): string {
@@ -447,6 +448,18 @@ export function registerTasksVehicleOperations(registry: VehicleRegistry, deps:
447
448
  },
448
449
  );
449
450
 
451
+ define(
452
+ "list_page",
453
+ "Cursor-paginates a stable creation-ordered Task inventory. Use nextCursor until it is absent. Existing tasks.list remains the lean array convenience API.",
454
+ "read",
455
+ listPageSchemaProps,
456
+ ["project_root"],
457
+ (input) => {
458
+ const rootTaskId = resolveRootTaskId(artifacts, tasks, input.project_root as string, input.root_task_id, input.root_task_name);
459
+ return { ...input, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) };
460
+ },
461
+ );
462
+
450
463
  define(
451
464
  "graph",
452
465
  "Returns the full task graph (nodes with parent/child/dependency ids) for the requested scope. project_root is required.",
package/src/index.ts CHANGED
@@ -8,6 +8,14 @@
8
8
  export type { Artifact, ArtifactEdge } from "./artifact/artifact.ts";
9
9
  export { projectArtifactRelationships } from "./artifact/artifact-relationship-view.ts";
10
10
  export type { ArtifactStore } from "./artifact/artifact-store.ts";
11
+ export {
12
+ BINDER_FILED_IN_RELATION,
13
+ BINDER_KIND,
14
+ BINDER_ORGANIZES_RELATION,
15
+ type BinderArtifactPlacement,
16
+ type BinderNode,
17
+ type BinderTree,
18
+ } from "./binder/binder.ts";
11
19
  export {
12
20
  connectPapyrusClient,
13
21
  type PapyrusClient,
@@ -0,0 +1,171 @@
1
+ import { summarizeArtifact } from "../artifact/artifact.ts";
2
+ import type { ArtifactScopeStore } from "../artifact/artifact-scope-store.ts";
3
+ import type { ArtifactStore } from "../artifact/artifact-store.ts";
4
+ import type { ArtifactTrashStore } from "../artifact/artifact-trash-store.ts";
5
+ import {
6
+ addBinderGroup,
7
+ addBinderProject,
8
+ binderScope,
9
+ binderTree,
10
+ createBinder,
11
+ fileArtifact,
12
+ listBinders,
13
+ moveBinder,
14
+ removeBinder,
15
+ removeBinderGroup,
16
+ removeBinderProject,
17
+ replaceBinderGroups,
18
+ replaceBinderProjects,
19
+ setBinderGlobal,
20
+ setBinderNone,
21
+ unfileArtifact,
22
+ updateBinder,
23
+ } from "../binder/binder-service.ts";
24
+ import type { OperationDefinition } from "../module-registry.ts";
25
+ import type { ProjectRegistryStore } from "../project-registry/project-registry-store.ts";
26
+ import type { ScopeGroupStore } from "../scope-group/scope-group-store.ts";
27
+ import { type OperationInput, optionalBoolean, optionalNumber, optionalString, string } from "./operation-input.ts";
28
+
29
+ const MODULE_ID = "binders";
30
+
31
+ const eventContext = (input: OperationInput) => ({
32
+ actor: optionalString(input, "actor"),
33
+ source: optionalString(input, "source"),
34
+ sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
35
+ });
36
+
37
+ const listFilter = (input: OperationInput) => {
38
+ const projectRoot = optionalString(input, "project_root");
39
+ const applicable = optionalBoolean(input, "applicable") === true;
40
+ if (applicable && projectRoot === undefined) throw new Error("applicable requires project_root");
41
+ return {
42
+ text: optionalString(input, "text"),
43
+ limit: optionalNumber(input, "limit"),
44
+ ...(applicable ? { applicableToProjectRoot: projectRoot } : { projectRoot }),
45
+ };
46
+ };
47
+
48
+ export const BINDERS_OPERATION_NAMES = [
49
+ "binders.create",
50
+ "binders.list",
51
+ "binders.tree",
52
+ "binders.show",
53
+ "binders.update",
54
+ "binders.move",
55
+ "binders.file",
56
+ "binders.unfile",
57
+ "binders.remove",
58
+ "binders.scope",
59
+ "binders.set_global",
60
+ "binders.set_none",
61
+ "binders.add_project",
62
+ "binders.remove_project",
63
+ "binders.replace_projects",
64
+ "binders.add_group",
65
+ "binders.remove_group",
66
+ "binders.replace_groups",
67
+ ] as const;
68
+
69
+ export function bindersOperations(
70
+ artifacts: ArtifactStore & ArtifactTrashStore,
71
+ scopes: ArtifactScopeStore,
72
+ registry: ProjectRegistryStore,
73
+ scopeGroups: ScopeGroupStore,
74
+ ): OperationDefinition[] {
75
+ const define = <Input, Output>(name: string, execute: (input: Input) => Output): OperationDefinition<Input, Output> => ({
76
+ name,
77
+ moduleId: MODULE_ID,
78
+ execute,
79
+ });
80
+ return [
81
+ define("binders.create", (input: OperationInput) =>
82
+ createBinder(
83
+ artifacts,
84
+ scopes,
85
+ {
86
+ title: string(input, "title"),
87
+ labels: input.labels as string[] | undefined,
88
+ parentId: optionalString(input, "parent_id"),
89
+ projectRoot: optionalString(input, "project_root"),
90
+ projectReferences: input.projects as string[] | undefined,
91
+ },
92
+ eventContext(input),
93
+ registry,
94
+ ),
95
+ ),
96
+ define("binders.list", (input: OperationInput) => {
97
+ const binders = listBinders(artifacts, scopes, listFilter(input));
98
+ return optionalBoolean(input, "full") === true ? binders : binders.map(summarizeArtifact);
99
+ }),
100
+ define("binders.tree", (input: OperationInput) =>
101
+ binderTree(artifacts, scopes, {
102
+ projectRoot: optionalString(input, "project_root"),
103
+ artifactIds: input.artifact_ids as string[] | undefined,
104
+ }),
105
+ ),
106
+ define("binders.show", (input: OperationInput) => {
107
+ const id = string(input, "id");
108
+ const tree = binderTree(artifacts, scopes, { projectRoot: optionalString(input, "project_root") });
109
+ const node = tree.nodes.find((candidate) => candidate.binder.id === id);
110
+ if (!node) throw new Error(`binder artifact "${id}" not found in this project context`);
111
+ return node;
112
+ }),
113
+ define("binders.update", (input: OperationInput) =>
114
+ updateBinder(
115
+ artifacts,
116
+ scopes,
117
+ string(input, "id"),
118
+ { title: optionalString(input, "title"), labels: input.labels as string[] | undefined },
119
+ optionalString(input, "project_root"),
120
+ eventContext(input),
121
+ ),
122
+ ),
123
+ define("binders.move", (input: OperationInput) =>
124
+ moveBinder(
125
+ artifacts,
126
+ scopes,
127
+ string(input, "id"),
128
+ optionalString(input, "parent_id"),
129
+ optionalString(input, "project_root"),
130
+ eventContext(input),
131
+ ),
132
+ ),
133
+ define("binders.file", (input: OperationInput) =>
134
+ fileArtifact(
135
+ artifacts,
136
+ scopes,
137
+ string(input, "artifact_id"),
138
+ string(input, "binder_id"),
139
+ optionalString(input, "project_root"),
140
+ eventContext(input),
141
+ ),
142
+ ),
143
+ define("binders.unfile", (input: OperationInput) =>
144
+ unfileArtifact(artifacts, scopes, string(input, "artifact_id"), optionalString(input, "project_root"), eventContext(input)),
145
+ ),
146
+ define("binders.remove", (input: OperationInput) =>
147
+ removeBinder(artifacts, string(input, "id"), eventContext(input), optionalString(input, "reason")),
148
+ ),
149
+ define("binders.scope", (input: OperationInput) => binderScope(artifacts, scopes, string(input, "id"))),
150
+ define("binders.set_global", (input: OperationInput) => setBinderGlobal(artifacts, scopes, string(input, "id"))),
151
+ define("binders.set_none", (input: OperationInput) => setBinderNone(artifacts, scopes, string(input, "id"))),
152
+ define("binders.add_project", (input: OperationInput) =>
153
+ addBinderProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
154
+ ),
155
+ define("binders.remove_project", (input: OperationInput) =>
156
+ removeBinderProject(artifacts, scopes, registry, string(input, "id"), string(input, "project")),
157
+ ),
158
+ define("binders.replace_projects", (input: OperationInput) =>
159
+ replaceBinderProjects(artifacts, scopes, registry, string(input, "id"), (input.projects as string[] | undefined) ?? []),
160
+ ),
161
+ define("binders.add_group", (input: OperationInput) =>
162
+ addBinderGroup(artifacts, scopes, scopeGroups, string(input, "id"), string(input, "group")),
163
+ ),
164
+ define("binders.remove_group", (input: OperationInput) =>
165
+ removeBinderGroup(artifacts, scopes, scopeGroups, string(input, "id"), string(input, "group")),
166
+ ),
167
+ define("binders.replace_groups", (input: OperationInput) =>
168
+ replaceBinderGroups(artifacts, scopes, scopeGroups, string(input, "id"), (input.groups as string[] | undefined) ?? []),
169
+ ),
170
+ ];
171
+ }
@@ -24,6 +24,7 @@ const MODULE_ID = "notes";
24
24
  export const NOTES_OPERATION_NAMES = [
25
25
  "notes.capture",
26
26
  "notes.list",
27
+ "notes.list_page",
27
28
  "notes.show",
28
29
  "notes.history",
29
30
  "notes.consume",
@@ -57,6 +58,15 @@ export function notesOperations(notes: Notes): OperationDefinition[] {
57
58
  limit: optionalNumber(input, "limit"),
58
59
  }),
59
60
  ),
61
+ define("notes.list_page", (input: OperationInput) =>
62
+ notes.listPage({
63
+ projectRoot: optionalString(input, "project_root"),
64
+ status: optionalString(input, "status") as "draft" | "active" | "archived" | undefined,
65
+ text: optionalString(input, "text"),
66
+ limit: optionalNumber(input, "limit"),
67
+ cursor: optionalString(input, "cursor"),
68
+ }),
69
+ ),
60
70
  define("notes.show", (input: OperationInput) => notes.show(string(input, "id"), string(input, "project_root"))),
61
71
  define("notes.history", (input: OperationInput) =>
62
72
  notes.history(string(input, "id"), string(input, "project_root"), {
@@ -54,6 +54,7 @@ const taskFilter = (input: OperationInput) => ({
54
54
  rootTaskId: optionalString(input, "root_task_id"),
55
55
  sessionId: optionalString(input, "session_id") ?? optionalString(input, "sessionId"),
56
56
  labels: optionalStringArray(input, "labels"),
57
+ cursor: optionalString(input, "cursor"),
57
58
  });
58
59
 
59
60
  /**
@@ -69,6 +70,7 @@ export const TASKS_OPERATION_NAMES = [
69
70
  "tasks.create",
70
71
  "tasks.update",
71
72
  "tasks.list",
73
+ "tasks.list_page",
72
74
  "tasks.graph",
73
75
  "tasks.plan",
74
76
  "tasks.show",
@@ -164,6 +166,13 @@ export function tasksOperations(tasks: Tasks, artifacts: ArtifactStore, sessionI
164
166
  const rows = tasks.list(taskFilter(input));
165
167
  return optionalBoolean(input, "full") === true ? rows : rows.map(summarizeArtifact);
166
168
  }),
169
+ define("tasks.list_page", (input: OperationInput) => {
170
+ const page = tasks.listPage(taskFilter(input));
171
+ return {
172
+ ...page,
173
+ items: optionalBoolean(input, "full") === true ? page.items : page.items.map(summarizeArtifact),
174
+ };
175
+ }),
167
176
  define("tasks.graph", (input: OperationInput) => tasks.graph(taskFilter(input))),
168
177
  define("tasks.plan", (input: OperationInput) => projectTaskExecution(tasks.graph(taskFilter(input)))),
169
178
  define("tasks.show", (input: OperationInput) => tasks.show(string(input, "id"))),
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Artifact } from "../artifact/artifact.ts";
2
3
  import type { ArtifactStore } from "../artifact/artifact-store.ts";
3
4
  import { requireAtomicArtifactStore } from "../artifact/atomic-artifact-store.ts";
@@ -37,6 +38,27 @@ export interface ListNotesInput {
37
38
  limit?: number;
38
39
  }
39
40
 
41
+ export interface ListNotesPageInput {
42
+ /** Omit only for an explicit cross-project inventory. */
43
+ projectRoot?: string;
44
+ status?: "draft" | "active" | "archived";
45
+ text?: string;
46
+ limit?: number;
47
+ cursor?: string;
48
+ }
49
+
50
+ export interface NotesPage {
51
+ items: Artifact[];
52
+ nextCursor?: string;
53
+ }
54
+
55
+ interface NotesPageCursor {
56
+ v: 1;
57
+ createdAt: string;
58
+ id: string;
59
+ filterHash: string;
60
+ }
61
+
40
62
  export interface ArchiveNoteInput extends NoteProvenance {
41
63
  projectRoot: string;
42
64
  disposition: NoteDisposition;
@@ -54,6 +76,29 @@ function optionalBounded(value: string | undefined, field: string, maximum: numb
54
76
  return requiredBounded(value, field, maximum);
55
77
  }
56
78
 
79
+ function notesPageFilterHash(input: ListNotesPageInput, projectRoot: string | undefined): string {
80
+ return createHash("sha256")
81
+ .update(JSON.stringify({ projectRoot, status: input.status, text: input.text }))
82
+ .digest("base64url");
83
+ }
84
+
85
+ function decodeNotesPageCursor(cursor: string | undefined, filterHash: string): NotesPageCursor | undefined {
86
+ if (cursor === undefined) return undefined;
87
+ try {
88
+ const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as Partial<NotesPageCursor>;
89
+ if (parsed.v !== 1 || !parsed.createdAt || !parsed.id || parsed.filterHash !== filterHash) throw new Error("invalid cursor");
90
+ return parsed as NotesPageCursor;
91
+ } catch {
92
+ throw new Error("notes page cursor is invalid or does not match the requested filters");
93
+ }
94
+ }
95
+
96
+ function encodeNotesPageCursor(note: Artifact, filterHash: string): string {
97
+ return Buffer.from(JSON.stringify({ v: 1, createdAt: note.created_at, id: note.id, filterHash } satisfies NotesPageCursor)).toString(
98
+ "base64url",
99
+ );
100
+ }
101
+
57
102
  function noteTitle(body: string, requested?: string): string {
58
103
  if (requested !== undefined) return requiredBounded(requested, "note title", NOTE_TITLE_MAX_CHARACTERS);
59
104
  const firstLine = body.split(/\r?\n/, 1)[0]!.replace(/\s+/g, " ").trim();
@@ -110,6 +155,33 @@ export class Notes {
110
155
  });
111
156
  }
112
157
 
158
+ /** Cursor-paged inventory; omitting projectRoot intentionally enumerates notes across projects. */
159
+ listPage(input: ListNotesPageInput): NotesPage {
160
+ const projectRoot =
161
+ input.projectRoot === undefined ? undefined : requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
162
+ const limit = input.limit ?? NOTE_LIST_DEFAULT_LIMIT;
163
+ if (!Number.isInteger(limit) || limit < 1 || limit > NOTE_LIST_MAX_LIMIT) {
164
+ throw new Error(`note limit must be an integer from 1 to ${NOTE_LIST_MAX_LIMIT}`);
165
+ }
166
+ const filterHash = notesPageFilterHash(input, projectRoot);
167
+ const cursor = decodeNotesPageCursor(input.cursor, filterHash);
168
+ const candidates = this.artifacts.query({
169
+ kind: "doc",
170
+ subtype: NOTE_SUBTYPE,
171
+ ...(input.status ? { status: input.status } : { statuses: ["draft", "active"] }),
172
+ ...(input.text ? { text: input.text } : {}),
173
+ ...(projectRoot ? { extraEquals: { projectRoot } } : {}),
174
+ order: "created_desc",
175
+ ...(cursor ? { after: { createdAt: cursor.createdAt, id: cursor.id } } : {}),
176
+ limit: limit + 1,
177
+ });
178
+ const items = candidates.slice(0, limit);
179
+ return {
180
+ items,
181
+ ...(candidates.length > limit && items.length > 0 ? { nextCursor: encodeNotesPageCursor(items.at(-1)!, filterHash) } : {}),
182
+ };
183
+ }
184
+
113
185
  show(id: string, projectRoot: string): Artifact {
114
186
  const note = this.requireNote(id);
115
187
  this.requireProject(note, projectRoot);
package/src/ops.ts CHANGED
@@ -367,8 +367,14 @@ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
367
367
  conditions.push("json_extract(extra, ?) = ?");
368
368
  params.push(`$.${key}`, value);
369
369
  }
370
+ if (filter.after) {
371
+ if (filter.order !== "created_desc") throw new Error("artifact query cursor requires created_desc ordering");
372
+ if (!filter.after.createdAt || !filter.after.id) throw new Error("artifact query cursor is invalid");
373
+ conditions.push("(created_at < ? OR (created_at = ? AND id > ?))");
374
+ params.push(filter.after.createdAt, filter.after.createdAt, filter.after.id);
375
+ }
370
376
  if (conditions.length) sql += ` WHERE ${conditions.join(" AND ")}`;
371
- sql += " ORDER BY updated_at DESC";
377
+ sql += filter.order === "created_desc" ? " ORDER BY created_at DESC, id ASC" : " ORDER BY updated_at DESC, id ASC";
372
378
  if (filter.limit !== undefined) {
373
379
  if (!Number.isInteger(filter.limit) || filter.limit < 1) throw new Error("artifact query limit must be a positive integer");
374
380
  sql += " LIMIT ?";