pi-feats 0.1.1
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/LICENSE +21 -0
- package/README.md +508 -0
- package/extensions/README.md +27 -0
- package/extensions/api-server/PLAN.md +70 -0
- package/extensions/api-server/README.md +103 -0
- package/extensions/api-server/application-log-store.ts +21 -0
- package/extensions/api-server/application-runtime.ts +212 -0
- package/extensions/api-server/application-store.ts +30 -0
- package/extensions/api-server/index.ts +52 -0
- package/extensions/api-server/profile-store.ts +367 -0
- package/extensions/api-server/server.ts +863 -0
- package/extensions/cli-resources.ts +564 -0
- package/extensions/guardrails/index.ts +178 -0
- package/extensions/lib/application-handler-templates.ts +63 -0
- package/extensions/lib/profile-env.ts +61 -0
- package/extensions/lib/profile-sandbox.ts +197 -0
- package/extensions/lib/remote-hosts.ts +392 -0
- package/extensions/pi-console-webui/app/[section]/page.tsx +4 -0
- package/extensions/pi-console-webui/app/api/admin/config/[target]/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/admin/services/[service]/restart/route.ts +5 -0
- package/extensions/pi-console-webui/app/api/auth/login/route.ts +9 -0
- package/extensions/pi-console-webui/app/api/auth/logout/route.ts +3 -0
- package/extensions/pi-console-webui/app/api/message/app/[slug]/route.ts +11 -0
- package/extensions/pi-console-webui/app/api/pi/[...path]/route.ts +31 -0
- package/extensions/pi-console-webui/app/applications/[slug]/page.tsx +2 -0
- package/extensions/pi-console-webui/app/globals.css +41 -0
- package/extensions/pi-console-webui/app/icon.svg +1 -0
- package/extensions/pi-console-webui/app/layout.tsx +5 -0
- package/extensions/pi-console-webui/app/login/page.tsx +11 -0
- package/extensions/pi-console-webui/app/page.tsx +2 -0
- package/extensions/pi-console-webui/app/terminal/page.tsx +4 -0
- package/extensions/pi-console-webui/components/admin-config-form.tsx +16 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +39 -0
- package/extensions/pi-console-webui/components/application-logs.tsx +38 -0
- package/extensions/pi-console-webui/components/application-mappings.tsx +28 -0
- package/extensions/pi-console-webui/components/application-sessions.tsx +11 -0
- package/extensions/pi-console-webui/components/application-settings.tsx +60 -0
- package/extensions/pi-console-webui/components/application-workspace.tsx +14 -0
- package/extensions/pi-console-webui/components/applications.tsx +15 -0
- package/extensions/pi-console-webui/components/chat-workspace.tsx +42 -0
- package/extensions/pi-console-webui/components/console-page.tsx +23 -0
- package/extensions/pi-console-webui/components/console-state.tsx +30 -0
- package/extensions/pi-console-webui/components/console.tsx +115 -0
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +78 -0
- package/extensions/pi-console-webui/components/package-resources.tsx +13 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +41 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +35 -0
- package/extensions/pi-console-webui/components/skill-source-document-preview.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-source-import.tsx +7 -0
- package/extensions/pi-console-webui/components/skill-sources.tsx +12 -0
- package/extensions/pi-console-webui/components/terminal-client.tsx +39 -0
- package/extensions/pi-console-webui/components/toast.tsx +18 -0
- package/extensions/pi-console-webui/components/ui/button.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/card.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/input.tsx +4 -0
- package/extensions/pi-console-webui/components/ui/switch.tsx +6 -0
- package/extensions/pi-console-webui/components/ui/tabs.tsx +11 -0
- package/extensions/pi-console-webui/components.json +8 -0
- package/extensions/pi-console-webui/index.ts +33 -0
- package/extensions/pi-console-webui/lib/admin-config.ts +22 -0
- package/extensions/pi-console-webui/lib/auth.ts +21 -0
- package/extensions/pi-console-webui/lib/config.ts +15 -0
- package/extensions/pi-console-webui/lib/pi-api.ts +9 -0
- package/extensions/pi-console-webui/lib/utils.ts +3 -0
- package/extensions/pi-console-webui/next-env.d.ts +6 -0
- package/extensions/pi-console-webui/next.config.js +5 -0
- package/extensions/pi-console-webui/postcss.config.js +1 -0
- package/extensions/pi-console-webui/tailwind.config.ts +2 -0
- package/extensions/pi-console-webui/tsconfig.json +41 -0
- package/extensions/profiles.ts +439 -0
- package/extensions/pulse/index.ts +62 -0
- package/extensions/pulse/store.ts +105 -0
- package/extensions/sequential-workflow.ts +270 -0
- package/extensions/skill-sources/index.ts +4 -0
- package/extensions/skill-sources/store.ts +118 -0
- package/package.json +89 -0
- package/scripts/install-nono.sh +34 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
type TaskType = "action" | "collect" | "evaluate";
|
|
9
|
+
type TaskStatus = "pending" | "running" | "awaiting_user" | "evaluating" | "accepted" | "rejected" | "failed";
|
|
10
|
+
type WorkflowStatus = "running" | "awaiting_user" | "evaluating" | "completed" | "cancelled" | "failed";
|
|
11
|
+
|
|
12
|
+
type Task = {
|
|
13
|
+
id: number;
|
|
14
|
+
workflow_id: number;
|
|
15
|
+
position: number;
|
|
16
|
+
type: TaskType;
|
|
17
|
+
instruction: string;
|
|
18
|
+
criteria: string | null;
|
|
19
|
+
status: TaskStatus;
|
|
20
|
+
attempts: number;
|
|
21
|
+
result: string | null;
|
|
22
|
+
evaluation: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Workflow = {
|
|
26
|
+
id: number;
|
|
27
|
+
title: string;
|
|
28
|
+
source: string;
|
|
29
|
+
status: WorkflowStatus;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const taskType = StringEnum(["action", "collect", "evaluate"] as const);
|
|
33
|
+
const phaseType = StringEnum(["action", "collect"] as const);
|
|
34
|
+
// Keep workflow state inside the active profile. Named profiles run under
|
|
35
|
+
// nono and cannot write the shared extension directory.
|
|
36
|
+
const workflowDatabase = () => join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "sequential-workflow.db");
|
|
37
|
+
|
|
38
|
+
export default function (pi: ExtensionAPI) {
|
|
39
|
+
const db = new DatabaseSync(workflowDatabase());
|
|
40
|
+
db.exec(`
|
|
41
|
+
PRAGMA journal_mode = WAL;
|
|
42
|
+
CREATE TABLE IF NOT EXISTS workflows (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
title TEXT NOT NULL,
|
|
45
|
+
source TEXT NOT NULL,
|
|
46
|
+
status TEXT NOT NULL,
|
|
47
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
48
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
49
|
+
);
|
|
50
|
+
CREATE TABLE IF NOT EXISTS workflow_tasks (
|
|
51
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
52
|
+
workflow_id INTEGER NOT NULL REFERENCES workflows(id),
|
|
53
|
+
position INTEGER NOT NULL,
|
|
54
|
+
type TEXT NOT NULL,
|
|
55
|
+
instruction TEXT NOT NULL,
|
|
56
|
+
criteria TEXT,
|
|
57
|
+
status TEXT NOT NULL,
|
|
58
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
59
|
+
result TEXT,
|
|
60
|
+
evaluation TEXT,
|
|
61
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
62
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
63
|
+
UNIQUE(workflow_id, position)
|
|
64
|
+
);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS workflow_events (
|
|
66
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
67
|
+
workflow_id INTEGER NOT NULL REFERENCES workflows(id),
|
|
68
|
+
task_id INTEGER REFERENCES workflow_tasks(id),
|
|
69
|
+
phase TEXT NOT NULL,
|
|
70
|
+
payload TEXT NOT NULL,
|
|
71
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
72
|
+
);
|
|
73
|
+
`);
|
|
74
|
+
|
|
75
|
+
const one = <T>(sql: string, ...params: unknown[]) => db.prepare(sql).get(...params) as T | undefined;
|
|
76
|
+
const many = <T>(sql: string, ...params: unknown[]) => db.prepare(sql).all(...params) as T[];
|
|
77
|
+
const event = (workflowId: number, taskId: number | null, phase: string, payload: unknown) => {
|
|
78
|
+
db.prepare("INSERT INTO workflow_events (workflow_id, task_id, phase, payload) VALUES (?, ?, ?, ?)")
|
|
79
|
+
.run(workflowId, taskId, phase, JSON.stringify(payload));
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const activeWorkflow = () => one<Workflow>(
|
|
83
|
+
"SELECT id, title, source, status FROM workflows WHERE status IN ('running', 'awaiting_user', 'evaluating') ORDER BY id DESC LIMIT 1",
|
|
84
|
+
);
|
|
85
|
+
const currentTask = (workflowId: number) => one<Task>(
|
|
86
|
+
"SELECT id, workflow_id, position, type, instruction, criteria, status, attempts, result, evaluation FROM workflow_tasks WHERE workflow_id = ? AND status NOT IN ('accepted', 'failed') ORDER BY position LIMIT 1",
|
|
87
|
+
workflowId,
|
|
88
|
+
);
|
|
89
|
+
const taskSummary = (task: Task) => ({
|
|
90
|
+
id: task.id,
|
|
91
|
+
position: task.position,
|
|
92
|
+
type: task.type,
|
|
93
|
+
instruction: task.instruction,
|
|
94
|
+
criteria: task.criteria,
|
|
95
|
+
status: task.status,
|
|
96
|
+
attempts: task.attempts,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const activateNext = (workflow: Workflow) => {
|
|
100
|
+
const next = currentTask(workflow.id);
|
|
101
|
+
if (!next) {
|
|
102
|
+
db.prepare("UPDATE workflows SET status = 'completed', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflow.id);
|
|
103
|
+
event(workflow.id, null, "completed", { message: "Todas as tasks foram aceitas." });
|
|
104
|
+
return { completed: true };
|
|
105
|
+
}
|
|
106
|
+
const status: TaskStatus = next.type === "collect" ? "awaiting_user" : "running";
|
|
107
|
+
const workflowStatus: WorkflowStatus = next.type === "collect" ? "awaiting_user" : "running";
|
|
108
|
+
db.prepare("UPDATE workflow_tasks SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(status, next.id);
|
|
109
|
+
db.prepare("UPDATE workflows SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflowStatus, workflow.id);
|
|
110
|
+
const activated = currentTask(workflow.id)!;
|
|
111
|
+
event(workflow.id, activated.id, "activated", taskSummary(activated));
|
|
112
|
+
return { completed: false, task: activated };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
pi.on("before_agent_start", (_event, _ctx) => {
|
|
116
|
+
const workflow = activeWorkflow();
|
|
117
|
+
if (!workflow) return;
|
|
118
|
+
const task = currentTask(workflow.id);
|
|
119
|
+
if (!task) return;
|
|
120
|
+
return {
|
|
121
|
+
message: {
|
|
122
|
+
customType: "sequential-workflow-state",
|
|
123
|
+
display: false,
|
|
124
|
+
content: `Workflow ativo #${workflow.id} (${workflow.title}). Task atual obrigatória: #${task.position} [${task.type}] ${task.instruction}\nStatus: ${task.status}. Critério: ${task.criteria ?? "nenhum"}. Não execute ou avance para outra task. Use as tools sequential_workflow_* para registrar resultado e avaliação.`,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
pi.registerTool({
|
|
130
|
+
name: "sequential_workflow_create",
|
|
131
|
+
label: "Create Sequential Workflow",
|
|
132
|
+
description: "Persiste um plano de workflow e ativa exclusivamente sua primeira task.",
|
|
133
|
+
promptSnippet: "Create a persisted sequential workflow from a validated Action/Collect/Evaluate plan",
|
|
134
|
+
promptGuidelines: [
|
|
135
|
+
"Use sequential_workflow_create only after an explicit request to create or execute a workflow has been identified.",
|
|
136
|
+
"A Collect task passed to sequential_workflow_create must include acceptance criteria.",
|
|
137
|
+
],
|
|
138
|
+
parameters: Type.Object({
|
|
139
|
+
title: Type.String({ minLength: 1 }),
|
|
140
|
+
source: Type.String({ minLength: 1 }),
|
|
141
|
+
tasks: Type.Array(Type.Object({
|
|
142
|
+
type: taskType,
|
|
143
|
+
instruction: Type.String({ minLength: 1 }),
|
|
144
|
+
criteria: Type.Optional(Type.String({ minLength: 1 })),
|
|
145
|
+
}), { minItems: 1 }),
|
|
146
|
+
}),
|
|
147
|
+
async execute(_id, params) {
|
|
148
|
+
const active = activeWorkflow();
|
|
149
|
+
if (active) throw new Error(`Já existe um workflow ativo (#${active.id}: ${active.title}). Conclua ou cancele-o antes de criar outro.`);
|
|
150
|
+
for (const [index, task] of params.tasks.entries()) {
|
|
151
|
+
if (task.type === "collect" && !task.criteria) {
|
|
152
|
+
throw new Error(`Task ${index + 1} é Collect e exige criteria.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const inserted = db.prepare("INSERT INTO workflows (title, source, status) VALUES (?, ?, 'running')")
|
|
156
|
+
.run(params.title, params.source);
|
|
157
|
+
const workflowId = Number(inserted.lastInsertRowid);
|
|
158
|
+
const insertTask = db.prepare("INSERT INTO workflow_tasks (workflow_id, position, type, instruction, criteria, status) VALUES (?, ?, ?, ?, ?, 'pending')");
|
|
159
|
+
params.tasks.forEach((task, index) => insertTask.run(workflowId, index + 1, task.type, task.instruction, task.criteria ?? null));
|
|
160
|
+
const workflow = one<Workflow>("SELECT id, title, source, status FROM workflows WHERE id = ?", workflowId)!;
|
|
161
|
+
event(workflowId, null, "created", { title: params.title, taskCount: params.tasks.length });
|
|
162
|
+
const next = activateNext(workflow);
|
|
163
|
+
const message = next.completed
|
|
164
|
+
? `Workflow #${workflowId} criado e concluído sem tasks pendentes.`
|
|
165
|
+
: `Workflow #${workflowId} criado. Execute somente a task #${next.task!.position}: ${next.task!.instruction}`;
|
|
166
|
+
return { content: [{ type: "text", text: message }], details: { workflowId, next } };
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.registerTool({
|
|
171
|
+
name: "sequential_workflow_record_result",
|
|
172
|
+
label: "Record Workflow Result",
|
|
173
|
+
description: "Registra o resultado da Action ou Collect atual sem avançar uma task que ainda precise de avaliação.",
|
|
174
|
+
promptSnippet: "Record the result of the active Action or Collect task",
|
|
175
|
+
promptGuidelines: ["Use sequential_workflow_record_result immediately after completing the active Action or receiving the active Collect response."],
|
|
176
|
+
parameters: Type.Object({ phase: phaseType, result: Type.String({ minLength: 1 }) }),
|
|
177
|
+
async execute(_id, params) {
|
|
178
|
+
const workflow = activeWorkflow();
|
|
179
|
+
if (!workflow) throw new Error("Não existe workflow ativo.");
|
|
180
|
+
const task = currentTask(workflow.id);
|
|
181
|
+
if (!task) throw new Error("Não existe task pendente.");
|
|
182
|
+
if (task.type !== params.phase) throw new Error(`A task atual é ${task.type}, não ${params.phase}.`);
|
|
183
|
+
if (task.status === "evaluating") throw new Error("O resultado já foi registrado; avalie a task atual.");
|
|
184
|
+
|
|
185
|
+
if (task.criteria) {
|
|
186
|
+
db.prepare("UPDATE workflow_tasks SET result = ?, status = 'evaluating', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(params.result, task.id);
|
|
187
|
+
db.prepare("UPDATE workflows SET status = 'evaluating', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflow.id);
|
|
188
|
+
event(workflow.id, task.id, "result_recorded", { phase: params.phase, result: params.result });
|
|
189
|
+
return { content: [{ type: "text", text: `Resultado registrado. Agora avalie a task #${task.position} contra o critério e chame sequential_workflow_evaluate.` }], details: { taskId: task.id, needsEvaluation: true } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
db.prepare("UPDATE workflow_tasks SET result = ?, status = 'accepted', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(params.result, task.id);
|
|
193
|
+
event(workflow.id, task.id, "accepted_without_criteria", { result: params.result });
|
|
194
|
+
const next = activateNext(workflow);
|
|
195
|
+
return { content: [{ type: "text", text: next.completed ? "Task aceita; workflow concluído." : `Task aceita. Execute a task #${next.task!.position}: ${next.task!.instruction}` }], details: { next } };
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
pi.registerTool({
|
|
200
|
+
name: "sequential_workflow_evaluate",
|
|
201
|
+
label: "Evaluate Workflow Task",
|
|
202
|
+
description: "Registra a aceitação ou reprovação da task atual e mantém a mesma task ativa quando ela é reprovada.",
|
|
203
|
+
promptSnippet: "Accept or reject the active workflow task after evaluation",
|
|
204
|
+
promptGuidelines: ["Use sequential_workflow_evaluate after every task with criteria and for every Evaluate task; never advance a rejected task."],
|
|
205
|
+
parameters: Type.Object({
|
|
206
|
+
accepted: Type.Boolean(),
|
|
207
|
+
reasoning: Type.String({ minLength: 1 }),
|
|
208
|
+
}),
|
|
209
|
+
async execute(_id, params) {
|
|
210
|
+
const workflow = activeWorkflow();
|
|
211
|
+
if (!workflow) throw new Error("Não existe workflow ativo.");
|
|
212
|
+
const task = currentTask(workflow.id);
|
|
213
|
+
if (!task) throw new Error("Não existe task pendente.");
|
|
214
|
+
if (task.type !== "evaluate" && task.status !== "evaluating") {
|
|
215
|
+
throw new Error("Registre o resultado da task antes de avaliá-la.");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (params.accepted) {
|
|
219
|
+
db.prepare("UPDATE workflow_tasks SET status = 'accepted', evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(params.reasoning, task.id);
|
|
220
|
+
event(workflow.id, task.id, "accepted", { reasoning: params.reasoning });
|
|
221
|
+
const next = activateNext(workflow);
|
|
222
|
+
return { content: [{ type: "text", text: next.completed ? "Avaliação aceita; workflow concluído." : `Avaliação aceita. Execute somente a task #${next.task!.position}: ${next.task!.instruction}` }], details: { accepted: true, next } };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const retryStatus: TaskStatus = task.type === "collect" ? "awaiting_user" : "running";
|
|
226
|
+
const workflowStatus: WorkflowStatus = task.type === "collect" ? "awaiting_user" : "running";
|
|
227
|
+
db.prepare("UPDATE workflow_tasks SET status = ?, attempts = attempts + 1, evaluation = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
|
228
|
+
.run(retryStatus, params.reasoning, task.id);
|
|
229
|
+
db.prepare("UPDATE workflows SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflowStatus, workflow.id);
|
|
230
|
+
event(workflow.id, task.id, "rejected", { reasoning: params.reasoning });
|
|
231
|
+
return { content: [{ type: "text", text: task.type === "collect" ? `Critério reprovado. Permaneça na task #${task.position}, explique o que falta e solicite novamente a informação.` : `Critério reprovado. Permaneça na task #${task.position}, corrija ou repita a ação e registre um novo resultado.` }], details: { accepted: false, task: taskSummary(currentTask(workflow.id)!) } };
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
pi.registerTool({
|
|
236
|
+
name: "sequential_workflow_status",
|
|
237
|
+
label: "Sequential Workflow Status",
|
|
238
|
+
description: "Consulta o workflow ativo, sua task atual e o estado persistido.",
|
|
239
|
+
parameters: Type.Object({}),
|
|
240
|
+
async execute() {
|
|
241
|
+
const workflow = activeWorkflow();
|
|
242
|
+
if (!workflow) return { content: [{ type: "text", text: "Não há workflow ativo." }], details: {} };
|
|
243
|
+
const tasks = many<Task>("SELECT id, workflow_id, position, type, instruction, criteria, status, attempts, result, evaluation FROM workflow_tasks WHERE workflow_id = ? ORDER BY position", workflow.id);
|
|
244
|
+
return { content: [{ type: "text", text: `Workflow #${workflow.id} (${workflow.status}). Task atual: ${currentTask(workflow.id)?.position ?? "nenhuma"}.` }], details: { workflow, tasks } };
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
pi.registerCommand("workflow-status", {
|
|
249
|
+
description: "Mostra o estado do workflow sequencial ativo",
|
|
250
|
+
handler: async (_args, ctx) => {
|
|
251
|
+
const workflow = activeWorkflow();
|
|
252
|
+
if (!workflow) return ctx.ui.notify("Não há workflow ativo.", "info");
|
|
253
|
+
const task = currentTask(workflow.id);
|
|
254
|
+
ctx.ui.notify(`Workflow #${workflow.id}: ${workflow.status}. Task atual: #${task?.position ?? "nenhuma"}.`, "info");
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
pi.registerCommand("workflow-cancel", {
|
|
259
|
+
description: "Cancela o workflow sequencial ativo",
|
|
260
|
+
handler: async (_args, ctx) => {
|
|
261
|
+
const workflow = activeWorkflow();
|
|
262
|
+
if (!workflow) return ctx.ui.notify("Não há workflow ativo.", "info");
|
|
263
|
+
db.prepare("UPDATE workflows SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(workflow.id);
|
|
264
|
+
event(workflow.id, null, "cancelled", { by: "user" });
|
|
265
|
+
ctx.ui.notify(`Workflow #${workflow.id} cancelado.`, "warning");
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
pi.on("session_shutdown", () => db.close());
|
|
270
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
// Skill Sources are a managed Git catalog. They intentionally do not contribute
|
|
3
|
+
// resource paths: a user must explicitly import each Skill into a profile.
|
|
4
|
+
export default function (_pi: ExtensionAPI) {}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { cp, lstat, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
const exec = promisify(execFile), now = () => new Date().toISOString();
|
|
9
|
+
type Row = Record<string, unknown>;
|
|
10
|
+
export type Source = { identifier: string; name: string; repoUrl: string; branch: string; basePath: string; username: string | null; hasAccessToken: boolean; enabled: boolean; lastCommitHash: string | null; lastSyncedAt: string | null; syncStatus: string; lastError: string | null; createdAt: string; updatedAt: string };
|
|
11
|
+
const valid = (value: string) => /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
12
|
+
export class SkillSourceStore {
|
|
13
|
+
private db: DatabaseSync; readonly root: string; private publishing = new Map<string, Promise<unknown>>();
|
|
14
|
+
constructor(agentDir: string) { this.root = join(agentDir, "skill-sources"); mkdirSync(this.root, { recursive: true }); this.db = new DatabaseSync(join(agentDir, "skill-sources.db")); this.db.exec("CREATE TABLE IF NOT EXISTS skill_sources (identifier TEXT PRIMARY KEY,name TEXT NOT NULL,repo_url TEXT NOT NULL,branch TEXT NOT NULL,base_path TEXT NOT NULL,auth_env_var TEXT,credential_username TEXT,credential_token TEXT,enabled INTEGER NOT NULL DEFAULT 1,last_commit_hash TEXT,last_synced_at TEXT,sync_status TEXT NOT NULL DEFAULT 'never_synced',last_error TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS skill_source_skills (source_identifier TEXT NOT NULL,skill_name TEXT NOT NULL,relative_path TEXT NOT NULL,description TEXT,content_hash TEXT NOT NULL,commit_hash TEXT,status TEXT NOT NULL,seen_at TEXT NOT NULL,PRIMARY KEY(source_identifier,relative_path)); CREATE TABLE IF NOT EXISTS skill_source_sync_runs (id TEXT PRIMARY KEY,source_identifier TEXT NOT NULL,status TEXT NOT NULL,commit_hash TEXT,discovered_count INTEGER NOT NULL,valid_count INTEGER NOT NULL,invalid_count INTEGER NOT NULL,error_summary TEXT,started_at TEXT NOT NULL,finished_at TEXT); CREATE TABLE IF NOT EXISTS skill_source_installations (profile TEXT NOT NULL,skill_name TEXT NOT NULL,source_identifier TEXT NOT NULL,source_relative_path TEXT NOT NULL,source_commit_hash TEXT,source_content_hash TEXT NOT NULL,target_path TEXT NOT NULL,installed_at TEXT NOT NULL,PRIMARY KEY(profile,skill_name))"); const skillSql = String((this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_source_skills'").get() as Row | undefined)?.sql ?? ""); if (skillSql.includes("PRIMARY KEY(source_identifier,skill_name)")) this.db.exec("BEGIN; ALTER TABLE skill_source_skills RENAME TO skill_source_skills_legacy; CREATE TABLE skill_source_skills (source_identifier TEXT NOT NULL,skill_name TEXT NOT NULL,relative_path TEXT NOT NULL,description TEXT,content_hash TEXT NOT NULL,commit_hash TEXT,status TEXT NOT NULL,seen_at TEXT NOT NULL,PRIMARY KEY(source_identifier,relative_path)); INSERT INTO skill_source_skills SELECT source_identifier,skill_name,relative_path,description,content_hash,commit_hash,status,seen_at FROM skill_source_skills_legacy; DROP TABLE skill_source_skills_legacy; COMMIT;"); const columns = new Set((this.db.prepare("PRAGMA table_info(skill_sources)").all() as Row[]).map(row => String(row.name))); if (!columns.has("credential_username")) this.db.exec("ALTER TABLE skill_sources ADD COLUMN credential_username TEXT"); if (!columns.has("credential_token")) this.db.exec("ALTER TABLE skill_sources ADD COLUMN credential_token TEXT"); }
|
|
15
|
+
private source(row: Row): Source { return { identifier:String(row.identifier), name:String(row.name), repoUrl:String(row.repo_url), branch:String(row.branch), basePath:String(row.base_path), username:row.credential_username == null ? null : String(row.credential_username), hasAccessToken:Boolean(row.credential_token), enabled:Boolean(row.enabled), lastCommitHash:row.last_commit_hash == null ? null : String(row.last_commit_hash), lastSyncedAt:row.last_synced_at == null ? null : String(row.last_synced_at), syncStatus:String(row.sync_status), lastError:row.last_error == null ? null : String(row.last_error), createdAt:String(row.created_at), updatedAt:String(row.updated_at) }; }
|
|
16
|
+
list() { return (this.db.prepare("SELECT * FROM skill_sources ORDER BY name").all() as Row[]).map(row => this.source(row)); }
|
|
17
|
+
get(identifier: string) { const row = this.db.prepare("SELECT * FROM skill_sources WHERE identifier=?").get(identifier) as Row | undefined; return row ? this.source(row) : undefined; }
|
|
18
|
+
save(input: Partial<Source> & { identifier: string; name: string; repoUrl: string; accessToken?: string }) { if (!valid(input.identifier)) throw Object.assign(new Error("Identifier must start with a lowercase letter and contain only lowercase letters, numbers or hyphens."), { status: 400 }); if (!input.name.trim() || !input.repoUrl.trim()) throw Object.assign(new Error("Name and repository URL are required."), { status: 400 }); const current=this.get(input.identifier), stamp=now(), branch=input.branch?.trim() || current?.branch || "main", basePath=(input.basePath?.trim() || current?.basePath || "skills").replace(/^\/+|\/+$/g, ""); if (!basePath || basePath.split("/").includes("..")) throw Object.assign(new Error("Invalid base path."), { status: 400 }); if (current) this.db.prepare("UPDATE skill_sources SET name=?,repo_url=?,branch=?,base_path=?,credential_username=?,credential_token=CASE WHEN ? THEN ? ELSE credential_token END,enabled=?,updated_at=? WHERE identifier=?").run(input.name.trim(),input.repoUrl.trim(),branch,basePath,input.username?.trim() || null,input.accessToken !== undefined ? 1 : 0,input.accessToken?.trim() || null,input.enabled === false ? 0 : 1,stamp,input.identifier); else this.db.prepare("INSERT INTO skill_sources (identifier,name,repo_url,branch,base_path,credential_username,credential_token,enabled,last_commit_hash,last_synced_at,sync_status,last_error,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)").run(input.identifier,input.name.trim(),input.repoUrl.trim(),branch,basePath,input.username?.trim() || null,input.accessToken?.trim() || null,input.enabled === false ? 0 : 1,null,null,"never_synced",null,stamp,stamp); return this.get(input.identifier)!; }
|
|
19
|
+
async rename(identifier: string, nextIdentifier: string) { if (identifier === nextIdentifier) return; if (!valid(nextIdentifier)) throw Object.assign(new Error("Identifier must start with a lowercase letter and contain only lowercase letters, numbers or hyphens."), { status: 400 }); if (!this.get(identifier)) throw Object.assign(new Error("Skill Source not found."), { status: 404 }); if (this.get(nextIdentifier)) throw Object.assign(new Error("A Skill Source with this identifier already exists."), { status: 409 }); const previous=join(this.root,identifier), next=join(this.root,nextIdentifier), cachePrevious=join(this.root,".git-cache",identifier), cacheNext=join(this.root,".git-cache",nextIdentifier); if (existsSync(previous)) await rename(previous,next); if (existsSync(cachePrevious)) await rename(cachePrevious,cacheNext); this.db.exec("BEGIN"); try { for (const table of ["skill_sources","skill_source_skills","skill_source_sync_runs","skill_source_installations"]) this.db.prepare(`UPDATE ${table} SET ${table === "skill_sources" ? "identifier" : "source_identifier"}=? WHERE ${table === "skill_sources" ? "identifier" : "source_identifier"}=?`).run(nextIdentifier,identifier); this.db.exec("COMMIT"); } catch(error) { this.db.exec("ROLLBACK"); if (existsSync(next) && !existsSync(previous)) await rename(next,previous); if (existsSync(cacheNext) && !existsSync(cachePrevious)) await rename(cacheNext,cachePrevious); throw error; } }
|
|
20
|
+
async remove(identifier: string) { if (!this.get(identifier)) throw Object.assign(new Error("Skill Source not found."), { status: 404 }); this.db.prepare("DELETE FROM skill_source_skills WHERE source_identifier=?").run(identifier); this.db.prepare("DELETE FROM skill_sources WHERE identifier=?").run(identifier); await rm(join(this.root, identifier), { recursive:true, force:true }); await rm(join(this.root, ".git-cache", identifier), { recursive:true, force:true }); }
|
|
21
|
+
skills(identifier: string) { if (!this.get(identifier)) throw Object.assign(new Error("Skill Source not found."), { status:404 }); return (this.db.prepare("SELECT skill_name,relative_path,description,content_hash,commit_hash,status,seen_at FROM skill_source_skills WHERE source_identifier=? ORDER BY skill_name").all(identifier) as Row[]).map(row => ({ name:String(row.skill_name), relativePath:String(row.relative_path), description:row.description == null ? null : String(row.description), contentHash:String(row.content_hash), commitHash:row.commit_hash == null ? null : String(row.commit_hash), status:String(row.status), seenAt:String(row.seen_at) })); }
|
|
22
|
+
async document(identifier: string, skillName: string) { const skill=this.skills(identifier).find(item=>item.name===skillName && item.status === "synced"); if (!skill) throw Object.assign(new Error("Skill document is not available."), { status: 404 }); const root=resolve(this.root,identifier), folder=resolve(root,skill.relativePath), path=join(folder,"SKILL.md"); if (!folder.startsWith(`${root}/`) || !existsSync(path)) throw Object.assign(new Error("Skill document is not available."), { status: 404 }); const raw=await readFile(path,"utf8"), match=raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/), frontmatter:Record<string,string>={}; if (match) for (const line of match[1].split(/\r?\n/)) { const separator=line.indexOf(":"); if (separator > 0) frontmatter[line.slice(0,separator).trim()]=line.slice(separator+1).trim().replace(/^['\"]|['\"]$/g,""); } const source=this.get(identifier)!; return { name:skill.name,path:relative(root,path).replaceAll("\\","/"),frontmatter,content:match ? raw.slice(match[0].length) : raw,source:{identifier:source.identifier,name:source.name,commitHash:skill.commitHash} }; }
|
|
23
|
+
private async git(args: string[], cwd: string, env: NodeJS.ProcessEnv) { try { return (await exec("git", args, { cwd, env, timeout:120000, maxBuffer:1024*1024 })).stdout.trim(); } catch (error) { const text=String((error as { stderr?: string }).stderr ?? (error as Error).message).replace(/https?:\/\/[^@\s]+@/g,"https://***@").slice(0,800); throw new Error(text || "Git command failed."); } }
|
|
24
|
+
private async scan(base: string, repo: string, commit: string) { const found: Array<{name:string;relativePath:string;description:string|null;contentHash:string}> = []; const visit=async (dir:string):Promise<void> => { for (const entry of await readdir(dir,{withFileTypes:true})) { if (!entry.isDirectory() || entry.name === ".git") continue; const folder=join(dir,entry.name), file=join(folder,"SKILL.md"); if (existsSync(file)) { const content=await readFile(file,"utf8"), front=content.match(/^---\r?\n([\s\S]*?)\r?\n---/), name=front?.[1].match(/^name:\s*["']?([^\n"']+)/m)?.[1].trim() || relative(base,folder).replaceAll("\\","/"), description=front?.[1].match(/^description:\s*["']?([^\n"']+)/m)?.[1].trim() || null; found.push({name,relativePath:relative(repo,folder).replaceAll("\\","/"),description,contentHash:createHash("sha256").update(content).digest("hex")}); } else await visit(folder); } }; await visit(base); return found; }
|
|
25
|
+
async sync(identifier: string) { const source=this.get(identifier); if (!source) throw Object.assign(new Error("Skill Source not found."), { status:404 }); if (!source.enabled) throw Object.assign(new Error("Skill Source is disabled."), { status:422 }); const started=now(), run=randomUUID(), repo=join(this.root,".git-cache",identifier), destination=join(this.root,identifier), env:{[key:string]:string|undefined}={...process.env,GIT_TERMINAL_PROMPT:"0"}; let askpass: string | undefined; const tokenRow=this.db.prepare("SELECT credential_token FROM skill_sources WHERE identifier=?").get(identifier) as Row | undefined; const token=tokenRow?.credential_token == null ? undefined : String(tokenRow.credential_token); if (token) { askpass=join(this.root,`.git-askpass-${run}`); await writeFile(askpass,"#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$PI_SKILL_SOURCE_GIT_USERNAME\" ;; *Password*) printf '%s\\n' \"$PI_SKILL_SOURCE_GIT_TOKEN\" ;; esac\n",{mode:0o700}); Object.assign(env,{GIT_ASKPASS:askpass,PI_SKILL_SOURCE_GIT_USERNAME:source.username || "x-access-token",PI_SKILL_SOURCE_GIT_TOKEN:token}); }
|
|
26
|
+
this.db.prepare("INSERT INTO skill_source_sync_runs VALUES (?,?,?,?,?,?,?,?,?,?)").run(run,identifier,"running",null,0,0,0,null,started,null); let commit:string|null=null, items:Awaited<ReturnType<SkillSourceStore["scan"]>>=[]; let error:string|null=null; try { if (!existsSync(join(repo,".git"))) { await rm(repo,{recursive:true,force:true}); await mkdir(dirname(repo),{recursive:true}); await this.git(["clone","--depth","1","--branch",source.branch,source.repoUrl,repo],this.root,env); } else { await this.git(["remote","set-url","origin",source.repoUrl],repo,env); await this.git(["fetch","--depth","1","--prune","origin",source.branch],repo,env); await this.git(["checkout","--force","-B",source.branch,`origin/${source.branch}`],repo,env); await this.git(["clean","-ffd"],repo,env); } commit=await this.git(["rev-parse","HEAD"],repo,env); const base=resolve(repo,source.basePath); if (!base.startsWith(`${resolve(repo)}/`) || !existsSync(base) || !(await stat(base)).isDirectory()) throw new Error(`Skill base path was not found: ${source.basePath}`); await rm(destination,{recursive:true,force:true}); await cp(base,destination,{recursive:true,filter:(path)=>!path.split("/").includes(".git")}); items=await this.scan(destination,destination,commit); const stamp=now(); this.db.prepare("UPDATE skill_source_skills SET status='missing' WHERE source_identifier=?").run(identifier); for (const item of items) this.db.prepare("INSERT INTO skill_source_skills VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(source_identifier,relative_path) DO UPDATE SET skill_name=excluded.skill_name,description=excluded.description,content_hash=excluded.content_hash,commit_hash=excluded.commit_hash,status='synced',seen_at=excluded.seen_at").run(identifier,item.name,item.relativePath,item.description,item.contentHash,commit,"synced",stamp); } catch (cause) { error=(cause as Error).message; }
|
|
27
|
+
if (askpass) await rm(askpass,{force:true}); const status=error ? (items.length ? "partial_failure" : "failed") : "succeeded", finished=now(); this.db.prepare("UPDATE skill_sources SET last_commit_hash=?,last_synced_at=?,sync_status=?,last_error=?,updated_at=? WHERE identifier=?").run(commit,finished,status,error,finished,identifier); this.db.prepare("UPDATE skill_source_sync_runs SET status=?,commit_hash=?,discovered_count=?,valid_count=?,invalid_count=?,error_summary=?,finished_at=? WHERE id=?").run(status,commit,items.length,items.length,0,error,finished,run); return { source:this.get(identifier)!, status, commitHash:commit, discoveredCount:items.length, validCount:items.length, invalidCount:0, error }; }
|
|
28
|
+
private async validateSkillDirectory(directory: string): Promise<void> {
|
|
29
|
+
const root = resolve(directory);
|
|
30
|
+
const visit = async (path: string): Promise<void> => {
|
|
31
|
+
const entry = await lstat(path);
|
|
32
|
+
if (entry.isSymbolicLink()) throw Object.assign(new Error("Skills containing symbolic links cannot be published."), { status: 400 });
|
|
33
|
+
if (!entry.isDirectory()) return;
|
|
34
|
+
for (const child of await readdir(path)) await visit(join(path, child));
|
|
35
|
+
};
|
|
36
|
+
await visit(root);
|
|
37
|
+
if (!existsSync(join(root, "SKILL.md"))) throw Object.assign(new Error("The skill directory does not contain SKILL.md."), { status: 400 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private installation(profile: string, skillName: string) {
|
|
41
|
+
return this.db.prepare("SELECT source_identifier,source_relative_path,target_path FROM skill_source_installations WHERE profile=? AND skill_name=?").get(profile, skillName) as Row | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async publish(identifier: string, profile: string, skillName: string, skillPath: string) {
|
|
45
|
+
const previous = this.publishing.get(identifier) ?? Promise.resolve();
|
|
46
|
+
let release!: () => void;
|
|
47
|
+
const queued = new Promise<void>((resolveRun) => { release = resolveRun; });
|
|
48
|
+
const chain = previous.then(() => queued);
|
|
49
|
+
this.publishing.set(identifier, chain);
|
|
50
|
+
await previous;
|
|
51
|
+
try {
|
|
52
|
+
const source = this.get(identifier);
|
|
53
|
+
if (!source) throw Object.assign(new Error("Skill Source not found."), { status: 404 });
|
|
54
|
+
if (!source.enabled) throw Object.assign(new Error("Skill Source is disabled."), { status: 422 });
|
|
55
|
+
if (!source.hasAccessToken) throw Object.assign(new Error("This Skill Source has no write credential."), { status: 422 });
|
|
56
|
+
const local = resolve(skillPath);
|
|
57
|
+
await this.validateSkillDirectory(local);
|
|
58
|
+
const installation = this.installation(profile, skillName);
|
|
59
|
+
if (installation && String(installation.source_identifier) !== identifier) throw Object.assign(new Error("This skill was imported from a different Skill Source. Publish it there or create a new local skill."), { status: 409 });
|
|
60
|
+
const targetRelative = installation ? String(installation.source_relative_path) : join(source.basePath, skillName).replaceAll("\\", "/");
|
|
61
|
+
if (targetRelative.split("/").includes("..") || targetRelative.startsWith("/")) throw Object.assign(new Error("Invalid skill destination."), { status: 400 });
|
|
62
|
+
const sameName = this.skills(identifier).filter((item) => item.name === skillName && item.relativePath !== targetRelative && item.status === "synced");
|
|
63
|
+
if (sameName.length) throw Object.assign(new Error(`A different skill named '${skillName}' already exists in this Skill Source. Publish by its original path or rename the local skill.`), { status: 409 });
|
|
64
|
+
const refreshed = await this.sync(identifier);
|
|
65
|
+
if (refreshed.status !== "succeeded") throw Object.assign(new Error(refreshed.error ?? "Unable to synchronize the Skill Source before publishing."), { status: 502 });
|
|
66
|
+
const repository = resolve(this.root, ".git-cache", identifier), target = resolve(repository, targetRelative);
|
|
67
|
+
if (!target.startsWith(`${repository}/`)) throw Object.assign(new Error("Invalid skill destination."), { status: 400 });
|
|
68
|
+
await rm(target, { recursive: true, force: true });
|
|
69
|
+
await mkdir(dirname(target), { recursive: true });
|
|
70
|
+
await cp(local, target, { recursive: true, filter: (path) => !path.split("/").includes("node_modules") && !path.split("/").includes(".git") });
|
|
71
|
+
const token = String((this.db.prepare("SELECT credential_token FROM skill_sources WHERE identifier=?").get(identifier) as Row).credential_token);
|
|
72
|
+
const askpass = join(this.root, `.git-askpass-publish-${randomUUID()}`), env: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: askpass, PI_SKILL_SOURCE_GIT_USERNAME: source.username || "x-access-token", PI_SKILL_SOURCE_GIT_TOKEN: token };
|
|
73
|
+
await writeFile(askpass, "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$PI_SKILL_SOURCE_GIT_USERNAME\" ;; *Password*) printf '%s\\n' \"$PI_SKILL_SOURCE_GIT_TOKEN\" ;; esac\n", { mode: 0o700 });
|
|
74
|
+
try {
|
|
75
|
+
await this.git(["add", "--", targetRelative], repository, env);
|
|
76
|
+
const status = await this.git(["status", "--porcelain", "--", targetRelative], repository, env);
|
|
77
|
+
if (!status) return { action: "unchanged" as const, source: identifier, relativePath: targetRelative, commitHash: refreshed.commitHash };
|
|
78
|
+
await this.git(["commit", "-m", `chore(skills): update ${skillName}`], repository, env);
|
|
79
|
+
await this.git(["push", "origin", source.branch], repository, env);
|
|
80
|
+
} finally { await rm(askpass, { force: true }); }
|
|
81
|
+
const synced = await this.sync(identifier);
|
|
82
|
+
if (synced.status !== "succeeded") throw Object.assign(new Error(synced.error ?? "The Skill Source was pushed but could not be synchronized."), { status: 502 });
|
|
83
|
+
const published = this.skills(identifier).find((item) => item.relativePath === targetRelative);
|
|
84
|
+
if (published) this.db.prepare("INSERT INTO skill_source_installations VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(profile,skill_name) DO UPDATE SET source_identifier=excluded.source_identifier,source_relative_path=excluded.source_relative_path,source_commit_hash=excluded.source_commit_hash,source_content_hash=excluded.source_content_hash,target_path=excluded.target_path,installed_at=excluded.installed_at").run(profile,skillName,identifier,targetRelative,published.commitHash,published.contentHash,local,now());
|
|
85
|
+
return { action: installation ? "updated" as const : "created" as const, source: identifier, relativePath: targetRelative, commitHash: synced.commitHash };
|
|
86
|
+
} finally { release(); if (this.publishing.get(identifier) === chain) this.publishing.delete(identifier); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async syncInstallation(profile: string, skillName: string, expectedPath: string) {
|
|
90
|
+
const installation = this.installation(profile, skillName);
|
|
91
|
+
if (!installation) throw Object.assign(new Error("This Skill was not installed from a Skill Source."), { status: 404 });
|
|
92
|
+
const identifier = String(installation.source_identifier), relativePath = String(installation.source_relative_path), target = resolve(String(installation.target_path));
|
|
93
|
+
if (target !== resolve(expectedPath)) throw Object.assign(new Error("The installed Skill path no longer matches its source record."), { status: 409 });
|
|
94
|
+
const result = await this.sync(identifier);
|
|
95
|
+
if (result.status !== "succeeded") throw Object.assign(new Error(result.error ?? "Unable to synchronize the Skill Source."), { status: 502 });
|
|
96
|
+
const skill = this.skills(identifier).find((item) => item.relativePath === relativePath && item.status === "synced");
|
|
97
|
+
if (!skill) throw Object.assign(new Error("This Skill no longer exists in its Skill Source."), { status: 404 });
|
|
98
|
+
const sourceRoot = resolve(this.root, identifier), source = resolve(sourceRoot, relativePath);
|
|
99
|
+
if (!source.startsWith(`${sourceRoot}/`) || !existsSync(join(source, "SKILL.md"))) throw Object.assign(new Error("The synchronized Skill files are unavailable."), { status: 404 });
|
|
100
|
+
const temporary = `${target}.sync-${randomUUID()}`, backup = `${target}.backup-${randomUUID()}`;
|
|
101
|
+
try {
|
|
102
|
+
await rm(temporary, { recursive: true, force: true });
|
|
103
|
+
await cp(source, temporary, { recursive: true, filter: (path) => !path.split("/").includes(".git") && !path.split("/").includes("node_modules") });
|
|
104
|
+
if (existsSync(target)) await rename(target, backup);
|
|
105
|
+
await rename(temporary, target);
|
|
106
|
+
await rm(backup, { recursive: true, force: true });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
await rm(temporary, { recursive: true, force: true });
|
|
109
|
+
if (!existsSync(target) && existsSync(backup)) await rename(backup, target);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
this.db.prepare("UPDATE skill_source_installations SET source_commit_hash=?,source_content_hash=?,target_path=?,installed_at=? WHERE profile=? AND skill_name=?").run(skill.commitHash, skill.contentHash, target, now(), profile, skillName);
|
|
113
|
+
return { source: identifier, relativePath, commitHash: skill.commitHash };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
forgetInstallation(profile: string, skillName: string) { this.db.prepare("DELETE FROM skill_source_installations WHERE profile=? AND skill_name=?").run(profile, skillName); }
|
|
117
|
+
async install(identifier:string, skillName:string, destination:string, profile:string) { const skill=this.skills(identifier).find(item=>item.name===skillName && item.status==="synced"); if (!skill) throw Object.assign(new Error("Skill was not found in the synchronized source."),{status:404}); const source=resolve(this.root,identifier,skill.relativePath), target=resolve(destination,skillName); if (!source.startsWith(`${resolve(this.root,identifier)}/`) || !target.startsWith(`${resolve(destination)}/`)) throw Object.assign(new Error("Invalid skill path."),{status:400}); if (existsSync(target)) throw Object.assign(new Error("A skill with this name already exists at the destination."),{status:409}); await mkdir(destination,{recursive:true}); await cp(source,target,{recursive:true,filter:(path)=>!path.split("/").includes(".git") && !path.split("/").includes("node_modules")}); this.db.prepare("INSERT INTO skill_source_installations VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(profile,skill_name) DO UPDATE SET source_identifier=excluded.source_identifier,source_relative_path=excluded.source_relative_path,source_commit_hash=excluded.source_commit_hash,source_content_hash=excluded.source_content_hash,target_path=excluded.target_path,installed_at=excluded.installed_at").run(profile,skill.name,identifier,skill.relativePath,skill.commitHash,skill.contentHash,target,now()); return skill; }
|
|
118
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-feats",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A modular Pi package for profiles, sandboxing, remote access, applications, guardrails, skills, session management, observability, and web-based operations.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-coding-agent",
|
|
7
|
+
"pi-agent",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"ai-agent",
|
|
10
|
+
"agent-framework",
|
|
11
|
+
"agentic-ai",
|
|
12
|
+
"sandbox",
|
|
13
|
+
"nono",
|
|
14
|
+
"guardrails",
|
|
15
|
+
"webui",
|
|
16
|
+
"agent-operations"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/rjaskonis/pi-feats#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/rjaskonis/pi-feats.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/rjaskonis/pi-feats/issues"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"extensions",
|
|
28
|
+
"scripts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build:web": "cd extensions/pi-console-webui && ../../node_modules/.bin/next build",
|
|
38
|
+
"ensure:nono": "sh scripts/install-nono.sh",
|
|
39
|
+
"postinstall": "npm run ensure:nono && npm run build:web",
|
|
40
|
+
"test": "npm run build:web"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@codemirror/lang-javascript": "^6.2.5",
|
|
44
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
45
|
+
"@codemirror/lang-markdown": "^6.5.2",
|
|
46
|
+
"@fastify/websocket": "^11.3.0",
|
|
47
|
+
"@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
|
|
48
|
+
"@radix-ui/react-slot": "^1.2.4",
|
|
49
|
+
"@radix-ui/react-switch": "^1.3.7",
|
|
50
|
+
"@radix-ui/react-tabs": "^1.1.21",
|
|
51
|
+
"@types/node": "^22.0.0",
|
|
52
|
+
"@types/react": "^19.0.0",
|
|
53
|
+
"@types/react-dom": "^19.0.0",
|
|
54
|
+
"@uiw/react-codemirror": "^4.25.11",
|
|
55
|
+
"@uiw/react-md-editor": "^4.1.2",
|
|
56
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
57
|
+
"@xterm/xterm": "^6.0.0",
|
|
58
|
+
"autoprefixer": "^10.4.20",
|
|
59
|
+
"class-variance-authority": "^0.7.1",
|
|
60
|
+
"clsx": "^2.1.1",
|
|
61
|
+
"fastify": "^5.12.1",
|
|
62
|
+
"ink": "^7.1.1",
|
|
63
|
+
"jiti": "^2.7.0",
|
|
64
|
+
"lucide-react": "^0.468.0",
|
|
65
|
+
"next": "^15.2.0",
|
|
66
|
+
"react": "^19.2.8",
|
|
67
|
+
"react-dom": "^19.2.8",
|
|
68
|
+
"react-markdown": "^10.1.0",
|
|
69
|
+
"remark-gfm": "^4.0.1",
|
|
70
|
+
"tailwind-merge": "^3.0.2",
|
|
71
|
+
"tailwindcss": "^3.4.17",
|
|
72
|
+
"typescript": "^5.7.2"
|
|
73
|
+
},
|
|
74
|
+
"peerDependencies": {
|
|
75
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
76
|
+
},
|
|
77
|
+
"pi": {
|
|
78
|
+
"extensions": [
|
|
79
|
+
"./extensions/api-server",
|
|
80
|
+
"./extensions/cli-resources.ts",
|
|
81
|
+
"./extensions/guardrails",
|
|
82
|
+
"./extensions/pi-console-webui",
|
|
83
|
+
"./extensions/profiles.ts",
|
|
84
|
+
"./extensions/pulse",
|
|
85
|
+
"./extensions/sequential-workflow.ts",
|
|
86
|
+
"./extensions/skill-sources"
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
set -eu
|
|
3
|
+
|
|
4
|
+
if command -v nono >/dev/null 2>&1; then
|
|
5
|
+
printf '%s\n' "Nono is already available: $(command -v nono)"
|
|
6
|
+
exit 0
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
if ! command -v curl >/dev/null 2>&1; then
|
|
10
|
+
printf '%s\n' "Nono is required by pi-feats but curl is not available to install it." >&2
|
|
11
|
+
printf '%s\n' "Install curl and rerun npm install, or install Nono manually from https://nono.sh/." >&2
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
temporary="$(mktemp "${TMPDIR:-/tmp}/pi-feats-nono.XXXXXX")"
|
|
16
|
+
cleanup() { rm -f "$temporary"; }
|
|
17
|
+
trap cleanup EXIT HUP INT TERM
|
|
18
|
+
|
|
19
|
+
printf '%s\n' "Installing Nono for sandboxed Pi Profiles..."
|
|
20
|
+
curl --fail --show-error --silent --location --proto '=https' --tlsv1.2 https://nono.sh/install.sh --output "$temporary"
|
|
21
|
+
sh "$temporary"
|
|
22
|
+
|
|
23
|
+
if command -v nono >/dev/null 2>&1; then
|
|
24
|
+
printf '%s\n' "Installed Nono: $(command -v nono)"
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [ -x "$HOME/.local/bin/nono" ]; then
|
|
29
|
+
printf '%s\n' "Nono was installed at $HOME/.local/bin/nono. Add $HOME/.local/bin to PATH before running Pi." >&2
|
|
30
|
+
exit 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
printf '%s\n' "Nono installation finished but the nono executable was not found." >&2
|
|
34
|
+
exit 1
|