@danypops/papyrus 0.11.4 → 0.13.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.
- package/README.md +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/artifact-browser.ts +13 -7
- package/extension/src/artifact-status-presentation.ts +53 -0
- package/extension/src/context-budget.ts +173 -0
- package/extension/src/context-view.ts +172 -0
- package/extension/src/docs.ts +6 -5
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +124 -38
- package/extension/src/notes.ts +16 -4
- package/extension/src/rules.ts +7 -7
- package/extension/src/skill-catalog-footprint.ts +183 -0
- package/extension/src/skills.ts +2 -3
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/task-widget.ts +13 -1
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +77 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +285 -33
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/skill-definition.ts +57 -8
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +201 -40
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/skill-execution.ts +169 -75
- package/src/task-service.ts +70 -38
package/src/domain-services.ts
CHANGED
|
@@ -1,12 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ARTIFACT_SCOPE_MAX_ARTIFACTS,
|
|
3
|
+
RULE_TEXT_HARD_LIMIT_CHARACTERS,
|
|
4
|
+
SKILL_INVOCATION_MAX_CALL_DEPTH,
|
|
5
|
+
SKILL_INVOCATION_MAX_LINKED_ARTIFACTS,
|
|
6
|
+
} from "./constants.ts";
|
|
1
7
|
import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
|
|
8
|
+
import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
9
|
+
import { normalizeProjectRoot } from "./domain/task-scope.ts";
|
|
2
10
|
import { validateSkillDefinition } from "./domain/skill-definition.ts";
|
|
3
11
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
12
|
+
import type { ArtifactScopeStore } from "./ports/artifact-scope-store.ts";
|
|
4
13
|
import { NOTE_SUBTYPE } from "./note-service.ts";
|
|
14
|
+
import type { AuthorityRegistry } from "./authority-registry.ts";
|
|
5
15
|
|
|
6
16
|
export interface ListFilter {
|
|
7
17
|
status?: string;
|
|
8
18
|
text?: string;
|
|
9
19
|
limit?: number;
|
|
20
|
+
/** When supplied, results are limited to artifacts scoped to this project (or the unscoped bucket, for an empty string is not accepted -- use assignArtifactProject's own validation). */
|
|
21
|
+
projectRoot?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shared by listDocuments/listRules/listSkills: when filter.projectRoot is given, resolve
|
|
26
|
+
* via ArtifactScopeStore first and post-filter by kind/status/text (mirrors Tasks.list's
|
|
27
|
+
* established scoped-listing shape); otherwise fall back to the existing unscoped query
|
|
28
|
+
* path unchanged, so every caller that predates project scoping keeps working exactly as
|
|
29
|
+
* before.
|
|
30
|
+
*/
|
|
31
|
+
function listScoped(artifacts: ArtifactStore, scopes: ArtifactScopeStore, kind: string, filter: ListFilter, excludeSubtype?: string): Artifact[] {
|
|
32
|
+
if (filter.projectRoot === undefined) return artifacts.query({ kind, excludeSubtype, status: filter.status, text: filter.text, limit: filter.limit });
|
|
33
|
+
const limit = filter.limit ?? ARTIFACT_SCOPE_MAX_ARTIFACTS;
|
|
34
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > ARTIFACT_SCOPE_MAX_ARTIFACTS) {
|
|
35
|
+
throw new Error(`list limit must be between 1 and ${ARTIFACT_SCOPE_MAX_ARTIFACTS}`);
|
|
36
|
+
}
|
|
37
|
+
const projectRoot = normalizeProjectRoot(filter.projectRoot);
|
|
38
|
+
const ids = scopes.ids(projectRoot, ARTIFACT_SCOPE_MAX_ARTIFACTS);
|
|
39
|
+
const text = filter.text?.toLowerCase();
|
|
40
|
+
return ids
|
|
41
|
+
.map((id) => artifacts.get(id))
|
|
42
|
+
.filter((artifact): artifact is Artifact => artifact?.kind === kind && artifact.subtype !== excludeSubtype)
|
|
43
|
+
.filter((artifact) => filter.status === undefined || artifact.status === filter.status)
|
|
44
|
+
.filter((artifact) => text === undefined || artifact.title.toLowerCase().includes(text) || artifact.body.toLowerCase().includes(text))
|
|
45
|
+
.sort((left, right) => right.updated_at.localeCompare(left.updated_at) || left.id.localeCompare(right.id))
|
|
46
|
+
.slice(0, limit);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Shared by assignDocumentProject/assignRuleProject/assignSkillProject. */
|
|
50
|
+
function assignArtifactProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, kind: string, projectRoot: string | undefined): Artifact {
|
|
51
|
+
requireKind(artifacts, id, kind);
|
|
52
|
+
scopes.assign(id, projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot), projectRoot === undefined ? "unscoped" : "explicit");
|
|
53
|
+
return artifacts.get(id)!;
|
|
10
54
|
}
|
|
11
55
|
|
|
12
56
|
function requireKind(artifacts: ArtifactStore, id: string, kind: string): Artifact {
|
|
@@ -25,10 +69,25 @@ function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | unde
|
|
|
25
69
|
&& (defaults as Record<string, unknown>)["subtype"] === NOTE_SUBTYPE;
|
|
26
70
|
}
|
|
27
71
|
|
|
28
|
-
|
|
72
|
+
/** caller never owns NOTE_SUBTYPE, so requireArtifactAllowed always throws — the trailing throw only satisfies TypeScript's control-flow analysis for a `never`-returning function. */
|
|
73
|
+
function requireNotesFacade(authority: AuthorityRegistry, caller: string): never {
|
|
74
|
+
authority.requireArtifactAllowed("doc", NOTE_SUBTYPE, "create", caller);
|
|
29
75
|
throw new Error("note creation requires notes.capture");
|
|
30
76
|
}
|
|
31
77
|
|
|
78
|
+
function templateSubtype(artifacts: ArtifactStore, templateId: string | undefined): string | undefined {
|
|
79
|
+
if (!templateId) return undefined;
|
|
80
|
+
const defaults = artifacts.get(templateId)?.extra["defaults"];
|
|
81
|
+
if (typeof defaults !== "object" || defaults === null || Array.isArray(defaults)) return undefined;
|
|
82
|
+
const subtype = (defaults as Record<string, unknown>)["subtype"];
|
|
83
|
+
return typeof subtype === "string" ? subtype : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function requireMutableDocument(document: Artifact, authority: AuthorityRegistry): Artifact {
|
|
87
|
+
authority.requireArtifactAllowed(document.kind, document.subtype, "status", "docs");
|
|
88
|
+
return document;
|
|
89
|
+
}
|
|
90
|
+
|
|
32
91
|
export interface CreateDocumentInput {
|
|
33
92
|
title: string;
|
|
34
93
|
body?: string;
|
|
@@ -36,6 +95,8 @@ export interface CreateDocumentInput {
|
|
|
36
95
|
labels?: string[];
|
|
37
96
|
extra?: Record<string, unknown>;
|
|
38
97
|
templateId?: string;
|
|
98
|
+
/** Optional at creation, unlike Tasks -- omitting it leaves the Doc in the unscoped bucket, matching today's default behavior for every existing caller. */
|
|
99
|
+
projectRoot?: string;
|
|
39
100
|
}
|
|
40
101
|
|
|
41
102
|
export type DocumentTransition = "activate" | "archive" | "reopen";
|
|
@@ -47,21 +108,35 @@ const DOCUMENT_TRANSITIONS: Record<DocumentTransition, { from: string[]; to: str
|
|
|
47
108
|
reopen: { from: ["archived"], to: "draft" },
|
|
48
109
|
};
|
|
49
110
|
|
|
50
|
-
export function createDocument(artifacts: ArtifactStore, input: CreateDocumentInput): Artifact {
|
|
51
|
-
if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade();
|
|
52
|
-
|
|
111
|
+
export function createDocument(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateDocumentInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
112
|
+
if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade(authority, "docs");
|
|
113
|
+
authority.requireArtifactAllowed("doc", input.subtype ?? templateSubtype(artifacts, input.templateId), "create", "docs");
|
|
114
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
115
|
+
const document = artifacts.create({
|
|
53
116
|
kind: "doc",
|
|
117
|
+
// Explicit, not defaultStatusFor's "first status row by rowid" fallback -- the same
|
|
118
|
+
// heuristic that made Task creation non-deterministic on a migrated database. Every
|
|
119
|
+
// creation path that has no caller-supplied initial status must set one explicitly.
|
|
120
|
+
status: "draft",
|
|
54
121
|
title: input.title,
|
|
55
122
|
body: input.body,
|
|
56
123
|
subtype: input.subtype,
|
|
57
124
|
labels: input.labels,
|
|
58
125
|
extra: input.extra,
|
|
59
126
|
templateId: input.templateId,
|
|
60
|
-
});
|
|
127
|
+
}, context);
|
|
128
|
+
scopes.assign(document.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
129
|
+
return document;
|
|
61
130
|
}
|
|
62
131
|
|
|
63
|
-
export function listDocuments(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
|
|
64
|
-
return artifacts
|
|
132
|
+
export function listDocuments(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
|
|
133
|
+
return listScoped(artifacts, scopes, "doc", filter, NOTE_SUBTYPE);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function assignDocumentProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
|
|
137
|
+
requireDocument(artifacts, id); // rejects Notes -- project reassignment for notes goes through notes.* like everything else about them
|
|
138
|
+
scopes.assign(id, projectRoot === undefined ? undefined : normalizeProjectRoot(projectRoot), projectRoot === undefined ? "unscoped" : "explicit");
|
|
139
|
+
return artifacts.get(id)!;
|
|
65
140
|
}
|
|
66
141
|
|
|
67
142
|
function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
|
|
@@ -75,17 +150,19 @@ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
|
|
|
75
150
|
return artifacts.get(id, { tree: true })!;
|
|
76
151
|
}
|
|
77
152
|
|
|
78
|
-
export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition): Artifact {
|
|
79
|
-
const document = requireDocument(artifacts, id);
|
|
153
|
+
export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
154
|
+
const document = requireMutableDocument(requireDocument(artifacts, id), authority);
|
|
80
155
|
const transition = DOCUMENT_TRANSITIONS[action];
|
|
81
156
|
if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
|
|
82
|
-
return artifacts.setStatus(id, transition.to)!;
|
|
157
|
+
return artifacts.setStatus(id, transition.to, context)!;
|
|
83
158
|
}
|
|
84
159
|
|
|
85
|
-
export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string): Artifact {
|
|
86
|
-
requireDocument(artifacts, id);
|
|
87
|
-
|
|
88
|
-
|
|
160
|
+
export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
161
|
+
requireMutableDocument(requireDocument(artifacts, id), authority);
|
|
162
|
+
const target = artifacts.get(targetId);
|
|
163
|
+
if (!target) throw new Error(`target artifact "${targetId}" not found`);
|
|
164
|
+
requireMutableDocument(target, authority);
|
|
165
|
+
artifacts.link({ from: id, relation, to: targetId }, context);
|
|
89
166
|
return showDocument(artifacts, id);
|
|
90
167
|
}
|
|
91
168
|
|
|
@@ -97,13 +174,36 @@ export interface CreateRuleInput {
|
|
|
97
174
|
severity?: "block" | "warn" | "info";
|
|
98
175
|
labels?: string[];
|
|
99
176
|
extra?: Record<string, unknown>;
|
|
177
|
+
projectRoot?: string;
|
|
100
178
|
}
|
|
101
179
|
|
|
102
180
|
export type RuleTransition = "enable" | "disable";
|
|
103
181
|
|
|
104
|
-
|
|
105
|
-
|
|
182
|
+
/**
|
|
183
|
+
* A Rule's condition+action+body is injected into every relevant turn for the rule's entire
|
|
184
|
+
* lifetime -- a permanent tax on every future turn's context budget, not a one-time cost.
|
|
185
|
+
* Rejects (rather than silently truncating or merely warning) once a rule is unambiguously
|
|
186
|
+
* bloated, since a silently-truncated rule would inject different text than what its author
|
|
187
|
+
* reviewed, and a warning nobody reads is not a bound. See RULE_TEXT_HARD_LIMIT_CHARACTERS's
|
|
188
|
+
* own comment in constants.ts for the research this threshold is grounded in.
|
|
189
|
+
*/
|
|
190
|
+
function assertRuleTextWithinBounds(condition: string | undefined, action: string | undefined, body: string | undefined): void {
|
|
191
|
+
const combined = (condition ?? "").length + (action ?? "").length + (body ?? "").length;
|
|
192
|
+
if (combined > RULE_TEXT_HARD_LIMIT_CHARACTERS) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`rule condition+action+body is ${combined} characters, exceeding the ${RULE_TEXT_HARD_LIMIT_CHARACTERS}-character bound. ` +
|
|
195
|
+
"A Rule is injected into every relevant turn for its entire lifetime -- this is a permanent context-budget tax, not a one-time cost. " +
|
|
196
|
+
"Split it: keep a short Rule (the condition and the invariant itself), and move the full reasoning, examples, and research into a linked Doc.",
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createRule(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateRuleInput, context?: ArtifactEventContext): Artifact {
|
|
202
|
+
assertRuleTextWithinBounds(input.condition, input.action, input.body);
|
|
203
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
204
|
+
const rule = artifacts.create({
|
|
106
205
|
kind: "rule",
|
|
206
|
+
status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
|
|
107
207
|
title: input.title,
|
|
108
208
|
body: input.body,
|
|
109
209
|
labels: input.labels,
|
|
@@ -113,11 +213,17 @@ export function createRule(artifacts: ArtifactStore, input: CreateRuleInput): Ar
|
|
|
113
213
|
...(input.action ? { action: input.action } : {}),
|
|
114
214
|
severity: input.severity ?? "info",
|
|
115
215
|
},
|
|
116
|
-
});
|
|
216
|
+
}, context);
|
|
217
|
+
scopes.assign(rule.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
218
|
+
return rule;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function listRules(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
|
|
222
|
+
return listScoped(artifacts, scopes, "rule", filter);
|
|
117
223
|
}
|
|
118
224
|
|
|
119
|
-
export function
|
|
120
|
-
return artifacts
|
|
225
|
+
export function assignRuleProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
|
|
226
|
+
return assignArtifactProject(artifacts, scopes, id, "rule", projectRoot);
|
|
121
227
|
}
|
|
122
228
|
|
|
123
229
|
/** Global rules always apply; scoped workflow rules apply only while their run owns active focus. */
|
|
@@ -144,18 +250,18 @@ export function previewRule(artifacts: ArtifactStore, id: string): string {
|
|
|
144
250
|
return `• ${rule.title}${condition}\n ${action}`;
|
|
145
251
|
}
|
|
146
252
|
|
|
147
|
-
export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition): Artifact {
|
|
253
|
+
export function transitionRule(artifacts: ArtifactStore, id: string, action: RuleTransition, context?: ArtifactEventContext): Artifact {
|
|
148
254
|
const rule = requireKind(artifacts, id, "rule");
|
|
149
255
|
const expected = action === "enable" ? "deprecated" : "active";
|
|
150
256
|
const target = action === "enable" ? "active" : "deprecated";
|
|
151
257
|
if (rule.status !== expected) throw new Error(`cannot ${action} rule from ${rule.status}`);
|
|
152
|
-
return artifacts.setStatus(id, target)!;
|
|
258
|
+
return artifacts.setStatus(id, target, context)!;
|
|
153
259
|
}
|
|
154
260
|
|
|
155
|
-
export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string): Artifact {
|
|
261
|
+
export function gateTaskWithRule(artifacts: ArtifactStore, ruleId: string, taskId: string, context?: ArtifactEventContext): Artifact {
|
|
156
262
|
requireKind(artifacts, ruleId, "rule");
|
|
157
263
|
requireKind(artifacts, taskId, "task");
|
|
158
|
-
artifacts.link({ from: ruleId, relation: "gates", to: taskId });
|
|
264
|
+
artifacts.link({ from: ruleId, relation: "gates", to: taskId }, context);
|
|
159
265
|
return showRule(artifacts, ruleId);
|
|
160
266
|
}
|
|
161
267
|
|
|
@@ -168,6 +274,7 @@ export interface CreateSkillInput {
|
|
|
168
274
|
definition?: unknown;
|
|
169
275
|
labels?: string[];
|
|
170
276
|
extra?: Record<string, unknown>;
|
|
277
|
+
projectRoot?: string;
|
|
171
278
|
}
|
|
172
279
|
|
|
173
280
|
export interface CreateArtifactTemplateInput {
|
|
@@ -177,18 +284,21 @@ export interface CreateArtifactTemplateInput {
|
|
|
177
284
|
required?: string[];
|
|
178
285
|
body?: string;
|
|
179
286
|
labels?: string[];
|
|
287
|
+
projectRoot?: string;
|
|
180
288
|
}
|
|
181
289
|
|
|
182
290
|
export type SkillTransition = "enable" | "disable";
|
|
183
291
|
|
|
184
|
-
export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput): Artifact {
|
|
292
|
+
export function createSkill(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateSkillInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
185
293
|
if (input.definition !== undefined && (input.trigger !== undefined || input.steps !== undefined || input.tools !== undefined)) {
|
|
186
294
|
throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
|
|
187
295
|
}
|
|
188
296
|
const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
|
|
189
|
-
if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade();
|
|
190
|
-
|
|
297
|
+
if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade(authority, "skills");
|
|
298
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
299
|
+
const skill = artifacts.create({
|
|
191
300
|
kind: "skill",
|
|
301
|
+
status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
|
|
192
302
|
subtype: definition ? "workflow" : undefined,
|
|
193
303
|
title: input.title,
|
|
194
304
|
body: input.body,
|
|
@@ -200,13 +310,17 @@ export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput):
|
|
|
200
310
|
...(input.steps ? { steps: input.steps } : {}),
|
|
201
311
|
...(input.tools ? { tools: input.tools } : {}),
|
|
202
312
|
},
|
|
203
|
-
});
|
|
313
|
+
}, context);
|
|
314
|
+
scopes.assign(skill.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
315
|
+
return skill;
|
|
204
316
|
}
|
|
205
317
|
|
|
206
|
-
export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateArtifactTemplateInput): Artifact {
|
|
207
|
-
if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade();
|
|
208
|
-
|
|
318
|
+
export function createArtifactTemplate(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreateArtifactTemplateInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
319
|
+
if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade(authority, "skills");
|
|
320
|
+
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
321
|
+
const template = artifacts.create({
|
|
209
322
|
kind: "skill",
|
|
323
|
+
status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
|
|
210
324
|
subtype: "artifact-template",
|
|
211
325
|
title: input.title,
|
|
212
326
|
body: input.body,
|
|
@@ -216,16 +330,22 @@ export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateAr
|
|
|
216
330
|
defaults: input.defaults ?? {},
|
|
217
331
|
required: input.required ?? ["title"],
|
|
218
332
|
},
|
|
219
|
-
});
|
|
333
|
+
}, context);
|
|
334
|
+
scopes.assign(template.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
335
|
+
return template;
|
|
220
336
|
}
|
|
221
337
|
|
|
222
|
-
export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput): Artifact {
|
|
223
|
-
if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade();
|
|
224
|
-
return artifacts.create({ ...input, templateId });
|
|
338
|
+
export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
339
|
+
if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade(authority, "skills");
|
|
340
|
+
return artifacts.create({ ...input, templateId }, context);
|
|
225
341
|
}
|
|
226
342
|
|
|
227
|
-
export function listSkills(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
|
|
228
|
-
return artifacts
|
|
343
|
+
export function listSkills(artifacts: ArtifactStore, scopes: ArtifactScopeStore, filter: ListFilter): Artifact[] {
|
|
344
|
+
return listScoped(artifacts, scopes, "skill", filter);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function assignSkillProject(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: string, projectRoot: string | undefined): Artifact {
|
|
348
|
+
return assignArtifactProject(artifacts, scopes, id, "skill", projectRoot);
|
|
229
349
|
}
|
|
230
350
|
|
|
231
351
|
export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
|
|
@@ -233,8 +353,7 @@ export function showSkill(artifacts: ArtifactStore, id: string): Artifact {
|
|
|
233
353
|
return artifacts.get(id, { tree: true })!;
|
|
234
354
|
}
|
|
235
355
|
|
|
236
|
-
|
|
237
|
-
const skill = requireKind(artifacts, id, "skill");
|
|
356
|
+
function skillInvocationBody(skill: Artifact): string {
|
|
238
357
|
if (skill.subtype === "artifact-template") {
|
|
239
358
|
return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_id: ${skill.id}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
|
|
240
359
|
}
|
|
@@ -261,10 +380,52 @@ export function skillInvocation(artifacts: ArtifactStore, id: string): string {
|
|
|
261
380
|
].join("\n");
|
|
262
381
|
}
|
|
263
382
|
|
|
264
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Skills are special: invoking one queries Papyrus for the skill's real outgoing graph edges
|
|
385
|
+
* -- not just its own static body/extra fields -- so a Skill linked to existing Tasks, Rules,
|
|
386
|
+
* or Docs surfaces that linked context on invocation. A Skill can also link to and invoke
|
|
387
|
+
* OTHER Skills (any relation whose target is itself a Skill, e.g. the same "triggers" relation
|
|
388
|
+
* workflow execution already uses for skill-to-task edges): invoking the parent recursively
|
|
389
|
+
* composes the linked skill's own invocation. Bounded and cycle-safe -- a skill-calls-skill
|
|
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.
|
|
393
|
+
*/
|
|
394
|
+
export function skillInvocation(artifacts: ArtifactStore, id: string, visited: Set<string> = new Set(), depth = 0): string {
|
|
395
|
+
const skill = requireKind(artifacts, id, "skill");
|
|
396
|
+
visited.add(id);
|
|
397
|
+
const sections = [skillInvocationBody(skill)];
|
|
398
|
+
|
|
399
|
+
const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, SKILL_INVOCATION_MAX_LINKED_ARTIFACTS);
|
|
400
|
+
const linkedArtifactLines: string[] = [];
|
|
401
|
+
const linkedSkillSections: string[] = [];
|
|
402
|
+
for (const edge of edges) {
|
|
403
|
+
const target = artifacts.get(edge.to);
|
|
404
|
+
if (!target) continue; // dangling edge -- defensive, should not happen
|
|
405
|
+
if (target.kind !== "skill") {
|
|
406
|
+
linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}" (${target.id})`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (visited.has(target.id)) {
|
|
410
|
+
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" (${target.id}) -- already invoked above in this chain, not repeated.`);
|
|
411
|
+
} else if (depth + 1 > SKILL_INVOCATION_MAX_CALL_DEPTH) {
|
|
412
|
+
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" (${target.id}) -- call depth limit reached, invoke it separately.`);
|
|
413
|
+
} else {
|
|
414
|
+
const nested = skillInvocation(artifacts, target.id, visited, depth + 1);
|
|
415
|
+
linkedSkillSections.push(`Also invoke linked skill (${edge.relation}) "${target.title}" (${target.id}):\n${nested}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (linkedArtifactLines.length > 0) {
|
|
419
|
+
sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedArtifactLines].join("\n"));
|
|
420
|
+
}
|
|
421
|
+
for (const section of linkedSkillSections) sections.push(section);
|
|
422
|
+
return sections.join("\n\n");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function transitionSkill(artifacts: ArtifactStore, id: string, action: SkillTransition, context?: ArtifactEventContext): Artifact {
|
|
265
426
|
const skill = requireKind(artifacts, id, "skill");
|
|
266
427
|
const expected = action === "enable" ? "deprecated" : "active";
|
|
267
428
|
const target = action === "enable" ? "active" : "deprecated";
|
|
268
429
|
if (skill.status !== expected) throw new Error(`cannot ${action} skill from ${skill.status}`);
|
|
269
|
-
return artifacts.setStatus(id, target)!;
|
|
430
|
+
return artifacts.setStatus(id, target, context)!;
|
|
270
431
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-projection-service.ts — the generic graph projection protocol for external bounded
|
|
3
|
+
* contexts. See src/domain/graph-projection.ts for the batch/checkpoint shapes and the
|
|
4
|
+
* documented, deliberate scope limits of this first walking-skeleton slice.
|
|
5
|
+
*/
|
|
6
|
+
import { GRAPH_PROJECTION_ID_MAX_LENGTH, GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH, GRAPH_PROJECTION_MAX_EDGES_PER_BATCH } from "./constants.ts";
|
|
7
|
+
import { GRAPH_PROJECTION_SCHEMA_VERSION, type GraphProjectionBatch, type GraphProjectionResult } from "./domain/graph-projection.ts";
|
|
8
|
+
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
9
|
+
import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
|
|
10
|
+
import type { GraphProjectionStore } from "./ports/graph-projection-store.ts";
|
|
11
|
+
import type { AuthorityRegistry } from "./authority-registry.ts";
|
|
12
|
+
|
|
13
|
+
function boundedId(value: string, label: string): string {
|
|
14
|
+
if (!value || value.length === 0) throw new Error(`${label} is required`);
|
|
15
|
+
if (value.length > GRAPH_PROJECTION_ID_MAX_LENGTH) throw new Error(`${label} exceeds ${GRAPH_PROJECTION_ID_MAX_LENGTH} characters`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class GraphProjection {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly artifacts: ArtifactStore,
|
|
22
|
+
private readonly store: GraphProjectionStore,
|
|
23
|
+
private readonly authority: AuthorityRegistry,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
checkpoint(producerId: string) {
|
|
27
|
+
return this.store.getCheckpoint(boundedId(producerId, "producer_id"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
apply(batch: GraphProjectionBatch): GraphProjectionResult {
|
|
31
|
+
if (batch.schemaVersion !== GRAPH_PROJECTION_SCHEMA_VERSION) {
|
|
32
|
+
throw new Error(`unsupported graph projection schema version "${batch.schemaVersion}", expected "${GRAPH_PROJECTION_SCHEMA_VERSION}"`);
|
|
33
|
+
}
|
|
34
|
+
const producerId = boundedId(batch.producerId, "producer_id");
|
|
35
|
+
const batchId = boundedId(batch.batchId, "batch_id");
|
|
36
|
+
if (!Number.isInteger(batch.sequence) || batch.sequence < 1) throw new Error("sequence must be a positive integer");
|
|
37
|
+
if (batch.artifacts.length > GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH) {
|
|
38
|
+
throw new Error(`batch is bounded to ${GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH} artifacts; got ${batch.artifacts.length}`);
|
|
39
|
+
}
|
|
40
|
+
if (batch.edges.length > GRAPH_PROJECTION_MAX_EDGES_PER_BATCH) {
|
|
41
|
+
throw new Error(`batch is bounded to ${GRAPH_PROJECTION_MAX_EDGES_PER_BATCH} edges; got ${batch.edges.length}`);
|
|
42
|
+
}
|
|
43
|
+
for (const artifact of batch.artifacts) boundedId(artifact.externalId, "artifact externalId");
|
|
44
|
+
for (const edge of batch.edges) { boundedId(edge.from, "edge from"); boundedId(edge.to, "edge to"); }
|
|
45
|
+
|
|
46
|
+
const existingCheckpoint = this.store.getCheckpoint(producerId);
|
|
47
|
+
if (existingCheckpoint?.lastBatchId === batchId && existingCheckpoint.lastSequence === batch.sequence) {
|
|
48
|
+
return { producerId, batchId, sequence: batch.sequence, artifactsUpserted: 0, artifactsCreated: 0, edgesUpserted: 0, alreadyApplied: true };
|
|
49
|
+
}
|
|
50
|
+
if (existingCheckpoint === null) {
|
|
51
|
+
if (batch.sequence !== 1) throw new Error(`first batch for producer "${producerId}" must have sequence 1, got ${batch.sequence}`);
|
|
52
|
+
} else {
|
|
53
|
+
if (batch.sequence <= existingCheckpoint.lastSequence) {
|
|
54
|
+
throw new Error(`stale batch: sequence ${batch.sequence} is not after checkpoint sequence ${existingCheckpoint.lastSequence} for producer "${producerId}"`);
|
|
55
|
+
}
|
|
56
|
+
if (batch.sequence > existingCheckpoint.lastSequence + 1) {
|
|
57
|
+
throw new Error(`sequence gap for producer "${producerId}": expected ${existingCheckpoint.lastSequence + 1}, got ${batch.sequence}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Authority up front, before any write: a producer that doesn't own a subtype/relation
|
|
62
|
+
// it's trying to project into must fail closed, not partially apply then fail midway.
|
|
63
|
+
for (const artifact of batch.artifacts) this.authority.requireArtifactAllowed(artifact.kind, artifact.subtype, "create", producerId);
|
|
64
|
+
for (const edge of batch.edges) this.authority.requireRelationAllowed(edge.relation, "link", producerId);
|
|
65
|
+
|
|
66
|
+
const atomic = requireAtomicArtifactStore(this.artifacts);
|
|
67
|
+
let artifactsCreated = 0;
|
|
68
|
+
let artifactsUpserted = 0;
|
|
69
|
+
let edgesUpserted = 0;
|
|
70
|
+
atomic.atomic(() => {
|
|
71
|
+
for (const projected of batch.artifacts) {
|
|
72
|
+
const existingId = this.store.resolveIdentity(producerId, projected.externalId);
|
|
73
|
+
if (existingId) {
|
|
74
|
+
this.artifacts.updateContent(existingId, { title: projected.title, body: projected.body, labels: projected.labels ? [...projected.labels] : undefined });
|
|
75
|
+
if (projected.extra !== undefined) this.artifacts.setExtra(existingId, projected.extra);
|
|
76
|
+
} else {
|
|
77
|
+
const created = this.artifacts.create({
|
|
78
|
+
kind: projected.kind,
|
|
79
|
+
subtype: projected.subtype,
|
|
80
|
+
title: projected.title,
|
|
81
|
+
body: projected.body,
|
|
82
|
+
labels: projected.labels ? [...projected.labels] : undefined,
|
|
83
|
+
extra: projected.extra,
|
|
84
|
+
});
|
|
85
|
+
this.store.recordIdentity(producerId, projected.externalId, created.id);
|
|
86
|
+
artifactsCreated++;
|
|
87
|
+
}
|
|
88
|
+
artifactsUpserted++;
|
|
89
|
+
}
|
|
90
|
+
for (const edge of batch.edges) {
|
|
91
|
+
const fromId = this.store.resolveIdentity(producerId, edge.from);
|
|
92
|
+
if (!fromId) throw new Error(`edge references unknown externalId "${edge.from}" for producer "${producerId}"`);
|
|
93
|
+
const toId = this.store.resolveIdentity(producerId, edge.to);
|
|
94
|
+
if (!toId) throw new Error(`edge references unknown externalId "${edge.to}" for producer "${producerId}"`);
|
|
95
|
+
this.artifacts.link({ from: fromId, relation: edge.relation, to: toId });
|
|
96
|
+
edgesUpserted++;
|
|
97
|
+
}
|
|
98
|
+
this.store.commitCheckpoint({ producerId, lastSequence: batch.sequence, lastBatchId: batchId, appliedAt: new Date().toISOString() });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return { producerId, batchId, sequence: batch.sequence, artifactsUpserted, artifactsCreated, edgesUpserted, alreadyApplied: false };
|
|
102
|
+
}
|
|
103
|
+
}
|