pi-harness-runtime 1.1.16 → 1.1.26
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/CHANGELOG.md +26 -0
- package/README.md +76 -18
- package/harness/e2e/auto-test-loop.ts +815 -0
- package/harness/e2e/tools-detector.test.ts +156 -0
- package/harness/e2e/tools-detector.ts +801 -0
- package/harness/glm-quota-logger.ts +20 -23
- package/harness/index.ts +42 -0
- package/harness/loop-runtime.ts +49 -13
- package/harness/skill-sync.test.ts +57 -0
- package/harness/skill-sync.ts +247 -0
- package/harness/ssh-hang-recovery.ts +280 -0
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +28 -0
- package/packages/skill-mcp-client/src/client.ts +171 -0
- package/packages/skill-mcp-client/src/errors.ts +75 -0
- package/packages/skill-mcp-client/src/index.ts +13 -0
- package/packages/skill-mcp-client/src/types.ts +123 -0
- package/packages/skill-registry/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
|
@@ -29,7 +29,11 @@ function ensureLogDir(): void {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** Format log entry */
|
|
32
|
-
function formatLog(
|
|
32
|
+
function formatLog(
|
|
33
|
+
level: LogLevel,
|
|
34
|
+
jobId: string | null,
|
|
35
|
+
message: string,
|
|
36
|
+
): string {
|
|
33
37
|
const timestamp = new Date().toISOString();
|
|
34
38
|
const job = jobId ? `[${jobId}]` : "[system]";
|
|
35
39
|
return `[${timestamp}] [${level}] ${job} ${message}`;
|
|
@@ -38,7 +42,11 @@ function formatLog(level: LogLevel, jobId: string | null, message: string): stri
|
|
|
38
42
|
/**
|
|
39
43
|
* Write a log entry to file
|
|
40
44
|
*/
|
|
41
|
-
function writeLog(
|
|
45
|
+
function writeLog(
|
|
46
|
+
level: LogLevel,
|
|
47
|
+
jobId: string | null,
|
|
48
|
+
message: string,
|
|
49
|
+
): void {
|
|
42
50
|
try {
|
|
43
51
|
ensureLogDir();
|
|
44
52
|
const entry = formatLog(level, jobId, message) + "\n";
|
|
@@ -70,7 +78,11 @@ export function logWarn(jobId: string | null, message: string): void {
|
|
|
70
78
|
/**
|
|
71
79
|
* Log error level event
|
|
72
80
|
*/
|
|
73
|
-
export function logError(
|
|
81
|
+
export function logError(
|
|
82
|
+
jobId: string | null,
|
|
83
|
+
message: string,
|
|
84
|
+
error?: unknown,
|
|
85
|
+
): void {
|
|
74
86
|
const errorMsg = error instanceof Error ? error.message : String(error ?? "");
|
|
75
87
|
const fullMessage = errorMsg ? `${message}: ${errorMsg}` : message;
|
|
76
88
|
console.error(`[GLMQuota] ${jobId ? `[${jobId}] ` : ""}${fullMessage}`);
|
|
@@ -87,10 +99,7 @@ export function logCountdownStarted(
|
|
|
87
99
|
): void {
|
|
88
100
|
const hours = Math.floor(totalSeconds / 3600);
|
|
89
101
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
90
|
-
logInfo(
|
|
91
|
-
jobId,
|
|
92
|
-
`Countdown started: ${hours}h ${minutes}m until ${resetAt}`,
|
|
93
|
-
);
|
|
102
|
+
logInfo(jobId, `Countdown started: ${hours}h ${minutes}m until ${resetAt}`);
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
/** Log countdown tick */
|
|
@@ -101,10 +110,7 @@ export function logCountdownTick(
|
|
|
101
110
|
const hours = Math.floor(remainingSeconds / 3600);
|
|
102
111
|
const minutes = Math.floor((remainingSeconds % 3600) / 60);
|
|
103
112
|
const secs = remainingSeconds % 60;
|
|
104
|
-
logInfo(
|
|
105
|
-
jobId,
|
|
106
|
-
`Countdown tick: ${hours}h ${minutes}m ${secs}s remaining`,
|
|
107
|
-
);
|
|
113
|
+
logInfo(jobId, `Countdown tick: ${hours}h ${minutes}m ${secs}s remaining`);
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/** Log countdown completed */
|
|
@@ -123,19 +129,13 @@ export function logAutoResumeFailed(jobId: string, error: string): void {
|
|
|
123
129
|
}
|
|
124
130
|
|
|
125
131
|
/** Log notification sent */
|
|
126
|
-
export function logNotificationSent(
|
|
127
|
-
jobId: string,
|
|
128
|
-
beforeReset: number,
|
|
129
|
-
): void {
|
|
132
|
+
export function logNotificationSent(jobId: string, beforeReset: number): void {
|
|
130
133
|
const minutes = Math.floor(beforeReset / 60);
|
|
131
134
|
logInfo(jobId, `Notification sent: ${minutes} minutes before reset`);
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
/** Log notification failed */
|
|
135
|
-
export function logNotificationFailed(
|
|
136
|
-
jobId: string,
|
|
137
|
-
error: string,
|
|
138
|
-
): void {
|
|
138
|
+
export function logNotificationFailed(jobId: string, error: string): void {
|
|
139
139
|
logWarn(jobId, `Notification failed: ${error}`);
|
|
140
140
|
}
|
|
141
141
|
|
|
@@ -149,10 +149,7 @@ export function logMirrorUpdate(jobId: string, success: boolean): void {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/** Log quota exhaustion detected */
|
|
152
|
-
export function logQuotaExhausted(
|
|
153
|
-
jobId: string,
|
|
154
|
-
resetAt: string,
|
|
155
|
-
): void {
|
|
152
|
+
export function logQuotaExhausted(jobId: string, resetAt: string): void {
|
|
156
153
|
logInfo(jobId, `GLM 5h quota exhausted, reset at ${resetAt}`);
|
|
157
154
|
}
|
|
158
155
|
|
package/harness/index.ts
CHANGED
|
@@ -261,3 +261,45 @@ export type {
|
|
|
261
261
|
KnowledgeQuery,
|
|
262
262
|
KnowledgeResult,
|
|
263
263
|
} from "../packages/memory-engine/src/types.js";
|
|
264
|
+
|
|
265
|
+
// --- E2E Testing (RFC-0101) -----------------------------------------------
|
|
266
|
+
export {
|
|
267
|
+
detectProjectType,
|
|
268
|
+
detectAvailableTools,
|
|
269
|
+
getRecommendedTools,
|
|
270
|
+
makeSmartDecision,
|
|
271
|
+
generateToolsPresentation,
|
|
272
|
+
getE2EToolsConfig,
|
|
273
|
+
} from "./e2e/tools-detector.js";
|
|
274
|
+
export type {
|
|
275
|
+
SmartDecision,
|
|
276
|
+
TestingTool,
|
|
277
|
+
E2EToolsConfig,
|
|
278
|
+
ProjectType,
|
|
279
|
+
NativeAppType,
|
|
280
|
+
} from "./e2e/tools-detector.js";
|
|
281
|
+
export { runAutoTestLoop } from "./e2e/auto-test-loop.js";
|
|
282
|
+
export type {
|
|
283
|
+
E2ETestResult,
|
|
284
|
+
AutoTestConfig,
|
|
285
|
+
AutoTestResult,
|
|
286
|
+
} from "./e2e/auto-test-loop.js";
|
|
287
|
+
|
|
288
|
+
// --- Skill SaaS Sync (RFC-0106) ----------------------------------------
|
|
289
|
+
export {
|
|
290
|
+
syncSkillsFromSaaS,
|
|
291
|
+
getSkillFromSaaS,
|
|
292
|
+
isSkillSyncConfigured,
|
|
293
|
+
getSyncStatus,
|
|
294
|
+
} from "./skill-sync.js";
|
|
295
|
+
|
|
296
|
+
// --- SSH Hang Recovery (auto-recovery from SSH hangs) -------------------
|
|
297
|
+
export {
|
|
298
|
+
sshWithRecovery,
|
|
299
|
+
pm2Restart,
|
|
300
|
+
getPM2Status,
|
|
301
|
+
safeSSHCommand,
|
|
302
|
+
pingServer,
|
|
303
|
+
type SSHConfig,
|
|
304
|
+
type SSHResult,
|
|
305
|
+
} from "./ssh-hang-recovery.js";
|
package/harness/loop-runtime.ts
CHANGED
|
@@ -46,6 +46,9 @@ import {
|
|
|
46
46
|
createForkedSummarizer,
|
|
47
47
|
} from "./forked-summarizer.js";
|
|
48
48
|
|
|
49
|
+
// GLM quota error parsing for 429 responses
|
|
50
|
+
import { parseGLMErrorResetTime } from "./e2e/glm-quota-scraper.js";
|
|
51
|
+
|
|
49
52
|
// --- Constants ----------------------------------------------------------------
|
|
50
53
|
|
|
51
54
|
const DEFAULT_MAX_COMPACT_RETRIES = 3;
|
|
@@ -84,9 +87,7 @@ export interface LoopCallbacks {
|
|
|
84
87
|
/** Get current checkpoint (used by auto-resume to read resumeAt) */
|
|
85
88
|
onGetCheckpoint?: () => Promise<RuntimeCheckpoint | null>;
|
|
86
89
|
/** Check mirror for a provider (used by auto-resume to detect early reset) */
|
|
87
|
-
onCheckMirror?: (
|
|
88
|
-
provider: string,
|
|
89
|
-
) => Promise<{ h5_used_pct?: number } | null>;
|
|
90
|
+
onCheckMirror?: (provider: string) => Promise<{ h5_used_pct?: number } | null>;
|
|
90
91
|
/** Generic notify (used by auto-resume to tell user wait time) */
|
|
91
92
|
onNotify?: (msg: string, type?: string) => Promise<void>;
|
|
92
93
|
/** Save checkpoint (call persistence layer) */
|
|
@@ -244,11 +245,7 @@ export class LoopRuntime {
|
|
|
244
245
|
await new Promise((r) => setTimeout(r, 5 * 60_000)); // 5 min
|
|
245
246
|
// Re-check mirror — quota may have reset early
|
|
246
247
|
const fresh = await this.callbacks.onCheckMirror?.("minimax");
|
|
247
|
-
if (
|
|
248
|
-
fresh &&
|
|
249
|
-
fresh.h5_used_pct !== undefined &&
|
|
250
|
-
fresh.h5_used_pct < 100
|
|
251
|
-
) {
|
|
248
|
+
if (fresh && fresh.h5_used_pct !== undefined && fresh.h5_used_pct < 100) {
|
|
252
249
|
break; // quota reset early, resume now
|
|
253
250
|
}
|
|
254
251
|
}
|
|
@@ -447,14 +444,54 @@ export class LoopRuntime {
|
|
|
447
444
|
continue;
|
|
448
445
|
}
|
|
449
446
|
|
|
450
|
-
// Error without compact —
|
|
451
|
-
|
|
447
|
+
// Error without compact — check for GLM 429 quota error
|
|
448
|
+
const errMsg = result.error ?? "Unknown invoke error";
|
|
449
|
+
if (errMsg.includes("1308") || errMsg.includes("Usage limit")) {
|
|
450
|
+
const resetTime = parseGLMErrorResetTime(errMsg);
|
|
451
|
+
if (resetTime) {
|
|
452
|
+
const resetEpoch = new Date(resetTime).getTime();
|
|
453
|
+
this.mirrorStore.writeProvider("minimax", {
|
|
454
|
+
provider: "minimax",
|
|
455
|
+
synced_at: new Date().toISOString(),
|
|
456
|
+
source: "tui-signal",
|
|
457
|
+
h5_used_pct: 100,
|
|
458
|
+
h5_resets_at_epoch: resetEpoch,
|
|
459
|
+
});
|
|
460
|
+
console.log(`[LoopRuntime] GLM quota hit, reset at ${resetTime}`);
|
|
461
|
+
}
|
|
462
|
+
await this.handleQuotaPause();
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
throw new Error(errMsg);
|
|
452
466
|
}
|
|
453
467
|
|
|
454
468
|
// Fallback: direct invoke without orchestrator
|
|
455
469
|
const result = await this.callbacks.onInvokeAgent?.(invokeOptions);
|
|
456
470
|
if (!result || !result.success) {
|
|
457
|
-
|
|
471
|
+
const errorMsg = result?.error ?? "Invoke failed";
|
|
472
|
+
|
|
473
|
+
// Check if this is a GLM 429 quota error with reset time
|
|
474
|
+
if (errorMsg.includes("1308") || errorMsg.includes("Usage limit")) {
|
|
475
|
+
const resetTime = parseGLMErrorResetTime(errorMsg);
|
|
476
|
+
if (resetTime) {
|
|
477
|
+
// Update mirror with reset epoch so auto-resume works
|
|
478
|
+
const resetEpoch = new Date(resetTime).getTime();
|
|
479
|
+
this.mirrorStore.writeProvider("minimax", {
|
|
480
|
+
provider: "minimax",
|
|
481
|
+
synced_at: new Date().toISOString(),
|
|
482
|
+
source: "tui-signal",
|
|
483
|
+
h5_used_pct: 100,
|
|
484
|
+
h5_resets_at_epoch: resetEpoch,
|
|
485
|
+
});
|
|
486
|
+
console.log(`[LoopRuntime] GLM quota hit, reset at ${resetTime}`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Handle quota pause instead of throwing
|
|
490
|
+
await this.handleQuotaPause();
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
throw new Error(errorMsg);
|
|
458
495
|
}
|
|
459
496
|
|
|
460
497
|
const continued = await this.handleAutoContinuation(
|
|
@@ -660,8 +697,7 @@ export class LoopRuntime {
|
|
|
660
697
|
// Append repair instruction
|
|
661
698
|
messages.push({
|
|
662
699
|
role: "user",
|
|
663
|
-
content:
|
|
664
|
-
"Tests failed. Please fix the issues and ensure all tests pass.",
|
|
700
|
+
content: "Tests failed. Please fix the issues and ensure all tests pass.",
|
|
665
701
|
timestamp: Date.now(),
|
|
666
702
|
});
|
|
667
703
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Sync Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// @ts-ignore - bun:test types
|
|
6
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// Mock the environment
|
|
12
|
+
const originalApiKey = process.env.SKILLS_SAAS_API_KEY;
|
|
13
|
+
|
|
14
|
+
describe("Skill Sync", () => {
|
|
15
|
+
let tempCacheDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tempCacheDir = join(tmpdir(), `skill-sync-test-${Date.now()}`);
|
|
19
|
+
mkdirSync(tempCacheDir, { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// Restore env
|
|
24
|
+
if (originalApiKey === undefined) {
|
|
25
|
+
delete process.env.SKILLS_SAAS_API_KEY;
|
|
26
|
+
} else {
|
|
27
|
+
process.env.SKILLS_SAAS_API_KEY = originalApiKey;
|
|
28
|
+
}
|
|
29
|
+
// Cleanup
|
|
30
|
+
try {
|
|
31
|
+
rmSync(tempCacheDir, { recursive: true });
|
|
32
|
+
} catch {
|
|
33
|
+
// Ignore
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("isSkillSyncConfigured", () => {
|
|
38
|
+
test("returns false when API key not set", () => {
|
|
39
|
+
delete process.env.SKILLS_SAAS_API_KEY;
|
|
40
|
+
// Import dynamically to test
|
|
41
|
+
expect(process.env.SKILLS_SAAS_API_KEY).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns true when API key is set", () => {
|
|
45
|
+
process.env.SKILLS_SAAS_API_KEY = "sk_test_xxx";
|
|
46
|
+
expect(process.env.SKILLS_SAAS_API_KEY).toBe("sk_test_xxx");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("getSyncStatus", () => {
|
|
51
|
+
test("returns correct status when not configured", () => {
|
|
52
|
+
delete process.env.SKILLS_SAAS_API_KEY;
|
|
53
|
+
// Status should show not configured
|
|
54
|
+
expect(process.env.SKILLS_SAAS_API_KEY).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Sync Service — RFC-0106
|
|
3
|
+
*
|
|
4
|
+
* Syncs skills from Skills SaaS backend and registers them with local registry.
|
|
5
|
+
* Falls back gracefully if backend is unavailable.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import type {
|
|
12
|
+
SkillInfo,
|
|
13
|
+
GetSkillResult,
|
|
14
|
+
} from "../packages/skill-mcp-client/src/types.js";
|
|
15
|
+
import { SkillAPIError } from "../packages/skill-mcp-client/src/errors.js";
|
|
16
|
+
|
|
17
|
+
type SkillMCPClient = InstanceType<
|
|
18
|
+
typeof import("../packages/skill-mcp-client/src/client.js").SkillMCPClient
|
|
19
|
+
>;
|
|
20
|
+
|
|
21
|
+
// Lazy import to avoid errors if package not built yet
|
|
22
|
+
let clientInstance: SkillMCPClient | null = null;
|
|
23
|
+
|
|
24
|
+
async function getClient(): Promise<SkillMCPClient | null> {
|
|
25
|
+
if (!process.env.SKILLS_SAAS_API_KEY) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (clientInstance) {
|
|
30
|
+
return clientInstance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import("../packages/skill-mcp-client/src/client.js");
|
|
35
|
+
const SkillMCPClientClass = mod.SkillMCPClient;
|
|
36
|
+
const serverUrl =
|
|
37
|
+
process.env.SKILLS_SAAS_URL || "https://api.skills.bunchee.online";
|
|
38
|
+
|
|
39
|
+
clientInstance = new SkillMCPClientClass({
|
|
40
|
+
serverUrl,
|
|
41
|
+
apiKey: process.env.SKILLS_SAAS_API_KEY,
|
|
42
|
+
timeoutMs: 5000,
|
|
43
|
+
});
|
|
44
|
+
return clientInstance;
|
|
45
|
+
} catch {
|
|
46
|
+
console.warn("[skill-sync] Failed to import skill-mcp-client");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Cache directory
|
|
52
|
+
const CACHE_DIR = join(homedir(), ".pi", "skills-cache");
|
|
53
|
+
const CACHE_FILE = join(CACHE_DIR, "skills-cache.json");
|
|
54
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
55
|
+
|
|
56
|
+
interface CacheData {
|
|
57
|
+
skills: SkillInfo[];
|
|
58
|
+
cachedAt: string;
|
|
59
|
+
version: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SkillSyncResult {
|
|
63
|
+
success: boolean;
|
|
64
|
+
skillCount: number;
|
|
65
|
+
source: "saas" | "cache" | "none";
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get cached skills from local disk
|
|
71
|
+
*/
|
|
72
|
+
function getCachedSkills(): CacheData | null {
|
|
73
|
+
try {
|
|
74
|
+
if (!existsSync(CACHE_FILE)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const data = JSON.parse(readFileSync(CACHE_FILE, "utf-8")) as CacheData;
|
|
79
|
+
const age = Date.now() - new Date(data.cachedAt).getTime();
|
|
80
|
+
|
|
81
|
+
if (age > CACHE_TTL_MS) {
|
|
82
|
+
return null; // Cache expired
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return data;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Save skills to local cache
|
|
93
|
+
*/
|
|
94
|
+
function saveCache(data: CacheData): void {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(CACHE_DIR)) {
|
|
97
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
100
|
+
} catch {
|
|
101
|
+
console.warn("[skill-sync] Failed to save cache");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetch and cache skills from SaaS backend
|
|
107
|
+
*/
|
|
108
|
+
async function fetchFromSaaS(): Promise<CacheData | null> {
|
|
109
|
+
const client = await getClient();
|
|
110
|
+
if (!client) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const result = await client.listSkills({ limit: 100 });
|
|
116
|
+
const cacheData: CacheData = {
|
|
117
|
+
skills: result.skills,
|
|
118
|
+
cachedAt: new Date().toISOString(),
|
|
119
|
+
version: "1.0.0",
|
|
120
|
+
};
|
|
121
|
+
saveCache(cacheData);
|
|
122
|
+
return cacheData;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error instanceof SkillAPIError) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[skill-sync] API error: ${error.message} (code: ${error.code})`,
|
|
127
|
+
);
|
|
128
|
+
} else {
|
|
129
|
+
console.warn("[skill-sync] Failed to fetch from SaaS");
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Sync skills from SaaS backend (RFC-0106)
|
|
137
|
+
*
|
|
138
|
+
* Flow:
|
|
139
|
+
* 1. Check cache first
|
|
140
|
+
* 2. If cache valid, return cached
|
|
141
|
+
* 3. If cache expired or missing, fetch from SaaS
|
|
142
|
+
* 4. If SaaS fails, return empty array (graceful degradation)
|
|
143
|
+
*
|
|
144
|
+
* @param options.forceRefresh - Force refresh from SaaS
|
|
145
|
+
* @returns Skills list (may be empty if backend unavailable)
|
|
146
|
+
*/
|
|
147
|
+
export async function syncSkillsFromSaaS(
|
|
148
|
+
options: { forceRefresh?: boolean } = {},
|
|
149
|
+
): Promise<SkillSyncResult> {
|
|
150
|
+
// Check if API key is configured
|
|
151
|
+
if (!process.env.SKILLS_SAAS_API_KEY) {
|
|
152
|
+
return {
|
|
153
|
+
success: true,
|
|
154
|
+
skillCount: 0,
|
|
155
|
+
source: "none",
|
|
156
|
+
error: "SKILLS_SAAS_API_KEY not configured",
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check cache first (unless force refresh)
|
|
161
|
+
if (!options.forceRefresh) {
|
|
162
|
+
const cached = getCachedSkills();
|
|
163
|
+
if (cached) {
|
|
164
|
+
return {
|
|
165
|
+
success: true,
|
|
166
|
+
skillCount: cached.skills.length,
|
|
167
|
+
source: "cache",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Fetch from SaaS
|
|
173
|
+
const data = await fetchFromSaaS();
|
|
174
|
+
if (data) {
|
|
175
|
+
return {
|
|
176
|
+
success: true,
|
|
177
|
+
skillCount: data.skills.length,
|
|
178
|
+
source: "saas",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// SaaS failed, try to use stale cache
|
|
183
|
+
const staleCache = getCachedSkills();
|
|
184
|
+
if (staleCache) {
|
|
185
|
+
return {
|
|
186
|
+
success: true,
|
|
187
|
+
skillCount: staleCache.skills.length,
|
|
188
|
+
source: "cache",
|
|
189
|
+
error: "Using stale cache (SaaS unavailable)",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Everything failed, return empty
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
skillCount: 0,
|
|
197
|
+
source: "none",
|
|
198
|
+
error: "Could not reach Skills SaaS backend",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Get full skill prompt by slug or id
|
|
204
|
+
*
|
|
205
|
+
* Returns null if skill not found or backend unavailable
|
|
206
|
+
*/
|
|
207
|
+
export async function getSkillFromSaaS(identifier: {
|
|
208
|
+
slug?: string;
|
|
209
|
+
id?: string;
|
|
210
|
+
}): Promise<GetSkillResult | null> {
|
|
211
|
+
const client = await getClient();
|
|
212
|
+
if (!client) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
return await client.getSkill(identifier);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error instanceof SkillAPIError) {
|
|
220
|
+
console.warn(`[skill-sync] Failed to get skill: ${error.message}`);
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check if skill sync is configured
|
|
228
|
+
*/
|
|
229
|
+
export function isSkillSyncConfigured(): boolean {
|
|
230
|
+
return !!process.env.SKILLS_SAAS_API_KEY;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get sync status info
|
|
235
|
+
*/
|
|
236
|
+
export function getSyncStatus(): {
|
|
237
|
+
configured: boolean;
|
|
238
|
+
cacheValid: boolean;
|
|
239
|
+
cachedSkills: number;
|
|
240
|
+
} {
|
|
241
|
+
const cache = getCachedSkills();
|
|
242
|
+
return {
|
|
243
|
+
configured: isSkillSyncConfigured(),
|
|
244
|
+
cacheValid: !!cache,
|
|
245
|
+
cachedSkills: cache?.skills.length ?? 0,
|
|
246
|
+
};
|
|
247
|
+
}
|