pi-harness-runtime 1.1.411 → 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.
Files changed (156) hide show
  1. package/harness/notification-settings.ts +130 -0
  2. package/harness/skill-remote-source.ts +157 -0
  3. package/harness/skill-sync.ts +74 -22
  4. package/harness/skills-startup.ts +202 -0
  5. package/harness/startup-report.ts +98 -56
  6. package/harness-startup-status.ts +31 -2
  7. package/index.ts +125 -31
  8. package/package.json +1 -1
  9. package/packages/a2a-adapter/package.json +1 -1
  10. package/packages/agent-policy/package.json +1 -1
  11. package/packages/architecture-generator/package.json +1 -1
  12. package/packages/auth/package.json +1 -1
  13. package/packages/autonomous-refactor/package.json +1 -1
  14. package/packages/autonomous-runtime/package.json +1 -1
  15. package/packages/cache-strategy/package.json +1 -1
  16. package/packages/capability-registry/package.json +1 -1
  17. package/packages/checkpoint/package.json +1 -1
  18. package/packages/cli-plugin-sdk/package.json +1 -1
  19. package/packages/clipboard/package.json +1 -1
  20. package/packages/clipboard-plugin/package.json +1 -1
  21. package/packages/code-generation/package.json +1 -1
  22. package/packages/code-review/package.json +1 -1
  23. package/packages/codex-adapter/package.json +1 -1
  24. package/packages/codex-watcher/package.json +1 -1
  25. package/packages/config-capture/package.json +1 -1
  26. package/packages/connection-runtime/package.json +1 -1
  27. package/packages/context-compiler/package.json +1 -1
  28. package/packages/context-discovery/package.json +1 -1
  29. package/packages/context-manager/package.json +1 -1
  30. package/packages/contracts/package.json +1 -1
  31. package/packages/cookie-sanitizer/package.json +1 -1
  32. package/packages/cost-optimizer/package.json +1 -1
  33. package/packages/dependency-analyzer/package.json +1 -1
  34. package/packages/django-plugin/package.json +1 -1
  35. package/packages/doc-generator/package.json +1 -1
  36. package/packages/durable-review/package.json +1 -1
  37. package/packages/e2e-tester/package.json +1 -1
  38. package/packages/evaluation-engine/package.json +1 -1
  39. package/packages/evaluation-runner/package.json +1 -1
  40. package/packages/event-bus/package.json +1 -1
  41. package/packages/event-sink/package.json +1 -1
  42. package/packages/event-store/package.json +1 -1
  43. package/packages/experience-replay/package.json +1 -1
  44. package/packages/feedback-collector/package.json +1 -1
  45. package/packages/file-copy-helper/package.json +1 -1
  46. package/packages/framework-detector/package.json +1 -1
  47. package/packages/framework-plugin-sdk/package.json +1 -1
  48. package/packages/frappe-plugin/package.json +1 -1
  49. package/packages/generic-web-plugin/package.json +1 -1
  50. package/packages/health-monitor/package.json +1 -1
  51. package/packages/intent-analyzer/package.json +1 -1
  52. package/packages/jev-judge/package.json +1 -1
  53. package/packages/knowledge-graph/package.json +1 -1
  54. package/packages/knowledge-retrieval/package.json +1 -1
  55. package/packages/laravel-plugin/package.json +1 -1
  56. package/packages/learning-engine/package.json +1 -1
  57. package/packages/logging/package.json +1 -1
  58. package/packages/mcp-adapter/package.json +1 -1
  59. package/packages/memory-engine/package.json +1 -1
  60. package/packages/milestone-manager/package.json +1 -1
  61. package/packages/model-registry/package.json +1 -1
  62. package/packages/nextjs-plugin/package.json +1 -1
  63. package/packages/notification/package.json +1 -1
  64. package/packages/observability/package.json +1 -1
  65. package/packages/okf-indexer/package.json +1 -1
  66. package/packages/paperclip-integration/package.json +1 -1
  67. package/packages/performance-optimizer/package.json +1 -1
  68. package/packages/permission-mode/package.json +1 -1
  69. package/packages/privilege-broker/package.json +1 -1
  70. package/packages/project-analyzer/package.json +1 -1
  71. package/packages/project-bootstrap/package.json +1 -1
  72. package/packages/projection-engine/package.json +1 -1
  73. package/packages/prompt-compiler/package.json +1 -1
  74. package/packages/prompt-versioning/package.json +1 -1
  75. package/packages/provider-adapter-sdk/package.json +1 -1
  76. package/packages/provider-router/package.json +1 -1
  77. package/packages/provider-selector/package.json +1 -1
  78. package/packages/providers/package.json +1 -1
  79. package/packages/qdrant-skills/dist/client.d.ts.map +1 -1
  80. package/packages/qdrant-skills/dist/client.js +21 -8
  81. package/packages/qdrant-skills/dist/client.js.map +1 -1
  82. package/packages/qdrant-skills/dist/embedder.d.ts +10 -0
  83. package/packages/qdrant-skills/dist/embedder.d.ts.map +1 -1
  84. package/packages/qdrant-skills/dist/embedder.js +12 -2
  85. package/packages/qdrant-skills/dist/embedder.js.map +1 -1
  86. package/packages/qdrant-skills/dist/index.d.ts +1 -1
  87. package/packages/qdrant-skills/dist/index.d.ts.map +1 -1
  88. package/packages/qdrant-skills/dist/index.js +1 -1
  89. package/packages/qdrant-skills/dist/index.js.map +1 -1
  90. package/packages/qdrant-skills/package.json +1 -1
  91. package/packages/qdrant-skills/src/client.ts +19 -8
  92. package/packages/qdrant-skills/src/embedder.ts +14 -2
  93. package/packages/qdrant-skills/src/index.ts +2 -0
  94. package/packages/qdrant-skills/test/collection-info.test.ts +129 -0
  95. package/packages/quota-manager/package.json +1 -1
  96. package/packages/rate-limiter/package.json +1 -1
  97. package/packages/react-vite-plugin/package.json +1 -1
  98. package/packages/relay/package.json +1 -1
  99. package/packages/release-manager/package.json +1 -1
  100. package/packages/requirement-compiler/package.json +1 -1
  101. package/packages/runtime/package.json +1 -1
  102. package/packages/scheduler/package.json +1 -1
  103. package/packages/scheduler-adapter/package.json +1 -1
  104. package/packages/session/package.json +1 -1
  105. package/packages/session-api/package.json +1 -1
  106. package/packages/session-export/package.json +1 -1
  107. package/packages/shared-context/package.json +1 -1
  108. package/packages/skill-mcp-client/package.json +1 -1
  109. package/packages/skill-registry/package.json +1 -1
  110. package/packages/skills/dist/index.d.ts +14 -1
  111. package/packages/skills/dist/index.d.ts.map +1 -1
  112. package/packages/skills/dist/index.js +3 -1
  113. package/packages/skills/dist/index.js.map +1 -1
  114. package/packages/skills/dist/loader.d.ts +179 -0
  115. package/packages/skills/dist/loader.d.ts.map +1 -1
  116. package/packages/skills/dist/loader.js +337 -15
  117. package/packages/skills/dist/loader.js.map +1 -1
  118. package/packages/skills/dist/remote-source.d.ts +107 -0
  119. package/packages/skills/dist/remote-source.d.ts.map +1 -0
  120. package/packages/skills/dist/remote-source.js +79 -0
  121. package/packages/skills/dist/remote-source.js.map +1 -0
  122. package/packages/skills/dist/vector-schema.d.ts +58 -0
  123. package/packages/skills/dist/vector-schema.d.ts.map +1 -0
  124. package/packages/skills/dist/vector-schema.js +55 -0
  125. package/packages/skills/dist/vector-schema.js.map +1 -0
  126. package/packages/skills/package.json +1 -1
  127. package/packages/skills/src/index.ts +16 -2
  128. package/packages/skills/src/loader.ts +458 -39
  129. package/packages/skills/src/remote-source.ts +148 -0
  130. package/packages/skills/src/vector-schema.ts +72 -0
  131. package/packages/skills/test/remote-skills.test.ts +242 -0
  132. package/packages/skills/test/remote-source.test.ts +248 -0
  133. package/packages/sprint-planner/package.json +1 -1
  134. package/packages/subscription-engine/package.json +1 -1
  135. package/packages/task-compiler/package.json +1 -1
  136. package/packages/telegram/package.json +1 -1
  137. package/packages/tencentdb-memory/package.json +1 -1
  138. package/packages/tencentdb-sync/package.json +1 -1
  139. package/packages/test-data-generator/package.json +1 -1
  140. package/packages/test-generator/package.json +1 -1
  141. package/packages/todo-bd-sync/package.json +1 -1
  142. package/packages/token-estimation/package.json +1 -1
  143. package/packages/token-optimizer/package.json +1 -1
  144. package/packages/tooling-preference/package.json +1 -1
  145. package/packages/trajectory/package.json +1 -1
  146. package/packages/tui/package.json +1 -1
  147. package/packages/two-phase-review/package.json +1 -1
  148. package/packages/types/package.json +1 -1
  149. package/packages/voice-api/package.json +1 -1
  150. package/packages/webmcp-tester/package.json +1 -1
  151. package/packages/wiki/package.json +1 -1
  152. package/packages/workflow-events/package.json +1 -1
  153. package/packages/workspace-scanner/package.json +1 -1
  154. package/packages/worktree/package.json +1 -1
  155. package/packages/write-review/package.json +1 -1
  156. package/scripts/sync-to-qdrant.ts +79 -14
