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
|
@@ -10,20 +10,35 @@ import { join } from "node:path";
|
|
|
10
10
|
import type {
|
|
11
11
|
Skill,
|
|
12
12
|
SkillIndex,
|
|
13
|
-
SkillIndexEntry,
|
|
14
13
|
MatchOptions,
|
|
15
14
|
MatchResult,
|
|
16
15
|
LoadOptions,
|
|
17
16
|
LoadedSkill,
|
|
18
|
-
DEFAULT_SKILLS_CONFIG,
|
|
19
17
|
} from "./types.js";
|
|
20
|
-
import { parseSkillFile
|
|
18
|
+
import { parseSkillFile } from "./parser.js";
|
|
19
|
+
import {
|
|
20
|
+
LEGACY_SKILL_COLLECTION,
|
|
21
|
+
LEGACY_SKILL_COLLECTION_DIMENSIONS,
|
|
22
|
+
LEGACY_SKILL_COLLECTION_MODEL,
|
|
23
|
+
SKILL_COLLECTION,
|
|
24
|
+
describeSkillSchemaMismatch,
|
|
25
|
+
type SkillVectorSchema,
|
|
26
|
+
} from "./vector-schema.js";
|
|
27
|
+
import {
|
|
28
|
+
describeRemoteFailure,
|
|
29
|
+
isStale,
|
|
30
|
+
skillFromRemote,
|
|
31
|
+
type RemoteSkill,
|
|
32
|
+
type RemoteSkillSource,
|
|
33
|
+
type RemoteSkillState,
|
|
34
|
+
type RemoteSkillStats,
|
|
35
|
+
} from "./remote-source.js";
|
|
21
36
|
|
|
22
37
|
// --- Qdrant integration (optional) -----------------------------------------
|
|
23
38
|
// Loaded dynamically so skills work without Qdrant configured.
|
|
24
39
|
// At runtime, packages/ is resolved from the monorepo root or node_modules.
|
|
25
40
|
|
|
26
|
-
interface QdrantSkillPoint {
|
|
41
|
+
export interface QdrantSkillPoint {
|
|
27
42
|
id: number;
|
|
28
43
|
name: string;
|
|
29
44
|
description: string;
|
|
@@ -31,7 +46,7 @@ interface QdrantSkillPoint {
|
|
|
31
46
|
text: string;
|
|
32
47
|
}
|
|
33
48
|
|
|
34
|
-
interface QdrantClientLike {
|
|
49
|
+
export interface QdrantClientLike {
|
|
35
50
|
upsert(collection: string, opts: { wait?: boolean; points: unknown[] }): Promise<void>;
|
|
36
51
|
query(collection: string, opts: {
|
|
37
52
|
query?: { text?: string; model?: string };
|
|
@@ -39,29 +54,176 @@ interface QdrantClientLike {
|
|
|
39
54
|
with_payload?: boolean;
|
|
40
55
|
filter?: Record<string, unknown>;
|
|
41
56
|
}): Promise<unknown>;
|
|
57
|
+
/** Read the description of a collection. Optional. */
|
|
58
|
+
getCollection?(name: string): Promise<unknown>;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
interface QdrantEmbedderLike {
|
|
61
|
+
export interface QdrantEmbedderLike {
|
|
45
62
|
createEmbedding(text: string, apiKey?: string): Promise<{ embedding: number[]; tokens: number }>;
|
|
63
|
+
/** One request for many texts. Absent in an older package build. */
|
|
64
|
+
createEmbeddingsBatch?: (texts: string[], apiKey?: string) => Promise<{ embeddings: number[][]; tokens: number }>;
|
|
46
65
|
}
|
|
47
66
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
/** What the vector store is in this run. */
|
|
68
|
+
export type QdrantSkillState =
|
|
69
|
+
/** No Qdrant credentials on this machine. */
|
|
70
|
+
| "not-configured"
|
|
71
|
+
/** The cluster is configured but cannot be reached. */
|
|
72
|
+
| "unavailable"
|
|
73
|
+
/** The cluster is reachable but the collection does not exist yet. */
|
|
74
|
+
| "missing"
|
|
75
|
+
/** The collection exists but holds no skill. */
|
|
76
|
+
| "empty"
|
|
77
|
+
/** The collection holds skills. */
|
|
78
|
+
| "populated";
|
|
79
|
+
|
|
80
|
+
/** The state of the skill vector store. */
|
|
81
|
+
export interface QdrantSkillStats {
|
|
82
|
+
/** What the vector store is in this run. */
|
|
83
|
+
state: QdrantSkillState;
|
|
84
|
+
/** The number of skill vectors in the collection. */
|
|
85
|
+
count: number;
|
|
86
|
+
/** The collection the numbers belong to. */
|
|
87
|
+
collection?: string;
|
|
88
|
+
/** The vector size the collection was created with. */
|
|
89
|
+
dimensions?: number;
|
|
90
|
+
/** The distance the collection was created with. */
|
|
91
|
+
distance?: string;
|
|
92
|
+
/** Why the collection could not be read. Never a credential. */
|
|
93
|
+
error?: string;
|
|
94
|
+
/**
|
|
95
|
+
* The collection does not match the schema of the runtime.
|
|
96
|
+
*
|
|
97
|
+
* The message names the real cause, so a user whose vector search finds
|
|
98
|
+
* nothing does not have to guess why.
|
|
99
|
+
*/
|
|
100
|
+
schemaMismatch?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The information the Qdrant package can report about a collection. */
|
|
104
|
+
interface QdrantCollectionInfo {
|
|
105
|
+
exists: boolean;
|
|
106
|
+
vectorsSize?: number;
|
|
107
|
+
vectorsDistance?: string;
|
|
108
|
+
pointsCount?: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The vector size of the runtime, used when the installed Qdrant package
|
|
113
|
+
* does not report the size of its model.
|
|
114
|
+
*
|
|
115
|
+
* The embedder owns the real value; this is the fallback for an older
|
|
116
|
+
* package build.
|
|
117
|
+
*/
|
|
118
|
+
const RUNTIME_VECTOR_DIMENSIONS = 1536;
|
|
119
|
+
|
|
120
|
+
/** The distance of the runtime skill collection. */
|
|
121
|
+
const RUNTIME_VECTOR_DISTANCE = "Cosine";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A point of the vector index: the vector and the payload of one skill.
|
|
125
|
+
*/
|
|
126
|
+
export interface SkillVectorPoint {
|
|
127
|
+
id: number;
|
|
128
|
+
vector: number[];
|
|
129
|
+
payload: QdrantSkillPoint;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The vector index of a registry.
|
|
134
|
+
*
|
|
135
|
+
* The index is an interface so a caller can supply its own implementation
|
|
136
|
+
* (a test uses a counting double) and so the registry never has to know
|
|
137
|
+
* whether the index lives in Qdrant.
|
|
138
|
+
*/
|
|
139
|
+
export interface SkillVectorIndexer {
|
|
140
|
+
/** One vector per text, in the order of the texts. */
|
|
141
|
+
embed(texts: string[]): Promise<number[][]>;
|
|
142
|
+
/** Store the points of the given skills. */
|
|
143
|
+
upsert(points: SkillVectorPoint[]): Promise<void>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The options of a skill registry.
|
|
148
|
+
*/
|
|
149
|
+
export interface SkillRegistryOptions {
|
|
150
|
+
/**
|
|
151
|
+
* The vector index.
|
|
152
|
+
*
|
|
153
|
+
* `undefined` (the default) indexes into the configured Qdrant cluster.
|
|
154
|
+
* `null` disables the vector index entirely. An object is used as the index.
|
|
155
|
+
*/
|
|
156
|
+
indexer?: SkillVectorIndexer | null;
|
|
157
|
+
/**
|
|
158
|
+
* How many skills one indexing request carries. A larger batch costs fewer
|
|
159
|
+
* requests but holds more of them in memory.
|
|
160
|
+
*/
|
|
161
|
+
batchSize?: number;
|
|
162
|
+
/**
|
|
163
|
+
* The Qdrant client.
|
|
164
|
+
*
|
|
165
|
+
* `undefined` (the default) connects to the configured cluster. An object
|
|
166
|
+
* is used as the client - a test uses a double, so the search path can be
|
|
167
|
+
* exercised without a network.
|
|
168
|
+
*/
|
|
169
|
+
qdrantClient?: QdrantClientLike;
|
|
170
|
+
/**
|
|
171
|
+
* The embedder that turns a query into a vector.
|
|
172
|
+
*
|
|
173
|
+
* Only used together with an injected client.
|
|
174
|
+
*/
|
|
175
|
+
embedder?: QdrantEmbedderLike;
|
|
176
|
+
/**
|
|
177
|
+
* The collection of the injected client.
|
|
178
|
+
*
|
|
179
|
+
* Only used together with an injected client.
|
|
180
|
+
*/
|
|
181
|
+
collection?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The default number of skills one indexing request carries.
|
|
186
|
+
*/
|
|
187
|
+
const DEFAULT_INDEX_BATCH_SIZE = 32;
|
|
188
|
+
|
|
189
|
+
/** The Qdrant package, as far as the registry needs it. */
|
|
190
|
+
interface QdrantModules {
|
|
191
|
+
createQdrantClient: (cfg: unknown) => QdrantClientLike;
|
|
51
192
|
createCollection: (client: unknown, name: string, dims: number) => Promise<void>;
|
|
52
193
|
createEmbedding: (text: string, apiKey?: string) => Promise<{ embedding: number[]; tokens: number }>;
|
|
53
|
-
|
|
194
|
+
/** One request for many texts. Absent in an older package build. */
|
|
195
|
+
createEmbeddingsBatch?: (texts: string[], apiKey?: string) => Promise<{ embeddings: number[][]; tokens: number }>;
|
|
196
|
+
/** Read the schema and the point count of a collection. */
|
|
197
|
+
getCollectionInfo?: (client: unknown, name: string) => Promise<QdrantCollectionInfo | null>;
|
|
198
|
+
/** The embedding model of the installed package. */
|
|
199
|
+
embeddingModel?: string;
|
|
200
|
+
/** The vector size of the embedding model. */
|
|
201
|
+
embeddingDimensions?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Resolve the Qdrant skill module path (monorepo sibling or npm package) */
|
|
205
|
+
async function resolveQdrantModules(): Promise<QdrantModules | null> {
|
|
54
206
|
// Try the monorepo sibling first (the compiled package, so the module
|
|
55
207
|
// comes from the same build the app ships).
|
|
56
208
|
for (const base of ["../../../packages/qdrant-skills/dist", "../../packages/qdrant-skills/dist"]) {
|
|
57
209
|
try {
|
|
58
210
|
// A dynamic import: this module is ESM, where `require` does not exist.
|
|
59
|
-
const mod = (await import(/* @vite-ignore */ base + "/index.js")) as
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
211
|
+
const mod = (await import(/* @vite-ignore */ base + "/index.js")) as Record<string, unknown>;
|
|
212
|
+
return {
|
|
213
|
+
createQdrantClient: mod.createQdrantClient as QdrantModules["createQdrantClient"],
|
|
214
|
+
createCollection: mod.createCollection as QdrantModules["createCollection"],
|
|
215
|
+
createEmbedding: mod.createEmbedding as QdrantModules["createEmbedding"],
|
|
216
|
+
// Absent in an older package build: the registry falls back to one
|
|
217
|
+
// request per text, still with a bounded concurrency.
|
|
218
|
+
createEmbeddingsBatch: typeof mod.createEmbeddingsBatch === "function"
|
|
219
|
+
? (mod.createEmbeddingsBatch as QdrantModules["createEmbeddingsBatch"])
|
|
220
|
+
: undefined,
|
|
221
|
+
getCollectionInfo: typeof mod.getCollectionInfo === "function"
|
|
222
|
+
? (mod.getCollectionInfo as QdrantModules["getCollectionInfo"])
|
|
223
|
+
: undefined,
|
|
224
|
+
embeddingModel: typeof mod.EMBEDDING_MODEL === "string" ? mod.EMBEDDING_MODEL : undefined,
|
|
225
|
+
embeddingDimensions: typeof mod.EMBEDDING_DIMENSIONS === "number" ? mod.EMBEDDING_DIMENSIONS : undefined,
|
|
63
226
|
};
|
|
64
|
-
return mod as Awaited<ReturnType<typeof resolveQdrantModules>>;
|
|
65
227
|
} catch {
|
|
66
228
|
// Try next path
|
|
67
229
|
}
|
|
@@ -71,11 +233,19 @@ async function resolveQdrantModules(): Promise<{
|
|
|
71
233
|
// The specifier is a variable on purpose: the package is optional and
|
|
72
234
|
// must not be resolved by the type checker.
|
|
73
235
|
const specifier = "@pi-harness/qdrant-skills";
|
|
74
|
-
const mod = (await import(/* @vite-ignore */ specifier)) as
|
|
236
|
+
const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
|
|
75
237
|
return {
|
|
76
|
-
createQdrantClient:
|
|
77
|
-
createCollection: mod.createCollection as
|
|
78
|
-
createEmbedding: mod.createEmbedding as
|
|
238
|
+
createQdrantClient: mod.createQdrantClient as QdrantModules["createQdrantClient"],
|
|
239
|
+
createCollection: mod.createCollection as QdrantModules["createCollection"],
|
|
240
|
+
createEmbedding: mod.createEmbedding as QdrantModules["createEmbedding"],
|
|
241
|
+
createEmbeddingsBatch: typeof mod.createEmbeddingsBatch === "function"
|
|
242
|
+
? (mod.createEmbeddingsBatch as QdrantModules["createEmbeddingsBatch"])
|
|
243
|
+
: undefined,
|
|
244
|
+
getCollectionInfo: typeof mod.getCollectionInfo === "function"
|
|
245
|
+
? (mod.getCollectionInfo as QdrantModules["getCollectionInfo"])
|
|
246
|
+
: undefined,
|
|
247
|
+
embeddingModel: typeof mod.EMBEDDING_MODEL === "string" ? mod.EMBEDDING_MODEL : undefined,
|
|
248
|
+
embeddingDimensions: typeof mod.EMBEDDING_DIMENSIONS === "number" ? mod.EMBEDDING_DIMENSIONS : undefined,
|
|
79
249
|
};
|
|
80
250
|
} catch {
|
|
81
251
|
return null;
|
|
@@ -116,7 +286,10 @@ export function getQdrantConfig(): {
|
|
|
116
286
|
return {
|
|
117
287
|
url,
|
|
118
288
|
apiKey,
|
|
119
|
-
collection:
|
|
289
|
+
// The collection carries the model in its name: a collection built with
|
|
290
|
+
// a different embedding model holds vectors of a different size and
|
|
291
|
+
// cannot be searched with these vectors.
|
|
292
|
+
collection: process.env.QDRANT_COLLECTION || SKILL_COLLECTION,
|
|
120
293
|
};
|
|
121
294
|
}
|
|
122
295
|
|
|
@@ -125,9 +298,42 @@ export function getQdrantConfig(): {
|
|
|
125
298
|
*/
|
|
126
299
|
export class SkillRegistry {
|
|
127
300
|
private skills: Map<string, Skill> = new Map();
|
|
301
|
+
/** The options of the registry. */
|
|
302
|
+
private _options: SkillRegistryOptions;
|
|
303
|
+
/**
|
|
304
|
+
* The skills that only exist in the vector store.
|
|
305
|
+
*
|
|
306
|
+
* They are kept apart from the local skills on purpose: `list()` must
|
|
307
|
+
* report the LOCAL skills, so a client can say how many skills it has on
|
|
308
|
+
* disk and how many it can reach through the vector store.
|
|
309
|
+
*/
|
|
310
|
+
private remoteSkills: Map<string, Skill> = new Map();
|
|
311
|
+
/**
|
|
312
|
+
* The remote skills whose content is COMPLETE.
|
|
313
|
+
*
|
|
314
|
+
* A skill that only carries the truncated body of a vector store payload
|
|
315
|
+
* is not in this set - it is completed on the first load.
|
|
316
|
+
*/
|
|
317
|
+
private _completeRemote: Set<string> = new Set();
|
|
318
|
+
/**
|
|
319
|
+
* The service that holds the server-side skills.
|
|
320
|
+
*
|
|
321
|
+
* `undefined` means "no service is configured".
|
|
322
|
+
*/
|
|
323
|
+
private _remoteSource: RemoteSkillSource | undefined;
|
|
128
324
|
private index: SkillIndex = { entries: [], loadedAt: 0 };
|
|
129
325
|
private loading: Map<string, Promise<Skill>> = new Map();
|
|
130
326
|
|
|
327
|
+
// The vector index. `undefined` means "use Qdrant", `null` means "no index".
|
|
328
|
+
private _indexer: SkillVectorIndexer | null | undefined;
|
|
329
|
+
private _batchSize: number;
|
|
330
|
+
// The skills that still have to be indexed, in registration order.
|
|
331
|
+
private _indexQueue: Array<{ skill: Skill; point: QdrantSkillPoint; hash: number }> = [];
|
|
332
|
+
private _indexFlushRunning = false;
|
|
333
|
+
private _indexFlushScheduled = false;
|
|
334
|
+
// The content hash of the version of a skill that was queued or indexed.
|
|
335
|
+
private _indexedHashes: Map<string, number> = new Map();
|
|
336
|
+
|
|
131
337
|
// Qdrant state (lazily initialized on first register if configured)
|
|
132
338
|
private _qdrant: {
|
|
133
339
|
client: QdrantClientLike;
|
|
@@ -137,6 +343,13 @@ export class SkillRegistry {
|
|
|
137
343
|
} | null = null;
|
|
138
344
|
private _qdrantInitPromise: Promise<void> | null = null;
|
|
139
345
|
private _qdrantInitFailed = false;
|
|
346
|
+
private _qdrantIndexer: SkillVectorIndexer | null = null;
|
|
347
|
+
|
|
348
|
+
constructor(options: SkillRegistryOptions = {}) {
|
|
349
|
+
this._options = options;
|
|
350
|
+
this._indexer = options.indexer;
|
|
351
|
+
this._batchSize = options.batchSize ?? DEFAULT_INDEX_BATCH_SIZE;
|
|
352
|
+
}
|
|
140
353
|
|
|
141
354
|
/**
|
|
142
355
|
* Check if Qdrant initialization has failed (useful for diagnostics).
|
|
@@ -145,6 +358,13 @@ export class SkillRegistry {
|
|
|
145
358
|
return this._qdrantInitFailed;
|
|
146
359
|
}
|
|
147
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Whether skills are still queued for the vector index.
|
|
363
|
+
*/
|
|
364
|
+
isIndexing(): boolean {
|
|
365
|
+
return this._indexFlushRunning || this._indexQueue.length > 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
148
368
|
/**
|
|
149
369
|
* Initialize Qdrant client if configured.
|
|
150
370
|
* Uses a shared init promise to prevent race conditions when multiple
|
|
@@ -160,6 +380,21 @@ export class SkillRegistry {
|
|
|
160
380
|
}
|
|
161
381
|
|
|
162
382
|
private async _doQdrantInit(): Promise<void> {
|
|
383
|
+
// An injected client replaces the whole connection: a test exercises the
|
|
384
|
+
// search path without a network and without credentials.
|
|
385
|
+
const injected = this._options.qdrantClient;
|
|
386
|
+
if (injected) {
|
|
387
|
+
this._qdrant = {
|
|
388
|
+
client: injected,
|
|
389
|
+
embedder: this._options.embedder ?? {
|
|
390
|
+
createEmbedding: async () => ({ embedding: [], tokens: 0 }),
|
|
391
|
+
},
|
|
392
|
+
collection: this._options.collection ?? "pi-harness-skills-test",
|
|
393
|
+
ready: true,
|
|
394
|
+
};
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
163
398
|
const config = getQdrantConfig();
|
|
164
399
|
if (!config) {
|
|
165
400
|
this._qdrantInitFailed = true;
|
|
@@ -178,10 +413,17 @@ export class SkillRegistry {
|
|
|
178
413
|
url: config.url,
|
|
179
414
|
apiKey: config.apiKey,
|
|
180
415
|
});
|
|
181
|
-
|
|
416
|
+
// The collection must hold vectors of the size the embedder produces.
|
|
417
|
+
// An existing collection with a different size is NOT recreated here:
|
|
418
|
+
// that would destroy the skills of another model. The mismatch is
|
|
419
|
+
// reported instead (see getQdrantStats).
|
|
420
|
+
await mods.createCollection(client, config.collection, this._expectedVectorSchema(mods).dimensions);
|
|
182
421
|
this._qdrant = {
|
|
183
422
|
client,
|
|
184
|
-
embedder: {
|
|
423
|
+
embedder: {
|
|
424
|
+
createEmbedding: mods.createEmbedding,
|
|
425
|
+
createEmbeddingsBatch: mods.createEmbeddingsBatch,
|
|
426
|
+
},
|
|
185
427
|
collection: config.collection,
|
|
186
428
|
ready: true,
|
|
187
429
|
};
|
|
@@ -192,27 +434,244 @@ export class SkillRegistry {
|
|
|
192
434
|
}
|
|
193
435
|
|
|
194
436
|
/**
|
|
195
|
-
*
|
|
437
|
+
* The schema the runtime expects of the skill collection.
|
|
438
|
+
*
|
|
439
|
+
* The installed Qdrant package knows the size of its own model; an older
|
|
440
|
+
* build does not, and the fallback is used then.
|
|
441
|
+
*/
|
|
442
|
+
private _expectedVectorSchema(mods: QdrantModules): SkillVectorSchema {
|
|
443
|
+
return {
|
|
444
|
+
dimensions: mods.embeddingDimensions ?? RUNTIME_VECTOR_DIMENSIONS,
|
|
445
|
+
distance: RUNTIME_VECTOR_DISTANCE,
|
|
446
|
+
model: mods.embeddingModel,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The state of the skill vector store.
|
|
452
|
+
*
|
|
453
|
+
* The numbers are what the startup summary shows next to the local skill
|
|
454
|
+
* count, and the state says WHY there is no number: no credentials, an
|
|
455
|
+
* unreachable cluster, a collection that does not exist yet.
|
|
456
|
+
*
|
|
457
|
+
* The collection schema is checked here as well: a collection built with a
|
|
458
|
+
* different embedding model holds vectors the runtime cannot search with,
|
|
459
|
+
* and the reason has to be named - otherwise the vector search simply
|
|
460
|
+
* finds nothing.
|
|
196
461
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
462
|
+
async getQdrantStats(): Promise<QdrantSkillStats> {
|
|
463
|
+
const injected = this._options.qdrantClient;
|
|
464
|
+
const config = injected
|
|
465
|
+
? {
|
|
466
|
+
url: "",
|
|
467
|
+
apiKey: "",
|
|
468
|
+
collection: this._options.collection ?? "pi-harness-skills-test",
|
|
469
|
+
}
|
|
470
|
+
: getQdrantConfig();
|
|
471
|
+
if (!config) return { state: "not-configured", count: 0 };
|
|
199
472
|
|
|
200
|
-
const
|
|
473
|
+
const mods = await resolveQdrantModules();
|
|
474
|
+
if (!mods) {
|
|
475
|
+
return {
|
|
476
|
+
state: "unavailable",
|
|
477
|
+
count: 0,
|
|
478
|
+
collection: config.collection,
|
|
479
|
+
error: "the Qdrant package is not available",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (!mods.getCollectionInfo) {
|
|
483
|
+
return {
|
|
484
|
+
state: "unavailable",
|
|
485
|
+
count: 0,
|
|
486
|
+
collection: config.collection,
|
|
487
|
+
error: "the installed Qdrant package cannot read a collection",
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
const client = injected ?? mods.createQdrantClient({ url: config.url, apiKey: config.apiKey });
|
|
493
|
+
const info = await mods.getCollectionInfo(client, config.collection);
|
|
494
|
+
if (!info) {
|
|
495
|
+
return {
|
|
496
|
+
state: "unavailable",
|
|
497
|
+
count: 0,
|
|
498
|
+
collection: config.collection,
|
|
499
|
+
error: "the collection could not be read",
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
if (!info.exists) {
|
|
503
|
+
return { state: "missing", count: 0, collection: config.collection };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const count = info.pointsCount ?? 0;
|
|
507
|
+
const stats: QdrantSkillStats = {
|
|
508
|
+
state: count > 0 ? "populated" : "empty",
|
|
509
|
+
count,
|
|
510
|
+
collection: config.collection,
|
|
511
|
+
dimensions: info.vectorsSize,
|
|
512
|
+
distance: info.vectorsDistance,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const mismatch = describeSkillSchemaMismatch(
|
|
516
|
+
{ dimensions: info.vectorsSize, distance: info.vectorsDistance },
|
|
517
|
+
this._expectedVectorSchema(mods),
|
|
518
|
+
);
|
|
519
|
+
if (mismatch) {
|
|
520
|
+
stats.schemaMismatch = mismatch;
|
|
521
|
+
// The legacy collection is a known cause - name it, so the reader
|
|
522
|
+
// does not have to know the history of the collection names.
|
|
523
|
+
if (config.collection === LEGACY_SKILL_COLLECTION) {
|
|
524
|
+
stats.schemaMismatch +=
|
|
525
|
+
` - the collection ${LEGACY_SKILL_COLLECTION} was created by scripts/sync-to-qdrant.ts` +
|
|
526
|
+
` with ${LEGACY_SKILL_COLLECTION_MODEL} (${LEGACY_SKILL_COLLECTION_DIMENSIONS} dimensions)`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return stats;
|
|
530
|
+
} catch (err) {
|
|
531
|
+
return {
|
|
532
|
+
state: "unavailable",
|
|
533
|
+
count: 0,
|
|
534
|
+
collection: config.collection,
|
|
535
|
+
error: err instanceof Error ? err.message : String(err),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** The point of a skill in the vector index. */
|
|
541
|
+
private _toQdrantPoint(skill: Skill): QdrantSkillPoint {
|
|
542
|
+
return {
|
|
201
543
|
id: this._hashId(skill.id),
|
|
202
544
|
name: skill.id,
|
|
203
545
|
description: skill.frontmatter.description,
|
|
204
546
|
body: skill.body.slice(0, 2000),
|
|
205
547
|
text: [skill.id, skill.frontmatter.name, skill.frontmatter.description, skill.body.slice(0, 1500)].join(" "),
|
|
206
548
|
};
|
|
549
|
+
}
|
|
207
550
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
551
|
+
/**
|
|
552
|
+
* The indexer of the registry: the injected one, or the Qdrant cluster.
|
|
553
|
+
*/
|
|
554
|
+
private _getIndexer(): SkillVectorIndexer | null {
|
|
555
|
+
if (this._indexer !== undefined) return this._indexer;
|
|
556
|
+
if (!this._qdrantIndexer) {
|
|
557
|
+
this._qdrantIndexer = this._createQdrantIndexer();
|
|
558
|
+
}
|
|
559
|
+
return this._qdrantIndexer;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The Qdrant cluster as an indexer.
|
|
564
|
+
*
|
|
565
|
+
* The batch call of the embeddings endpoint carries many texts in one
|
|
566
|
+
* request. One request per skill exhausted the sockets of the process when
|
|
567
|
+
* a registry held hundreds of skills, and every other request of the
|
|
568
|
+
* program then starved.
|
|
569
|
+
*/
|
|
570
|
+
private _createQdrantIndexer(): SkillVectorIndexer | null {
|
|
571
|
+
const qdrant = this._qdrant;
|
|
572
|
+
if (!qdrant?.ready) return null;
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
embed: async (texts: string[]): Promise<number[][]> => {
|
|
576
|
+
if (texts.length === 0) return [];
|
|
577
|
+
const batch = qdrant.embedder.createEmbeddingsBatch;
|
|
578
|
+
if (batch) {
|
|
579
|
+
const result = await batch(texts);
|
|
580
|
+
return result.embeddings;
|
|
581
|
+
}
|
|
582
|
+
// An older build without the batch call: one request per text, but
|
|
583
|
+
// the caller keeps the concurrency bounded all the same.
|
|
584
|
+
const embeddings: number[][] = [];
|
|
585
|
+
for (const text of texts) {
|
|
586
|
+
const { embedding } = await qdrant.embedder.createEmbedding(text);
|
|
587
|
+
embeddings.push(embedding);
|
|
588
|
+
}
|
|
589
|
+
return embeddings;
|
|
590
|
+
},
|
|
591
|
+
upsert: async (points: SkillVectorPoint[]): Promise<void> => {
|
|
592
|
+
await qdrant.client.upsert(qdrant.collection, { wait: false, points });
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Queue a skill for the vector index.
|
|
599
|
+
*
|
|
600
|
+
* A skill whose text did not change is not queued again: the registry is
|
|
601
|
+
* filled on every scan, and re-embedding the same skill would pay for the
|
|
602
|
+
* same vector over and over.
|
|
603
|
+
*/
|
|
604
|
+
private _scheduleIndex(skill: Skill): void {
|
|
605
|
+
const point = this._toQdrantPoint(skill);
|
|
606
|
+
const hash = this._hashId(point.text);
|
|
607
|
+
if (this._indexedHashes.get(skill.id) === hash) return;
|
|
608
|
+
|
|
609
|
+
this._indexedHashes.set(skill.id, hash);
|
|
610
|
+
this._indexQueue.push({ skill, point, hash });
|
|
611
|
+
this._scheduleIndexFlush();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Start the index flush, a microtask from now.
|
|
616
|
+
*
|
|
617
|
+
* A scan registers every skill in one go. Waiting for the microtask lets
|
|
618
|
+
* that burst share one request instead of starting one request per skill.
|
|
619
|
+
*/
|
|
620
|
+
private _scheduleIndexFlush(): void {
|
|
621
|
+
if (this._indexFlushScheduled) return;
|
|
622
|
+
this._indexFlushScheduled = true;
|
|
623
|
+
queueMicrotask(() => {
|
|
624
|
+
this._indexFlushScheduled = false;
|
|
625
|
+
this._runIndexFlush();
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Index the queued skills, one batch at a time.
|
|
631
|
+
*
|
|
632
|
+
* Only one batch is in flight, so the number of open connections stays
|
|
633
|
+
* bound no matter how many skills the registry holds.
|
|
634
|
+
*/
|
|
635
|
+
private _runIndexFlush(): void {
|
|
636
|
+
if (this._indexFlushRunning) return;
|
|
637
|
+
this._indexFlushRunning = true;
|
|
638
|
+
|
|
639
|
+
void this._flushIndexQueue().finally(() => {
|
|
640
|
+
this._indexFlushRunning = false;
|
|
641
|
+
// Skills may have arrived while the flush was running.
|
|
642
|
+
if (this._indexQueue.length > 0) this._scheduleIndexFlush();
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private async _flushIndexQueue(): Promise<void> {
|
|
647
|
+
if (this._indexer === undefined) await this._ensureQdrant();
|
|
648
|
+
const indexer = this._getIndexer();
|
|
649
|
+
if (!indexer) {
|
|
650
|
+
// No vector index: the skills stay usable without vectors.
|
|
651
|
+
this._indexQueue = [];
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
while (this._indexQueue.length > 0) {
|
|
656
|
+
const batch = this._indexQueue.splice(0, this._batchSize);
|
|
657
|
+
try {
|
|
658
|
+
const vectors = await indexer.embed(batch.map((item) => item.point.text));
|
|
659
|
+
const points: SkillVectorPoint[] = [];
|
|
660
|
+
batch.forEach((item, position) => {
|
|
661
|
+
const vector = vectors[position];
|
|
662
|
+
if (!vector || vector.length === 0) return;
|
|
663
|
+
points.push({ id: item.point.id, vector, payload: item.point });
|
|
664
|
+
});
|
|
665
|
+
if (points.length > 0) await indexer.upsert(points);
|
|
666
|
+
} catch {
|
|
667
|
+
// The vector index is optional. A failed batch may be retried by a
|
|
668
|
+
// later registration of the same skills.
|
|
669
|
+
for (const item of batch) {
|
|
670
|
+
if (this._indexedHashes.get(item.skill.id) === item.hash) {
|
|
671
|
+
this._indexedHashes.delete(item.skill.id);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
216
675
|
}
|
|
217
676
|
}
|
|
218
677
|
|
|
@@ -231,8 +690,8 @@ export class SkillRegistry {
|
|
|
231
690
|
register(skill: Skill): void {
|
|
232
691
|
this.skills.set(skill.id, skill);
|
|
233
692
|
this.updateIndex(skill);
|
|
234
|
-
//
|
|
235
|
-
this.
|
|
693
|
+
// Queue the skill for the vector index without blocking registration.
|
|
694
|
+
this._scheduleIndex(skill);
|
|
236
695
|
}
|
|
237
696
|
|
|
238
697
|
/**
|
|
@@ -254,7 +713,97 @@ export class SkillRegistry {
|
|
|
254
713
|
* Get a skill by ID
|
|
255
714
|
*/
|
|
256
715
|
get(skillId: string): Skill | undefined {
|
|
257
|
-
|
|
716
|
+
// A server skill is only known after a vector search found it, so it is
|
|
717
|
+
// looked up second - the local skill always wins.
|
|
718
|
+
return this.skills.get(skillId) ?? this.remoteSkills.get(skillId);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* The skills that only exist in the vector store.
|
|
723
|
+
*
|
|
724
|
+
* They are not part of `list()`: the local count must stay local.
|
|
725
|
+
*/
|
|
726
|
+
listRemote(): Skill[] {
|
|
727
|
+
return Array.from(this.remoteSkills.values());
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The service that holds the server-side skills.
|
|
732
|
+
*
|
|
733
|
+
* `undefined` means "no service is configured". Setting a source clears
|
|
734
|
+
* the cached remote skills: a new service is a new authority, so the old
|
|
735
|
+
* content must not survive it.
|
|
736
|
+
*/
|
|
737
|
+
setRemoteSource(source: RemoteSkillSource | undefined): void {
|
|
738
|
+
this._remoteSource = source;
|
|
739
|
+
this.remoteSkills.clear();
|
|
740
|
+
this._completeRemote.clear();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Ask the service for the skills it holds.
|
|
745
|
+
*
|
|
746
|
+
* A skill whose version changed is replaced by the new one. A record
|
|
747
|
+
* without a version never replaces a cached skill - an unknown version is
|
|
748
|
+
* not evidence of a change.
|
|
749
|
+
*/
|
|
750
|
+
async refreshRemoteSkills(options?: {
|
|
751
|
+
search?: string;
|
|
752
|
+
limit?: number;
|
|
753
|
+
}): Promise<RemoteSkillStats> {
|
|
754
|
+
if (!this._remoteSource) {
|
|
755
|
+
return { state: "not-configured", count: 0 };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let records: RemoteSkill[];
|
|
759
|
+
try {
|
|
760
|
+
records = await this._remoteSource.list(options);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
return {
|
|
763
|
+
state: this._remoteFailureState(error),
|
|
764
|
+
count: 0,
|
|
765
|
+
error: describeRemoteFailure(error),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
for (const record of records) {
|
|
770
|
+
const cached = this.remoteSkills.get(record.id);
|
|
771
|
+
if (!isStale(cached, record)) continue;
|
|
772
|
+
const skill = skillFromRemote(record);
|
|
773
|
+
this.remoteSkills.set(skill.id, skill);
|
|
774
|
+
if (record.contentComplete) this._completeRemote.add(skill.id);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
state: records.length > 0 ? "populated" : "empty",
|
|
779
|
+
count: records.length,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The state of the service that holds the server-side skills.
|
|
785
|
+
*
|
|
786
|
+
* The states stay apart: a refused call is not an unreachable service and
|
|
787
|
+
* not an empty service.
|
|
788
|
+
*/
|
|
789
|
+
async getRemoteStats(): Promise<RemoteSkillStats> {
|
|
790
|
+
if (!this._remoteSource) {
|
|
791
|
+
return { state: "not-configured", count: 0 };
|
|
792
|
+
}
|
|
793
|
+
return this.refreshRemoteSkills();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The state a failed call stands for.
|
|
798
|
+
*
|
|
799
|
+
* A refusal of the service is "denied" - the service answered, it just
|
|
800
|
+
* did not allow this call. Everything else is a service that could not be
|
|
801
|
+
* reached.
|
|
802
|
+
*/
|
|
803
|
+
private _remoteFailureState(error: unknown): RemoteSkillState {
|
|
804
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
805
|
+
if (code === "denied" || code === -32001 || code === -32002) return "denied";
|
|
806
|
+
return "unavailable";
|
|
258
807
|
}
|
|
259
808
|
|
|
260
809
|
/**
|
|
@@ -368,10 +917,38 @@ export class SkillRegistry {
|
|
|
368
917
|
|
|
369
918
|
// --- Qdrant vector search --------------------------------------------------
|
|
370
919
|
|
|
920
|
+
/**
|
|
921
|
+
* A skill that only exists in the vector store.
|
|
922
|
+
*
|
|
923
|
+
* The payload carries the name, the description and the body - enough to
|
|
924
|
+
* USE the skill on a machine that has no local copy of it.
|
|
925
|
+
*/
|
|
926
|
+
private static skillFromPayload(payload: Record<string, unknown>, name: string): Skill | null {
|
|
927
|
+
const description = typeof payload.description === "string" ? payload.description : "";
|
|
928
|
+
const body = typeof payload.body === "string" ? payload.body : "";
|
|
929
|
+
if (!description && !body) return null;
|
|
930
|
+
return {
|
|
931
|
+
id: name,
|
|
932
|
+
// A server skill has no directory on this machine.
|
|
933
|
+
path: "",
|
|
934
|
+
frontmatter: { name, description },
|
|
935
|
+
body,
|
|
936
|
+
references: [],
|
|
937
|
+
examples: [],
|
|
938
|
+
metadata: { name, description },
|
|
939
|
+
loaded: true,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
371
943
|
/**
|
|
372
944
|
* Search skills by semantic similarity using Qdrant.
|
|
373
945
|
* Falls back to an empty result if Qdrant is not available.
|
|
374
946
|
*
|
|
947
|
+
* A hit that belongs to no local skill is a SERVER skill: it is returned
|
|
948
|
+
* with its payload instead of being dropped. Dropping it would make a
|
|
949
|
+
* shared skill invisible on every machine that has no local copy of it -
|
|
950
|
+
* which is exactly the case the vector store exists for.
|
|
951
|
+
*
|
|
375
952
|
* @param query Natural-language query
|
|
376
953
|
* @param limit Max results (default 5)
|
|
377
954
|
* @param threshold Minimum cosine score (default 0.5)
|
|
@@ -380,7 +957,7 @@ export class SkillRegistry {
|
|
|
380
957
|
query: string,
|
|
381
958
|
limit = 5,
|
|
382
959
|
threshold = 0.5,
|
|
383
|
-
): Promise<Array<{ skill: Skill; score: number }>> {
|
|
960
|
+
): Promise<Array<{ skill: Skill; score: number; source: "local" | "remote" }>> {
|
|
384
961
|
await this._ensureQdrant();
|
|
385
962
|
if (!this._qdrant?.ready) return [];
|
|
386
963
|
|
|
@@ -394,12 +971,21 @@ export class SkillRegistry {
|
|
|
394
971
|
const raw = await this._qdrant.client.query(this._qdrant.collection, queryOpts) as {
|
|
395
972
|
points?: Array<{ id: number; score: number; payload?: Record<string, unknown> }> };
|
|
396
973
|
|
|
397
|
-
const results: Array<{ skill: Skill; score: number }> = [];
|
|
974
|
+
const results: Array<{ skill: Skill; score: number; source: "local" | "remote" }> = [];
|
|
398
975
|
for (const pt of raw.points ?? []) {
|
|
399
976
|
if (pt.score < threshold) continue;
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
if (
|
|
977
|
+
const payload = pt.payload ?? {};
|
|
978
|
+
const name = typeof payload.name === "string" ? payload.name : undefined;
|
|
979
|
+
if (!name) continue;
|
|
980
|
+
const local = this.skills.get(name);
|
|
981
|
+
if (local) {
|
|
982
|
+
results.push({ skill: local, score: pt.score, source: "local" });
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
const remote = SkillRegistry.skillFromPayload(payload, name);
|
|
986
|
+
if (!remote) continue;
|
|
987
|
+
this.remoteSkills.set(remote.id, remote);
|
|
988
|
+
results.push({ skill: remote, score: pt.score, source: "remote" });
|
|
403
989
|
}
|
|
404
990
|
return results;
|
|
405
991
|
} catch (err) {
|
|
@@ -413,13 +999,30 @@ export class SkillRegistry {
|
|
|
413
999
|
* Useful after Qdrant becomes available or collection is rebuilt.
|
|
414
1000
|
*/
|
|
415
1001
|
async syncAllToQdrant(onProgress?: (index: number, total: number) => void): Promise<void> {
|
|
416
|
-
await this._ensureQdrant();
|
|
417
|
-
|
|
1002
|
+
if (this._indexer === undefined) await this._ensureQdrant();
|
|
1003
|
+
const indexer = this._getIndexer();
|
|
1004
|
+
if (!indexer) return;
|
|
418
1005
|
|
|
419
1006
|
const skills = this.list();
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
1007
|
+
const total = skills.length;
|
|
1008
|
+
let done = 0;
|
|
1009
|
+
for (let i = 0; i < total; i += this._batchSize) {
|
|
1010
|
+
const batch = skills.slice(i, i + this._batchSize);
|
|
1011
|
+
const points = batch.map((skill) => this._toQdrantPoint(skill));
|
|
1012
|
+
try {
|
|
1013
|
+
const vectors = await indexer.embed(points.map((point) => point.text));
|
|
1014
|
+
const payload: SkillVectorPoint[] = [];
|
|
1015
|
+
points.forEach((point, position) => {
|
|
1016
|
+
const vector = vectors[position];
|
|
1017
|
+
if (!vector || vector.length === 0) return;
|
|
1018
|
+
payload.push({ id: point.id, vector, payload: point });
|
|
1019
|
+
});
|
|
1020
|
+
if (payload.length > 0) await indexer.upsert(payload);
|
|
1021
|
+
} catch {
|
|
1022
|
+
// The vector index is optional — a failed batch does not stop the sync.
|
|
1023
|
+
}
|
|
1024
|
+
done += batch.length;
|
|
1025
|
+
onProgress?.(done, total);
|
|
423
1026
|
}
|
|
424
1027
|
}
|
|
425
1028
|
|
|
@@ -432,7 +1035,36 @@ export class SkillRegistry {
|
|
|
432
1035
|
* Load a skill's full content (progressive disclosure)
|
|
433
1036
|
*/
|
|
434
1037
|
async load(skillId: string, options?: LoadOptions): Promise<LoadedSkill | null> {
|
|
435
|
-
|
|
1038
|
+
let skill = this.skills.get(skillId) ?? this.remoteSkills.get(skillId);
|
|
1039
|
+
|
|
1040
|
+
// A skill that is not known yet may still live on the server. The
|
|
1041
|
+
// service decides whether this user may see it - a refusal is not an
|
|
1042
|
+
// error, it is an answer.
|
|
1043
|
+
if (!skill && this._remoteSource) {
|
|
1044
|
+
const record = await this._remoteSource.get(skillId);
|
|
1045
|
+
if (!record) return null;
|
|
1046
|
+
skill = skillFromRemote(record);
|
|
1047
|
+
this.remoteSkills.set(skill.id, skill);
|
|
1048
|
+
if (record.contentComplete) this._completeRemote.add(skill.id);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// A cached remote skill may carry only a truncated body. The service
|
|
1052
|
+
// holds the complete content, so it is fetched once.
|
|
1053
|
+
if (
|
|
1054
|
+
skill &&
|
|
1055
|
+
!this.skills.has(skillId) &&
|
|
1056
|
+
this._remoteSource &&
|
|
1057
|
+
!this._completeRemote.has(skillId)
|
|
1058
|
+
) {
|
|
1059
|
+
const record = await this._remoteSource.get(skillId);
|
|
1060
|
+
if (record) {
|
|
1061
|
+
const complete = skillFromRemote(record);
|
|
1062
|
+
this.remoteSkills.set(complete.id, complete);
|
|
1063
|
+
if (record.contentComplete) this._completeRemote.add(complete.id);
|
|
1064
|
+
skill = complete;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
436
1068
|
if (!skill) {
|
|
437
1069
|
return null;
|
|
438
1070
|
}
|
|
@@ -469,10 +1101,12 @@ export class SkillRegistry {
|
|
|
469
1101
|
options?: LoadOptions
|
|
470
1102
|
): Promise<LoadedSkill> {
|
|
471
1103
|
const loadedSkill: LoadedSkill = { ...skill };
|
|
472
|
-
|
|
1104
|
+
// A server skill has no directory on this machine - its body comes from
|
|
1105
|
+
// the vector store, so nothing is read from the disk.
|
|
1106
|
+
const skillMdPath = skill.path ? join(skill.path, "SKILL.md") : "";
|
|
473
1107
|
|
|
474
1108
|
// Re-parse to get fresh content
|
|
475
|
-
if (existsSync(skillMdPath)) {
|
|
1109
|
+
if (skillMdPath && existsSync(skillMdPath)) {
|
|
476
1110
|
const parsed = parseSkillFile(skillMdPath);
|
|
477
1111
|
loadedSkill.body = parsed.body;
|
|
478
1112
|
loadedSkill.frontmatter = parsed.frontmatter;
|
|
@@ -554,6 +1188,7 @@ export class SkillRegistry {
|
|
|
554
1188
|
*/
|
|
555
1189
|
clear(): void {
|
|
556
1190
|
this.skills.clear();
|
|
1191
|
+
this.remoteSkills.clear();
|
|
557
1192
|
this.index = { entries: [], loadedAt: 0 };
|
|
558
1193
|
this.loading.clear();
|
|
559
1194
|
}
|
|
@@ -569,15 +1204,15 @@ export class SkillRegistry {
|
|
|
569
1204
|
* Check if skill exists
|
|
570
1205
|
*/
|
|
571
1206
|
has(skillId: string): boolean {
|
|
572
|
-
return this.skills.has(skillId);
|
|
1207
|
+
return this.skills.has(skillId) || this.remoteSkills.has(skillId);
|
|
573
1208
|
}
|
|
574
1209
|
}
|
|
575
1210
|
|
|
576
1211
|
/**
|
|
577
1212
|
* Create a new skill registry
|
|
578
1213
|
*/
|
|
579
|
-
export function createSkillRegistry(): SkillRegistry {
|
|
580
|
-
return new SkillRegistry();
|
|
1214
|
+
export function createSkillRegistry(options: SkillRegistryOptions = {}): SkillRegistry {
|
|
1215
|
+
return new SkillRegistry(options);
|
|
581
1216
|
}
|
|
582
1217
|
|
|
583
1218
|
/**
|