pi-harness-runtime 1.1.410 → 1.1.412
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/harness/notification-settings.ts +130 -0
- package/harness/skill-remote-source.ts +157 -0
- package/harness/skill-sync.ts +74 -22
- package/harness/skills-startup.ts +202 -0
- package/harness/startup-report.ts +98 -56
- package/harness-startup-status.ts +31 -2
- package/index.ts +125 -31
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/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/autonomous-runtime/test/types.test.ts +59 -32
- 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/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/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/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cookie-sanitizer/test/sync.test.ts +7 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/dist/src/imports.js +3 -3
- package/packages/dependency-analyzer/dist/src/imports.js.map +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/dependency-analyzer/src/imports.ts +3 -3
- package/packages/dependency-analyzer/test/analyzer.test.ts +1 -0
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/durable-review/test/acceptance.test.ts +24 -2
- package/packages/durable-review/test/smoke.test.ts +85 -47
- package/packages/e2e-tester/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-bus/test/bus.test.ts +16 -4
- package/packages/event-sink/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/jev-judge/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/logging/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/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/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/qdrant-skills/dist/client.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/client.js +21 -8
- package/packages/qdrant-skills/dist/client.js.map +1 -1
- package/packages/qdrant-skills/dist/embedder.d.ts +37 -0
- package/packages/qdrant-skills/dist/embedder.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/embedder.js +65 -1
- package/packages/qdrant-skills/dist/embedder.js.map +1 -1
- package/packages/qdrant-skills/dist/index.d.ts +1 -1
- package/packages/qdrant-skills/dist/index.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/index.js +1 -1
- package/packages/qdrant-skills/dist/index.js.map +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/qdrant-skills/src/client.ts +19 -8
- package/packages/qdrant-skills/src/embedder.ts +103 -1
- package/packages/qdrant-skills/src/index.ts +5 -0
- package/packages/qdrant-skills/test/collection-info.test.ts +129 -0
- package/packages/qdrant-skills/test/embedder-batch.test.ts +184 -0
- 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/relay/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 +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/dist/index.d.ts +19 -1
- package/packages/skills/dist/index.d.ts.map +1 -1
- package/packages/skills/dist/index.js +7 -2
- package/packages/skills/dist/index.js.map +1 -1
- package/packages/skills/dist/loader.d.ts +273 -3
- package/packages/skills/dist/loader.d.ts.map +1 -1
- package/packages/skills/dist/loader.js +525 -36
- package/packages/skills/dist/loader.js.map +1 -1
- package/packages/skills/dist/remote-source.d.ts +107 -0
- package/packages/skills/dist/remote-source.d.ts.map +1 -0
- package/packages/skills/dist/remote-source.js +79 -0
- package/packages/skills/dist/remote-source.js.map +1 -0
- package/packages/skills/dist/vector-schema.d.ts +58 -0
- package/packages/skills/dist/vector-schema.d.ts.map +1 -0
- package/packages/skills/dist/vector-schema.js +55 -0
- package/packages/skills/dist/vector-schema.js.map +1 -0
- package/packages/skills/package.json +1 -1
- package/packages/skills/src/index.ts +28 -5
- package/packages/skills/src/loader.ts +688 -53
- package/packages/skills/src/remote-source.ts +148 -0
- package/packages/skills/src/vector-schema.ts +72 -0
- package/packages/skills/test/init-skills.test.ts +107 -111
- package/packages/skills/test/remote-skills.test.ts +242 -0
- package/packages/skills/test/remote-source.test.ts +248 -0
- package/packages/skills/test/vector-indexing.test.ts +205 -0
- 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/telegram/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/tooling-preference/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/two-phase-review/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/workspace-scanner/test/scanner.test.ts +4 -3
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/sync-to-qdrant.ts +79 -14
- package/packages/autonomous-runtime/test/autonomous-runtime.test.ts +0 -194
- package/packages/autonomous-runtime/test/inbox.test.ts +0 -80
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persisted notification-channel settings.
|
|
3
|
+
*
|
|
4
|
+
* The notification center keeps the runtime state of a channel (in memory and
|
|
5
|
+
* in its own SQLite table). This file is the durable record of an EXPLICIT
|
|
6
|
+
* user decision: "LINE is off".
|
|
7
|
+
*
|
|
8
|
+
* Two things need that record:
|
|
9
|
+
*
|
|
10
|
+
* - The startup summary must show "LINE: disabled" instead of a warning
|
|
11
|
+
* after the user turned the channel off. Without a durable record the
|
|
12
|
+
* summary cannot tell "disabled on purpose" from "never configured" - and
|
|
13
|
+
* it must not guess, because guessing wrong shows a warning for a channel
|
|
14
|
+
* the user deliberately turned off.
|
|
15
|
+
* - The decision must survive a restart, so the setting is written to disk
|
|
16
|
+
* when the user changes it.
|
|
17
|
+
*
|
|
18
|
+
* Plain JSON is used on purpose: it works in every runtime (Bun has no
|
|
19
|
+
* usable `node:sqlite`), it is readable by the user, and a broken file can
|
|
20
|
+
* never stop the harness from starting.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
export interface NotificationChannelSettings {
|
|
27
|
+
/** `false` = the user turned the channel off. */
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface NotificationSettings {
|
|
32
|
+
/** The shape of the file. A future change bumps the version. */
|
|
33
|
+
version: 1;
|
|
34
|
+
channels: Record<string, NotificationChannelSettings>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The directory that holds the harness runtime state. */
|
|
38
|
+
export function notificationSettingsDir(): string {
|
|
39
|
+
return join(homedir(), ".pi-harness-runtime");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The file that holds the persisted channel decisions. */
|
|
43
|
+
export function notificationSettingsPath(): string {
|
|
44
|
+
return join(notificationSettingsDir(), "notification-settings.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function defaultNotificationSettings(): NotificationSettings {
|
|
48
|
+
return { version: 1, channels: {} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A plain object - the only shape the decoder accepts. */
|
|
52
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decode one channel entry.
|
|
58
|
+
*
|
|
59
|
+
* The file is user-writable, so every value is checked before it is used.
|
|
60
|
+
* An entry that does not decode is dropped - a broken entry must not take
|
|
61
|
+
* the whole file (and with it the other channels) down.
|
|
62
|
+
*/
|
|
63
|
+
function decodeChannelSettings(value: unknown): NotificationChannelSettings | null {
|
|
64
|
+
if (!isRecord(value)) return null;
|
|
65
|
+
return { enabled: value.enabled !== false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the persisted settings.
|
|
70
|
+
*
|
|
71
|
+
* A missing, unreadable or broken file yields the defaults: the settings are
|
|
72
|
+
* a convenience, never a reason to fail a start.
|
|
73
|
+
*/
|
|
74
|
+
export function loadNotificationSettings(path: string = notificationSettingsPath()): NotificationSettings {
|
|
75
|
+
try {
|
|
76
|
+
if (!existsSync(path)) return defaultNotificationSettings();
|
|
77
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
if (!isRecord(parsed)) return defaultNotificationSettings();
|
|
79
|
+
const channels: Record<string, NotificationChannelSettings> = {};
|
|
80
|
+
const rawChannels = parsed.channels;
|
|
81
|
+
if (isRecord(rawChannels)) {
|
|
82
|
+
for (const [id, value] of Object.entries(rawChannels)) {
|
|
83
|
+
const decoded = decodeChannelSettings(value);
|
|
84
|
+
if (decoded) channels[id] = decoded;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { version: 1, channels };
|
|
88
|
+
} catch {
|
|
89
|
+
return defaultNotificationSettings();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Write the settings. Returns false when the file could not be written. */
|
|
94
|
+
export function saveNotificationSettings(
|
|
95
|
+
settings: NotificationSettings,
|
|
96
|
+
path: string = notificationSettingsPath(),
|
|
97
|
+
): boolean {
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
100
|
+
writeFileSync(path, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Did the user explicitly turn the channel off?
|
|
109
|
+
*
|
|
110
|
+
* A channel that was never mentioned has no entry - it is NOT disabled. Only
|
|
111
|
+
* an explicit decision counts, because "disabled" and "not configured" are
|
|
112
|
+
* different states with different meanings.
|
|
113
|
+
*/
|
|
114
|
+
export function isChannelDisabled(channel: string, path?: string): boolean {
|
|
115
|
+
const settings = loadNotificationSettings(path);
|
|
116
|
+
const entry = settings.channels[channel];
|
|
117
|
+
return entry ? entry.enabled === false : false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Record an explicit channel decision.
|
|
122
|
+
*
|
|
123
|
+
* Returns false when the decision could not be persisted - the caller then
|
|
124
|
+
* knows that the decision is lost on the next restart.
|
|
125
|
+
*/
|
|
126
|
+
export function setChannelDisabled(channel: string, disabled: boolean, path?: string): boolean {
|
|
127
|
+
const settings = loadNotificationSettings(path);
|
|
128
|
+
settings.channels[channel] = { enabled: !disabled };
|
|
129
|
+
return saveNotificationSettings(settings, path);
|
|
130
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill service as a source of server-held skills.
|
|
3
|
+
*
|
|
4
|
+
* The service holds the authoritative skill files. It applies the access
|
|
5
|
+
* rules of the user, so a refusal is an ANSWER of the service and not a
|
|
6
|
+
* failure of the client - the two need different fixes and must stay
|
|
7
|
+
* apart.
|
|
8
|
+
*
|
|
9
|
+
* The vector store is an index: it says WHICH skill fits a question, but
|
|
10
|
+
* its payload carries only a truncated body. The service delivers the
|
|
11
|
+
* complete skill, so a machine without a local copy can still use it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getSkillFromSaaS, syncSkillsFromSaaS } from "./skill-sync.js";
|
|
15
|
+
import type {
|
|
16
|
+
RemoteSkill,
|
|
17
|
+
RemoteSkillSource,
|
|
18
|
+
} from "../packages/skills/dist/remote-source.js";
|
|
19
|
+
|
|
20
|
+
/** Why a call to the service failed. */
|
|
21
|
+
export type SkillServiceFailure = "denied" | "unavailable";
|
|
22
|
+
|
|
23
|
+
/** The error a failed call to the service raises. */
|
|
24
|
+
export class SkillServiceError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param message The reason of the failure. Never a credential.
|
|
27
|
+
* @param code Whether the service refused the call or could not be
|
|
28
|
+
* reached. The two need different fixes.
|
|
29
|
+
*/
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
readonly code: SkillServiceFailure,
|
|
33
|
+
) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "SkillServiceError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The parts of the sync result the source needs. */
|
|
40
|
+
interface SyncOutcome {
|
|
41
|
+
source: "saas" | "cache" | "none";
|
|
42
|
+
skills?: Array<{
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
description: string;
|
|
46
|
+
version?: string;
|
|
47
|
+
updatedAt?: string;
|
|
48
|
+
}>;
|
|
49
|
+
error?: string;
|
|
50
|
+
failure?: SkillServiceFailure;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The parts of the sync module the source needs. */
|
|
54
|
+
export interface SkillServiceGateway {
|
|
55
|
+
syncSkillsFromSaaS(options?: {
|
|
56
|
+
forceRefresh?: boolean;
|
|
57
|
+
}): Promise<SyncOutcome>;
|
|
58
|
+
getSkillFromSaaS(identifier: {
|
|
59
|
+
id?: string;
|
|
60
|
+
slug?: string;
|
|
61
|
+
}): Promise<{
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
description: string;
|
|
65
|
+
version?: string;
|
|
66
|
+
updatedAt?: string;
|
|
67
|
+
prompt?: string;
|
|
68
|
+
} | null>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The service as a source of remote skills.
|
|
73
|
+
*
|
|
74
|
+
* The gateway is injected, so the source can be tested without a service
|
|
75
|
+
* and without a network.
|
|
76
|
+
*/
|
|
77
|
+
export class SkillServiceSource implements RemoteSkillSource {
|
|
78
|
+
/** @param gateway The sync module. The real one by default. */
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly gateway: SkillServiceGateway = {
|
|
81
|
+
syncSkillsFromSaaS,
|
|
82
|
+
getSkillFromSaaS,
|
|
83
|
+
},
|
|
84
|
+
) {}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The skills the service holds.
|
|
88
|
+
*
|
|
89
|
+
* A service that refuses the call raises a `SkillServiceError` with
|
|
90
|
+
* the code "denied" - the caller can tell that apart from a service
|
|
91
|
+
* that could not be reached.
|
|
92
|
+
*/
|
|
93
|
+
async list(options?: {
|
|
94
|
+
search?: string;
|
|
95
|
+
limit?: number;
|
|
96
|
+
}): Promise<RemoteSkill[]> {
|
|
97
|
+
const outcome = await this.gateway.syncSkillsFromSaaS();
|
|
98
|
+
|
|
99
|
+
if (outcome.source === "none") {
|
|
100
|
+
throw new SkillServiceError(
|
|
101
|
+
outcome.error ?? "the skill service could not be reached",
|
|
102
|
+
outcome.failure ?? "unavailable",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const skills = outcome.skills ?? [];
|
|
107
|
+
const searched = options?.search
|
|
108
|
+
? skills.filter((skill) => matches(skill, options.search as string))
|
|
109
|
+
: skills;
|
|
110
|
+
const limited = options?.limit ? searched.slice(0, options.limit) : searched;
|
|
111
|
+
|
|
112
|
+
return limited.map((skill) => ({
|
|
113
|
+
id: skill.id,
|
|
114
|
+
name: skill.name,
|
|
115
|
+
description: skill.description,
|
|
116
|
+
version: skill.version,
|
|
117
|
+
updatedAt: skill.updatedAt,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The complete content of one skill.
|
|
123
|
+
*
|
|
124
|
+
* The service returns the whole skill, so the content is complete -
|
|
125
|
+
* unlike the truncated body of a vector store payload.
|
|
126
|
+
*/
|
|
127
|
+
async get(id: string): Promise<RemoteSkill | null> {
|
|
128
|
+
const skill = await this.gateway.getSkillFromSaaS({ id });
|
|
129
|
+
if (!skill) return null;
|
|
130
|
+
return {
|
|
131
|
+
id: skill.id,
|
|
132
|
+
name: skill.name,
|
|
133
|
+
description: skill.description,
|
|
134
|
+
version: skill.version,
|
|
135
|
+
updatedAt: skill.updatedAt,
|
|
136
|
+
content: skill.prompt,
|
|
137
|
+
contentComplete: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether a skill matches a search term.
|
|
144
|
+
*
|
|
145
|
+
* The service does the real search; this filter only narrows what the
|
|
146
|
+
* service already returned.
|
|
147
|
+
*/
|
|
148
|
+
function matches(
|
|
149
|
+
skill: { name: string; description: string },
|
|
150
|
+
term: string,
|
|
151
|
+
): boolean {
|
|
152
|
+
const needle = term.toLowerCase();
|
|
153
|
+
return (
|
|
154
|
+
skill.name.toLowerCase().includes(needle) ||
|
|
155
|
+
skill.description.toLowerCase().includes(needle)
|
|
156
|
+
);
|
|
157
|
+
}
|
package/harness/skill-sync.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Falls back gracefully if backend is unavailable.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync,
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import type {
|
|
@@ -13,6 +13,8 @@ import type {
|
|
|
13
13
|
GetSkillResult,
|
|
14
14
|
} from "../packages/skill-mcp-client/src/types.js";
|
|
15
15
|
import { SkillAPIError } from "../packages/skill-mcp-client/dist/errors.js";
|
|
16
|
+
import { atomicWriteFileSync } from "../packages/cookie-sanitizer/dist/index.js";
|
|
17
|
+
import { centralWarn } from "../packages/logging/dist/index.js";
|
|
16
18
|
|
|
17
19
|
type SkillMCPClient = InstanceType<
|
|
18
20
|
typeof import("../packages/skill-mcp-client/dist/client.js").SkillMCPClient
|
|
@@ -52,7 +54,11 @@ async function getClient(): Promise<SkillMCPClient | null> {
|
|
|
52
54
|
});
|
|
53
55
|
return clientInstance;
|
|
54
56
|
} catch {
|
|
55
|
-
|
|
57
|
+
centralWarn(
|
|
58
|
+
"harness.skill-sync",
|
|
59
|
+
"service.sync-failed",
|
|
60
|
+
"the skill client package could not be loaded",
|
|
61
|
+
);
|
|
56
62
|
return null;
|
|
57
63
|
}
|
|
58
64
|
}
|
|
@@ -73,6 +79,22 @@ interface SkillSyncResult {
|
|
|
73
79
|
skillCount: number;
|
|
74
80
|
source: "saas" | "cache" | "none";
|
|
75
81
|
error?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The skills the service holds.
|
|
84
|
+
*
|
|
85
|
+
* The list is carried along with the count, so a caller that needs
|
|
86
|
+
* the skills does not have to ask the service a second time.
|
|
87
|
+
*/
|
|
88
|
+
skills?: SkillInfo[];
|
|
89
|
+
/**
|
|
90
|
+
* Why the service could not be reached.
|
|
91
|
+
*
|
|
92
|
+
* "denied" means the service ANSWERED and refused the call - the
|
|
93
|
+
* token is missing or the plan does not cover the skill. That is a
|
|
94
|
+
* different situation than a service that could not be reached at
|
|
95
|
+
* all, and the user needs a different fix for each.
|
|
96
|
+
*/
|
|
97
|
+
failure?: "denied" | "unavailable";
|
|
76
98
|
}
|
|
77
99
|
|
|
78
100
|
/**
|
|
@@ -102,22 +124,28 @@ function getCachedSkills(): CacheData | null {
|
|
|
102
124
|
*/
|
|
103
125
|
function saveCache(data: CacheData): void {
|
|
104
126
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
127
|
+
// The write is atomic: a crash in the middle leaves the previous
|
|
128
|
+
// cache intact instead of a half written file.
|
|
129
|
+
atomicWriteFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
|
|
109
130
|
} catch {
|
|
110
|
-
|
|
131
|
+
centralWarn(
|
|
132
|
+
"harness.skill-sync",
|
|
133
|
+
"service.cache-write-failed",
|
|
134
|
+
"the skill cache could not be written",
|
|
135
|
+
);
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|
|
114
139
|
/**
|
|
115
140
|
* Fetch and cache skills from SaaS backend
|
|
116
141
|
*/
|
|
117
|
-
async function fetchFromSaaS(): Promise<
|
|
142
|
+
async function fetchFromSaaS(): Promise<{
|
|
143
|
+
data: CacheData | null;
|
|
144
|
+
failure?: "denied" | "unavailable";
|
|
145
|
+
}> {
|
|
118
146
|
const client = await getClient();
|
|
119
147
|
if (!client) {
|
|
120
|
-
return null;
|
|
148
|
+
return { data: null, failure: "unavailable" };
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
try {
|
|
@@ -128,16 +156,26 @@ async function fetchFromSaaS(): Promise<CacheData | null> {
|
|
|
128
156
|
version: "1.0.0",
|
|
129
157
|
};
|
|
130
158
|
saveCache(cacheData);
|
|
131
|
-
return cacheData;
|
|
159
|
+
return { data: cacheData };
|
|
132
160
|
} catch (error) {
|
|
133
161
|
if (error instanceof SkillAPIError) {
|
|
134
|
-
|
|
135
|
-
|
|
162
|
+
centralWarn(
|
|
163
|
+
"harness.skill-sync",
|
|
164
|
+
"service.sync-failed",
|
|
165
|
+
`the skill service answered with an error (code ${error.code})`,
|
|
136
166
|
);
|
|
167
|
+
// The service answered and refused the call.
|
|
168
|
+
if (error.isAuthError() || error.isTierRestricted()) {
|
|
169
|
+
return { data: null, failure: "denied" };
|
|
170
|
+
}
|
|
137
171
|
} else {
|
|
138
|
-
|
|
172
|
+
centralWarn(
|
|
173
|
+
"harness.skill-sync",
|
|
174
|
+
"service.sync-failed",
|
|
175
|
+
"the skill service could not be reached",
|
|
176
|
+
);
|
|
139
177
|
}
|
|
140
|
-
return null;
|
|
178
|
+
return { data: null, failure: "unavailable" };
|
|
141
179
|
}
|
|
142
180
|
}
|
|
143
181
|
|
|
@@ -162,6 +200,7 @@ export async function syncSkillsFromSaaS(
|
|
|
162
200
|
success: true,
|
|
163
201
|
skillCount: 0,
|
|
164
202
|
source: "none",
|
|
203
|
+
skills: [],
|
|
165
204
|
error: "SKILLS_SAAS_API_KEY not configured",
|
|
166
205
|
};
|
|
167
206
|
}
|
|
@@ -174,17 +213,19 @@ export async function syncSkillsFromSaaS(
|
|
|
174
213
|
success: true,
|
|
175
214
|
skillCount: cached.skills.length,
|
|
176
215
|
source: "cache",
|
|
216
|
+
skills: cached.skills,
|
|
177
217
|
};
|
|
178
218
|
}
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
// Fetch from SaaS
|
|
182
|
-
const
|
|
183
|
-
if (data) {
|
|
222
|
+
const fetched = await fetchFromSaaS();
|
|
223
|
+
if (fetched.data) {
|
|
184
224
|
return {
|
|
185
225
|
success: true,
|
|
186
|
-
skillCount: data.skills.length,
|
|
226
|
+
skillCount: fetched.data.skills.length,
|
|
187
227
|
source: "saas",
|
|
228
|
+
skills: fetched.data.skills,
|
|
188
229
|
};
|
|
189
230
|
}
|
|
190
231
|
|
|
@@ -195,16 +236,23 @@ export async function syncSkillsFromSaaS(
|
|
|
195
236
|
success: true,
|
|
196
237
|
skillCount: staleCache.skills.length,
|
|
197
238
|
source: "cache",
|
|
239
|
+
skills: staleCache.skills,
|
|
198
240
|
error: "Using stale cache (SaaS unavailable)",
|
|
241
|
+
failure: fetched.failure,
|
|
199
242
|
};
|
|
200
243
|
}
|
|
201
244
|
|
|
202
245
|
// Everything failed, return empty
|
|
203
246
|
return {
|
|
204
|
-
success:
|
|
247
|
+
success: false,
|
|
205
248
|
skillCount: 0,
|
|
206
249
|
source: "none",
|
|
207
|
-
|
|
250
|
+
skills: [],
|
|
251
|
+
error:
|
|
252
|
+
fetched.failure === "denied"
|
|
253
|
+
? "The skill service refused the call - the token or the plan does not allow it"
|
|
254
|
+
: "Could not reach Skills SaaS backend",
|
|
255
|
+
failure: fetched.failure,
|
|
208
256
|
};
|
|
209
257
|
}
|
|
210
258
|
|
|
@@ -226,7 +274,11 @@ export async function getSkillFromSaaS(identifier: {
|
|
|
226
274
|
return await client.getSkill(identifier);
|
|
227
275
|
} catch (error) {
|
|
228
276
|
if (error instanceof SkillAPIError) {
|
|
229
|
-
|
|
277
|
+
centralWarn(
|
|
278
|
+
"harness.skill-sync",
|
|
279
|
+
"service.skill-fetch-failed",
|
|
280
|
+
`the skill service could not deliver a skill (code ${error.code})`,
|
|
281
|
+
);
|
|
230
282
|
}
|
|
231
283
|
return null;
|
|
232
284
|
}
|
|
@@ -236,7 +288,7 @@ export async function getSkillFromSaaS(identifier: {
|
|
|
236
288
|
* Check if skill sync is configured
|
|
237
289
|
*/
|
|
238
290
|
export function isSkillSyncConfigured(): boolean {
|
|
239
|
-
return
|
|
291
|
+
return Boolean(process.env.SKILLS_SAAS_API_KEY);
|
|
240
292
|
}
|
|
241
293
|
|
|
242
294
|
/**
|
|
@@ -250,7 +302,7 @@ export function getSyncStatus(): {
|
|
|
250
302
|
const cache = getCachedSkills();
|
|
251
303
|
return {
|
|
252
304
|
configured: isSkillSyncConfigured(),
|
|
253
|
-
cacheValid:
|
|
305
|
+
cacheValid: Boolean(cache),
|
|
254
306
|
cachedSkills: cache?.skills.length ?? 0,
|
|
255
307
|
};
|
|
256
308
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The startup line of the skill system.
|
|
3
|
+
*
|
|
4
|
+
* The line carries TWO counts: the skills on this machine and the skills in
|
|
5
|
+
* the vector store. They are different things - a machine without local
|
|
6
|
+
* skill files can still reach a shared skill - and a single "N discovered"
|
|
7
|
+
* hides exactly that difference.
|
|
8
|
+
*
|
|
9
|
+
* The vector store has states that must not be flattened into "0": no
|
|
10
|
+
* credentials, an unreachable cluster and an empty collection are three
|
|
11
|
+
* different situations and need three different actions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What the vector store is in this run. */
|
|
15
|
+
export type QdrantSkillState =
|
|
16
|
+
/** No credentials for the vector store on this machine. */
|
|
17
|
+
| "not-configured"
|
|
18
|
+
/** The cluster is configured but cannot be reached. */
|
|
19
|
+
| "unavailable"
|
|
20
|
+
/** The cluster is reachable but the collection does not exist yet. */
|
|
21
|
+
| "missing"
|
|
22
|
+
/** The collection exists but holds no skill. */
|
|
23
|
+
| "empty"
|
|
24
|
+
/** The collection holds skills. */
|
|
25
|
+
| "populated";
|
|
26
|
+
|
|
27
|
+
/** The state of the skill vector store. */
|
|
28
|
+
export interface QdrantSkillStats {
|
|
29
|
+
/** What the vector store is in this run. */
|
|
30
|
+
state: QdrantSkillState;
|
|
31
|
+
/** The number of skill vectors in the collection. */
|
|
32
|
+
count: number;
|
|
33
|
+
/** The collection the numbers belong to. */
|
|
34
|
+
collection?: string;
|
|
35
|
+
/** The vector size the collection was created with. */
|
|
36
|
+
dimensions?: number;
|
|
37
|
+
/** The distance the collection was created with. */
|
|
38
|
+
distance?: string;
|
|
39
|
+
/** Why the collection could not be read. Never a credential. */
|
|
40
|
+
error?: string;
|
|
41
|
+
/** The collection does not match the schema of the runtime. */
|
|
42
|
+
schemaMismatch?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SkillsStartupReport {
|
|
46
|
+
/** The text of the line. */
|
|
47
|
+
text: string;
|
|
48
|
+
/** The status of the line. */
|
|
49
|
+
status: "wait" | "ok" | "info" | "warn";
|
|
50
|
+
/** The fix instructions below the line. */
|
|
51
|
+
details: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The state of the service that holds the server-side skills.
|
|
56
|
+
*
|
|
57
|
+
* The vector store is the INDEX. The service holds the authoritative
|
|
58
|
+
* files, so it is the one that can hand out a complete skill.
|
|
59
|
+
*/
|
|
60
|
+
export interface RemoteSkillStats {
|
|
61
|
+
/** What the service is in this run. */
|
|
62
|
+
state:
|
|
63
|
+
/** No service is configured. */
|
|
64
|
+
| "not-configured"
|
|
65
|
+
/** The service refused the call - the token or the plan. */
|
|
66
|
+
| "denied"
|
|
67
|
+
/** The service could not be reached. */
|
|
68
|
+
| "unavailable"
|
|
69
|
+
/** The service holds no skill for this user. */
|
|
70
|
+
| "empty"
|
|
71
|
+
/** The service holds skills. */
|
|
72
|
+
| "populated";
|
|
73
|
+
/** The number of skills the service holds. */
|
|
74
|
+
count: number;
|
|
75
|
+
/** Why the service could not be reached. Never a credential. */
|
|
76
|
+
error?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The line before the vector store has answered.
|
|
81
|
+
*
|
|
82
|
+
* The count of the local skills is known immediately; the count of the
|
|
83
|
+
* vector store needs the cluster, so the line waits for it.
|
|
84
|
+
*/
|
|
85
|
+
export function localSkillsReport(localCount: number): SkillsStartupReport {
|
|
86
|
+
return {
|
|
87
|
+
text: `Local Skills ${localCount} | Qdrant Skills ...`,
|
|
88
|
+
status: "wait",
|
|
89
|
+
details: [],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The line with the state of the vector store.
|
|
95
|
+
*
|
|
96
|
+
* The number is only shown when it is known: a cluster that cannot be
|
|
97
|
+
* reached has no count, and "0" would be a lie.
|
|
98
|
+
*/
|
|
99
|
+
export function skillsReport(
|
|
100
|
+
localCount: number,
|
|
101
|
+
stats: QdrantSkillStats,
|
|
102
|
+
remote?: RemoteSkillStats,
|
|
103
|
+
): SkillsStartupReport {
|
|
104
|
+
const details: string[] = [];
|
|
105
|
+
|
|
106
|
+
if (stats.schemaMismatch) {
|
|
107
|
+
details.push(
|
|
108
|
+
`The vector schema does not match: ${stats.schemaMismatch}.`,
|
|
109
|
+
"A collection with a different vector size cannot be searched with these vectors, so the vector search finds nothing.",
|
|
110
|
+
`The runtime uses the collection ${stats.collection ?? "pi-harness-skills-openai-v1"} - move the shared skills into it.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (stats.state === "unavailable") {
|
|
114
|
+
details.push(
|
|
115
|
+
stats.error
|
|
116
|
+
? `The vector store could not be reached: ${stats.error}`
|
|
117
|
+
: "The vector store could not be reached.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
details.push(...remoteDetails(remote));
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
text: `Local Skills ${localCount} | Qdrant Skills ${describeQdrantState(stats)}`,
|
|
124
|
+
status: startupStatus(stats, remote),
|
|
125
|
+
details,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The lines below the count.
|
|
131
|
+
*
|
|
132
|
+
* The service is reported separately from the vector store: it holds the
|
|
133
|
+
* complete skills, the vector store only the index.
|
|
134
|
+
*/
|
|
135
|
+
function remoteDetails(remote: RemoteSkillStats | undefined): string[] {
|
|
136
|
+
if (!remote || remote.state === "not-configured") return [];
|
|
137
|
+
|
|
138
|
+
if (remote.state === "populated") {
|
|
139
|
+
return [
|
|
140
|
+
`The skill service holds ${remote.count} skill${remote.count === 1 ? "" : "s"} - they can be loaded without a local copy.`,
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
if (remote.state === "denied") {
|
|
144
|
+
return [
|
|
145
|
+
remote.error
|
|
146
|
+
? `The skill service refused the call: ${remote.error}`
|
|
147
|
+
: "The skill service refused the call - the token or the plan does not allow it.",
|
|
148
|
+
"Fix: check SKILLS_SAAS_API_KEY and the plan of the account.",
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
if (remote.state === "unavailable") {
|
|
152
|
+
return [
|
|
153
|
+
remote.error
|
|
154
|
+
? `The skill service could not be reached: ${remote.error}`
|
|
155
|
+
: "The skill service could not be reached.",
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The status of the line.
|
|
163
|
+
*
|
|
164
|
+
* A vector store that is not configured is not a problem - most machines
|
|
165
|
+
* do not have one - so it is an information. An unreachable cluster and an
|
|
166
|
+
* incompatible collection are warnings: something is broken there.
|
|
167
|
+
*/
|
|
168
|
+
function startupStatus(
|
|
169
|
+
stats: QdrantSkillStats,
|
|
170
|
+
remote?: RemoteSkillStats,
|
|
171
|
+
): "ok" | "info" | "warn" {
|
|
172
|
+
if (stats.state === "unavailable" || stats.schemaMismatch) return "warn";
|
|
173
|
+
if (remote && (remote.state === "denied" || remote.state === "unavailable")) {
|
|
174
|
+
return "warn";
|
|
175
|
+
}
|
|
176
|
+
if (stats.state === "not-configured") return "info";
|
|
177
|
+
return "ok";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The short form of the vector store state.
|
|
182
|
+
*
|
|
183
|
+
* "0" means "the remote set is empty" - whether the collection does not
|
|
184
|
+
* exist yet or holds no skill. The other states say what is wrong.
|
|
185
|
+
*/
|
|
186
|
+
function describeQdrantState(stats: QdrantSkillStats): string {
|
|
187
|
+
switch (stats.state) {
|
|
188
|
+
case "not-configured":
|
|
189
|
+
return "not configured";
|
|
190
|
+
case "unavailable":
|
|
191
|
+
return "unavailable";
|
|
192
|
+
case "missing":
|
|
193
|
+
case "empty":
|
|
194
|
+
return "0";
|
|
195
|
+
case "populated":
|
|
196
|
+
return String(stats.count);
|
|
197
|
+
default:
|
|
198
|
+
// An unknown state is treated like an unreachable cluster: no
|
|
199
|
+
// count is shown, because a wrong number is worse than none.
|
|
200
|
+
return "unavailable";
|
|
201
|
+
}
|
|
202
|
+
}
|