@@ -10,14 +10,29 @@ 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, scanSkillDirectory } from "./parser.js";
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.
@@ -31,7 +46,7 @@ export 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,14 +54,72 @@ 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 }>;
46
63
  /** One request for many texts. Absent in an older package build. */
47
64
  createEmbeddingsBatch?: (texts: string[], apiKey?: string) => Promise<{ embeddings: number[][]; tokens: number }>;
48
65
  }
49
66
 
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
+
50
123
  /**
51
124
  * A point of the vector index: the vector and the payload of one skill.
52
125
  */
@@ -86,6 +159,26 @@ export interface SkillRegistryOptions {
86
159
  * requests but holds more of them in memory.
87
160
  */
88
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;
89
182
  }
90
183
 
91
184
  /**
@@ -93,25 +186,44 @@ export interface SkillRegistryOptions {
93
186
  */
94
187
  const DEFAULT_INDEX_BATCH_SIZE = 32;
95
188
 
96
- /** Resolve the Qdrant skill module path (monorepo sibling or npm package) */
97
- async function resolveQdrantModules(): Promise<{
98
- createQdrantClient: (cfg: unknown) => { upsert(c: string, o: unknown): Promise<void>; query(c: string, o: unknown): Promise<unknown>; };
189
+ /** The Qdrant package, as far as the registry needs it. */
190
+ interface QdrantModules {
191
+ createQdrantClient: (cfg: unknown) => QdrantClientLike;
99
192
  createCollection: (client: unknown, name: string, dims: number) => Promise<void>;
100
193
  createEmbedding: (text: string, apiKey?: string) => Promise<{ embedding: number[]; tokens: number }>;
194
+ /** One request for many texts. Absent in an older package build. */
101
195
  createEmbeddingsBatch?: (texts: string[], apiKey?: string) => Promise<{ embeddings: number[][]; tokens: number }>;
102
- } | null> {
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> {
103
206
  // Try the monorepo sibling first (the compiled package, so the module
104
207
  // comes from the same build the app ships).
105
208
  for (const base of ["../../../packages/qdrant-skills/dist", "../../packages/qdrant-skills/dist"]) {
106
209
  try {
107
210
  // A dynamic import: this module is ESM, where `require` does not exist.
108
- const mod = (await import(/* @vite-ignore */ base + "/index.js")) as {
109
- createQdrantClient: unknown;
110
- createCollection: unknown;
111
- createEmbedding: unknown;
112
- createEmbeddingsBatch?: unknown;
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,
113
226
  };
114
- return mod as Awaited<ReturnType<typeof resolveQdrantModules>>;
115
227
  } catch {
116
228
  // Try next path
117
229
  }
@@ -121,21 +233,19 @@ async function resolveQdrantModules(): Promise<{
121
233
  // The specifier is a variable on purpose: the package is optional and
122
234
  // must not be resolved by the type checker.
123
235
  const specifier = "@pi-harness/qdrant-skills";
124
- const mod = (await import(/* @vite-ignore */ specifier)) as {
125
- createQdrantClient: unknown;
126
- createCollection: unknown;
127
- createEmbedding: unknown;
128
- createEmbeddingsBatch?: unknown;
129
- };
236
+ const mod = (await import(/* @vite-ignore */ specifier)) as Record<string, unknown>;
130
237
  return {
131
- createQdrantClient: (cfg: unknown) => (mod.createQdrantClient as Function)(cfg) as { upsert(c: string, o: unknown): Promise<void>; query(c: string, o: unknown): Promise<unknown> },
132
- createCollection: mod.createCollection as (client: unknown, name: string, dims: number) => Promise<void>,
133
- createEmbedding: mod.createEmbedding as (text: string, apiKey?: string) => Promise<{ embedding: number[]; tokens: number }>,
134
- // Absent in an older package build: the registry falls back to one
135
- // request per text, still with a bounded concurrency.
238
+ createQdrantClient: mod.createQdrantClient as QdrantModules["createQdrantClient"],
239
+ createCollection: mod.createCollection as QdrantModules["createCollection"],
240
+ createEmbedding: mod.createEmbedding as QdrantModules["createEmbedding"],
136
241
  createEmbeddingsBatch: typeof mod.createEmbeddingsBatch === "function"
137
- ? (mod.createEmbeddingsBatch as (texts: string[], apiKey?: string) => Promise<{ embeddings: number[][]; tokens: number }>)
242
+ ? (mod.createEmbeddingsBatch as QdrantModules["createEmbeddingsBatch"])
243
+ : undefined,
244
+ getCollectionInfo: typeof mod.getCollectionInfo === "function"
245
+ ? (mod.getCollectionInfo as QdrantModules["getCollectionInfo"])
138
246
  : undefined,
247
+ embeddingModel: typeof mod.EMBEDDING_MODEL === "string" ? mod.EMBEDDING_MODEL : undefined,
248
+ embeddingDimensions: typeof mod.EMBEDDING_DIMENSIONS === "number" ? mod.EMBEDDING_DIMENSIONS : undefined,
139
249
  };
140
250
  } catch {
141
251
  return null;
@@ -176,7 +286,10 @@ export function getQdrantConfig(): {
176
286
  return {
177
287
  url,
178
288
  apiKey,
179
- collection: process.env.QDRANT_COLLECTION || "pi-harness-skills",
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,
180
293
  };
181
294
  }
182
295
 
@@ -185,6 +298,29 @@ export function getQdrantConfig(): {
185
298
  */
186
299
  export class SkillRegistry {
187
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;
188
324
  private index: SkillIndex = { entries: [], loadedAt: 0 };
189
325
  private loading: Map<string, Promise<Skill>> = new Map();
190
326
 
@@ -210,6 +346,7 @@ export class SkillRegistry {
210
346
  private _qdrantIndexer: SkillVectorIndexer | null = null;
211
347
 
212
348
  constructor(options: SkillRegistryOptions = {}) {
349
+ this._options = options;
213
350
  this._indexer = options.indexer;
214
351
  this._batchSize = options.batchSize ?? DEFAULT_INDEX_BATCH_SIZE;
215
352
  }
@@ -243,6 +380,21 @@ export class SkillRegistry {
243
380
  }
244
381
 
245
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
+
246
398
  const config = getQdrantConfig();
247
399
  if (!config) {
248
400
  this._qdrantInitFailed = true;
@@ -261,7 +413,11 @@ export class SkillRegistry {
261
413
  url: config.url,
262
414
  apiKey: config.apiKey,
263
415
  });
264
- await mods.createCollection(client, config.collection, 1536);
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);
265
421
  this._qdrant = {
266
422
  client,
267
423
  embedder: {
@@ -277,6 +433,110 @@ export class SkillRegistry {
277
433
  }
278
434
  }
279
435
 
436
+ /**
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.
461
+ */
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 };
472
+
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
+
280
540
  /** The point of a skill in the vector index. */
281
541
  private _toQdrantPoint(skill: Skill): QdrantSkillPoint {
282
542
  return {
@@ -453,7 +713,97 @@ export class SkillRegistry {
453
713
  * Get a skill by ID
454
714
  */
455
715
  get(skillId: string): Skill | undefined {
456
- return this.skills.get(skillId);
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";
457
807
  }
458
808
 
459
809
  /**
@@ -567,10 +917,38 @@ export class SkillRegistry {
567
917
 
568
918
  // --- Qdrant vector search --------------------------------------------------
569
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
+
570
943
  /**
571
944
  * Search skills by semantic similarity using Qdrant.
572
945
  * Falls back to an empty result if Qdrant is not available.
573
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
+ *
574
952
  * @param query Natural-language query
575
953
  * @param limit Max results (default 5)
576
954
  * @param threshold Minimum cosine score (default 0.5)
@@ -579,7 +957,7 @@ export class SkillRegistry {
579
957
  query: string,
580
958
  limit = 5,
581
959
  threshold = 0.5,
582
- ): Promise<Array<{ skill: Skill; score: number }>> {
960
+ ): Promise<Array<{ skill: Skill; score: number; source: "local" | "remote" }>> {
583
961
  await this._ensureQdrant();
584
962
  if (!this._qdrant?.ready) return [];
585
963
 
@@ -593,12 +971,21 @@ export class SkillRegistry {
593
971
  const raw = await this._qdrant.client.query(this._qdrant.collection, queryOpts) as {
594
972
  points?: Array<{ id: number; score: number; payload?: Record<string, unknown> }> };
595
973
 
596
- const results: Array<{ skill: Skill; score: number }> = [];
974
+ const results: Array<{ skill: Skill; score: number; source: "local" | "remote" }> = [];
597
975
  for (const pt of raw.points ?? []) {
598
976
  if (pt.score < threshold) continue;
599
- const name = pt.payload?.name as string | undefined;
600
- const skill = name ? this.skills.get(name) : undefined;
601
- if (skill) results.push({ skill, score: pt.score });
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" });
602
989
  }
603
990
  return results;
604
991
  } catch (err) {
@@ -648,7 +1035,36 @@ export class SkillRegistry {
648
1035
  * Load a skill's full content (progressive disclosure)
649
1036
  */
650
1037
  async load(skillId: string, options?: LoadOptions): Promise<LoadedSkill | null> {
651
- const skill = this.skills.get(skillId);
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
+
652
1068
  if (!skill) {
653
1069
  return null;
654
1070
  }
@@ -685,10 +1101,12 @@ export class SkillRegistry {
685
1101
  options?: LoadOptions
686
1102
  ): Promise<LoadedSkill> {
687
1103
  const loadedSkill: LoadedSkill = { ...skill };
688
- const skillMdPath = join(skill.path, "SKILL.md");
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") : "";
689
1107
 
690
1108
  // Re-parse to get fresh content
691
- if (existsSync(skillMdPath)) {
1109
+ if (skillMdPath && existsSync(skillMdPath)) {
692
1110
  const parsed = parseSkillFile(skillMdPath);
693
1111
  loadedSkill.body = parsed.body;
694
1112
  loadedSkill.frontmatter = parsed.frontmatter;
@@ -770,6 +1188,7 @@ export class SkillRegistry {
770
1188
  */
771
1189
  clear(): void {
772
1190
  this.skills.clear();
1191
+ this.remoteSkills.clear();
773
1192
  this.index = { entries: [], loadedAt: 0 };
774
1193
  this.loading.clear();
775
1194
  }
@@ -785,7 +1204,7 @@ export class SkillRegistry {
785
1204
  * Check if skill exists
786
1205
  */
787
1206
  has(skillId: string): boolean {
788
- return this.skills.has(skillId);
1207
+ return this.skills.has(skillId) || this.remoteSkills.has(skillId);
789
1208
  }
790
1209
  }
791
1210