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.
- 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/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/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- 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-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 +10 -0
- package/packages/qdrant-skills/dist/embedder.d.ts.map +1 -1
- package/packages/qdrant-skills/dist/embedder.js +12 -2
- 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 +14 -2
- package/packages/qdrant-skills/src/index.ts +2 -0
- package/packages/qdrant-skills/test/collection-info.test.ts +129 -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 +14 -1
- package/packages/skills/dist/index.d.ts.map +1 -1
- package/packages/skills/dist/index.js +3 -1
- package/packages/skills/dist/index.js.map +1 -1
- package/packages/skills/dist/loader.d.ts +179 -0
- package/packages/skills/dist/loader.d.ts.map +1 -1
- package/packages/skills/dist/loader.js +337 -15
- 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 +16 -2
- package/packages/skills/src/loader.ts +458 -39
- package/packages/skills/src/remote-source.ts +148 -0
- package/packages/skills/src/vector-schema.ts +72 -0
- package/packages/skills/test/remote-skills.test.ts +242 -0
- package/packages/skills/test/remote-source.test.ts +248 -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/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/sync-to-qdrant.ts +79 -14
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server-held skills.
|
|
3
|
+
*
|
|
4
|
+
* The vector store holds an INDEX: the name, the description and a
|
|
5
|
+
* truncated body. That is enough to FIND a skill, but not to use it - a
|
|
6
|
+
* complete SKILL.md (with its references and examples) is larger than a
|
|
7
|
+
* vector store payload should be.
|
|
8
|
+
*
|
|
9
|
+
* The remote source is therefore the authoritative store of the complete
|
|
10
|
+
* skill content. It is reached through an authenticated service, so the
|
|
11
|
+
* SERVER decides which skills this user may see. A client never decides
|
|
12
|
+
* that on its own.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Skill } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/** A skill the server holds. */
|
|
18
|
+
export interface RemoteSkill {
|
|
19
|
+
/** The id the server knows the skill by. */
|
|
20
|
+
id: string;
|
|
21
|
+
/** The name of the skill. */
|
|
22
|
+
name: string;
|
|
23
|
+
/** What the skill does. */
|
|
24
|
+
description: string;
|
|
25
|
+
/** The version of the content. */
|
|
26
|
+
version?: string;
|
|
27
|
+
/** When the content changed the last time. */
|
|
28
|
+
updatedAt?: string;
|
|
29
|
+
/** The content of the skill (the body of SKILL.md). */
|
|
30
|
+
content?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Whether the content is the COMPLETE skill.
|
|
33
|
+
*
|
|
34
|
+
* A vector store payload carries a truncated body - that is not a
|
|
35
|
+
* complete skill. The service returns the whole thing, so it sets
|
|
36
|
+
* this flag.
|
|
37
|
+
*/
|
|
38
|
+
contentComplete?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the server-held skills come from. */
|
|
42
|
+
export interface RemoteSkillSource {
|
|
43
|
+
/**
|
|
44
|
+
* The skills the server holds for this user and workspace.
|
|
45
|
+
*
|
|
46
|
+
* @param options The search term and the number of skills to return.
|
|
47
|
+
*/
|
|
48
|
+
list(options?: { search?: string; limit?: number }): Promise<RemoteSkill[]>;
|
|
49
|
+
/** The complete content of one skill. */
|
|
50
|
+
get(id: string): Promise<RemoteSkill | null>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The reasons a call to the remote source can fail. */
|
|
54
|
+
export type RemoteSkillFailure =
|
|
55
|
+
/** The service refused the call - the token is missing or wrong. */
|
|
56
|
+
| "denied"
|
|
57
|
+
/** The service could not be reached. */
|
|
58
|
+
| "unavailable";
|
|
59
|
+
|
|
60
|
+
/** The state of the server-held skills. */
|
|
61
|
+
export type RemoteSkillState =
|
|
62
|
+
/** No service is configured. */
|
|
63
|
+
| "not-configured"
|
|
64
|
+
/** The service refused the call. */
|
|
65
|
+
| "denied"
|
|
66
|
+
/** The service could not be reached. */
|
|
67
|
+
| "unavailable"
|
|
68
|
+
/** The service holds no skill for this user. */
|
|
69
|
+
| "empty"
|
|
70
|
+
/** The service holds skills. */
|
|
71
|
+
| "populated";
|
|
72
|
+
|
|
73
|
+
/** What the remote source knows. */
|
|
74
|
+
export interface RemoteSkillStats {
|
|
75
|
+
/** The state of the source. */
|
|
76
|
+
state: RemoteSkillState;
|
|
77
|
+
/** The number of skills the source holds. */
|
|
78
|
+
count: number;
|
|
79
|
+
/** The reason of a failed call. */
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A skill from a remote record.
|
|
85
|
+
*
|
|
86
|
+
* The record carries the content, so the skill is usable without a local
|
|
87
|
+
* copy. The path stays empty: a server skill has no directory on this
|
|
88
|
+
* machine.
|
|
89
|
+
*/
|
|
90
|
+
export function skillFromRemote(record: RemoteSkill): Skill {
|
|
91
|
+
const version = record.version;
|
|
92
|
+
return {
|
|
93
|
+
id: record.id,
|
|
94
|
+
path: "",
|
|
95
|
+
frontmatter: {
|
|
96
|
+
name: record.name,
|
|
97
|
+
description: record.description,
|
|
98
|
+
...(version ? { version } : {}),
|
|
99
|
+
},
|
|
100
|
+
body: record.content ?? "",
|
|
101
|
+
references: [],
|
|
102
|
+
examples: [],
|
|
103
|
+
metadata: {
|
|
104
|
+
name: record.name,
|
|
105
|
+
description: record.description,
|
|
106
|
+
...(version ? { version } : {}),
|
|
107
|
+
...(record.updatedAt ? { updated_at: record.updatedAt } : {}),
|
|
108
|
+
},
|
|
109
|
+
loaded: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The version of a cached remote skill.
|
|
115
|
+
*
|
|
116
|
+
* A skill without a version cannot be compared - the cache keeps it.
|
|
117
|
+
*/
|
|
118
|
+
export function remoteVersion(skill: Skill | undefined): string | undefined {
|
|
119
|
+
return skill?.frontmatter.version ?? skill?.metadata.version;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The safe reason of a failed call.
|
|
124
|
+
*
|
|
125
|
+
* The message never carries a credential: a token that a service echoed
|
|
126
|
+
* back is removed before the message leaves the process.
|
|
127
|
+
*/
|
|
128
|
+
export function describeRemoteFailure(error: unknown): string {
|
|
129
|
+
const raw = error instanceof Error ? error.message : String(error ?? "");
|
|
130
|
+
const safe = raw
|
|
131
|
+
.replace(/bearer\s+\S+/gi, "bearer <redacted>")
|
|
132
|
+
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, "<redacted>")
|
|
133
|
+
.replace(/\s+/g, " ")
|
|
134
|
+
.trim();
|
|
135
|
+
return safe.slice(0, 200) || "the service could not be reached";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the cached skill is older than the record.
|
|
140
|
+
*
|
|
141
|
+
* A record without a version never replaces a cached skill: an unknown
|
|
142
|
+
* version is not evidence of a change.
|
|
143
|
+
*/
|
|
144
|
+
export function isStale(cached: Skill | undefined, record: RemoteSkill): boolean {
|
|
145
|
+
if (!cached) return true;
|
|
146
|
+
if (!record.version) return false;
|
|
147
|
+
return remoteVersion(cached) !== record.version;
|
|
148
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vector schema of the skill collection.
|
|
3
|
+
*
|
|
4
|
+
* A Qdrant collection holds vectors of exactly ONE size. Two different
|
|
5
|
+
* embedding models produce two different vector sizes, so they must never
|
|
6
|
+
* write into the same collection: the second writer is rejected by the
|
|
7
|
+
* server with a dimension error, and the data of the first writer becomes
|
|
8
|
+
* unsearchable by the second.
|
|
9
|
+
*
|
|
10
|
+
* The collection name carries the model and a version, so a change of the
|
|
11
|
+
* model leads to a NEW collection instead of a silent mix of two vector
|
|
12
|
+
* spaces.
|
|
13
|
+
*
|
|
14
|
+
* The model itself lives in the Qdrant package (the embedder owns it). This
|
|
15
|
+
* module owns the collection - that is a decision of the skills domain, not
|
|
16
|
+
* of the vector store.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The collection the runtime indexes into and searches.
|
|
21
|
+
*
|
|
22
|
+
* `QDRANT_COLLECTION` overrides it, so a cluster that already holds a
|
|
23
|
+
* collection under another name keeps working.
|
|
24
|
+
*/
|
|
25
|
+
export const SKILL_COLLECTION = "pi-harness-skills-openai-v1";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The collection that was used before the schema was versioned.
|
|
29
|
+
*
|
|
30
|
+
* It holds 384-dimensional vectors - the model that
|
|
31
|
+
* `scripts/sync-to-qdrant.ts` used - and is NOT compatible with the
|
|
32
|
+
* runtime. It is named here so a mismatch can name the real cause instead
|
|
33
|
+
* of only the numbers.
|
|
34
|
+
*/
|
|
35
|
+
export const LEGACY_SKILL_COLLECTION = "pi-harness-skills";
|
|
36
|
+
|
|
37
|
+
/** The model of the legacy collection. */
|
|
38
|
+
export const LEGACY_SKILL_COLLECTION_MODEL = "sentence-transformers/all-MiniLM-L6-v2";
|
|
39
|
+
|
|
40
|
+
/** The vector size of the legacy collection. */
|
|
41
|
+
export const LEGACY_SKILL_COLLECTION_DIMENSIONS = 384;
|
|
42
|
+
|
|
43
|
+
/** The schema a skill collection is expected to have. */
|
|
44
|
+
export interface SkillVectorSchema {
|
|
45
|
+
/** The vector size. */
|
|
46
|
+
dimensions: number;
|
|
47
|
+
/** The distance metric. */
|
|
48
|
+
distance: string;
|
|
49
|
+
/** The embedding model, when it is known. */
|
|
50
|
+
model?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Why the collection does not match the expected schema - or null when it
|
|
55
|
+
* does.
|
|
56
|
+
*
|
|
57
|
+
* The message names the numbers, so the reader can tell WHICH schema the
|
|
58
|
+
* collection was built with.
|
|
59
|
+
*/
|
|
60
|
+
export function describeSkillSchemaMismatch(
|
|
61
|
+
actual: { dimensions?: number; distance?: string },
|
|
62
|
+
expected: SkillVectorSchema,
|
|
63
|
+
): string | null {
|
|
64
|
+
if (actual.dimensions !== undefined && actual.dimensions !== expected.dimensions) {
|
|
65
|
+
const model = expected.model ? ` (${expected.model})` : "";
|
|
66
|
+
return `the collection holds ${actual.dimensions}-dimensional vectors, the runtime embeds with ${expected.dimensions}${model}`;
|
|
67
|
+
}
|
|
68
|
+
if (actual.distance !== undefined && actual.distance !== expected.distance) {
|
|
69
|
+
return `the collection uses the ${actual.distance} distance, the runtime expects ${expected.distance}`;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skills that only exist in the vector store.
|
|
3
|
+
*
|
|
4
|
+
* The vector store holds the skills of a team. A client that has no local
|
|
5
|
+
* copy of a skill must still be able to find it - and to use it: the
|
|
6
|
+
* payload carries the name, the description and the body.
|
|
7
|
+
*
|
|
8
|
+
* The registry used to DROP every hit whose skill was not registered
|
|
9
|
+
* locally, so a shared skill was invisible on every machine that had no
|
|
10
|
+
* local copy of it - which is exactly the case the vector store exists for.
|
|
11
|
+
*
|
|
12
|
+
* The client and the embedder are injected, so the test neither reaches the
|
|
13
|
+
* network nor replaces a process-global module.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createSkillRegistry,
|
|
20
|
+
type QdrantClientLike,
|
|
21
|
+
type QdrantEmbedderLike,
|
|
22
|
+
} from "../src/loader.js";
|
|
23
|
+
import type { Skill } from "../src/types.js";
|
|
24
|
+
|
|
25
|
+
/** A point of the vector store. */
|
|
26
|
+
interface VectorPoint {
|
|
27
|
+
id: number;
|
|
28
|
+
score: number;
|
|
29
|
+
payload?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A client that answers a query with the given points. */
|
|
33
|
+
function fakeClient(
|
|
34
|
+
points: VectorPoint[],
|
|
35
|
+
description?: Record<string, unknown>,
|
|
36
|
+
): QdrantClientLike & { queries: number } {
|
|
37
|
+
const client = {
|
|
38
|
+
queries: 0,
|
|
39
|
+
async upsert(): Promise<void> {},
|
|
40
|
+
async query(): Promise<unknown> {
|
|
41
|
+
client.queries += 1;
|
|
42
|
+
return { points };
|
|
43
|
+
},
|
|
44
|
+
async getCollection(): Promise<unknown> {
|
|
45
|
+
if (!description) throw new Error("Not found: Collection not found");
|
|
46
|
+
return description;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
return client;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An embedder that returns a fixed vector. */
|
|
53
|
+
const fakeEmbedder: QdrantEmbedderLike = {
|
|
54
|
+
async createEmbedding() {
|
|
55
|
+
return { embedding: [0.1, 0.2, 0.3], tokens: 3 };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** A local skill, as the scanner would register it. */
|
|
60
|
+
function localSkill(id: string): Skill {
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
path: `/tmp/skills/${id}`,
|
|
64
|
+
frontmatter: { name: id, description: `the local ${id}` },
|
|
65
|
+
body: `the local body of ${id}`,
|
|
66
|
+
references: [],
|
|
67
|
+
examples: [],
|
|
68
|
+
metadata: { name: id, description: `the local ${id}` },
|
|
69
|
+
loaded: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("a skill that only exists in the vector store", () => {
|
|
74
|
+
it("is found instead of being dropped", async () => {
|
|
75
|
+
const client = fakeClient([
|
|
76
|
+
{
|
|
77
|
+
id: 19,
|
|
78
|
+
score: 0.91,
|
|
79
|
+
payload: {
|
|
80
|
+
name: "frappe-doctype-field-order",
|
|
81
|
+
description: "the shared skill",
|
|
82
|
+
body: "the shared body",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
const registry = createSkillRegistry({
|
|
87
|
+
qdrantClient: client,
|
|
88
|
+
embedder: fakeEmbedder,
|
|
89
|
+
collection: "pi-harness-skills-test",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const results = await registry.findVector("field order");
|
|
93
|
+
|
|
94
|
+
expect(results).toHaveLength(1);
|
|
95
|
+
expect(results[0].source).toBe("remote");
|
|
96
|
+
expect(results[0].skill.id).toBe("frappe-doctype-field-order");
|
|
97
|
+
// The payload is the skill - the body travels with it.
|
|
98
|
+
expect(results[0].skill.body).toBe("the shared body");
|
|
99
|
+
expect(results[0].skill.frontmatter.description).toBe("the shared skill");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not count as a local skill", async () => {
|
|
103
|
+
const client = fakeClient([
|
|
104
|
+
{ id: 19, score: 0.9, payload: { name: "shared", description: "d", body: "b" } },
|
|
105
|
+
]);
|
|
106
|
+
const registry = createSkillRegistry({
|
|
107
|
+
qdrantClient: client,
|
|
108
|
+
embedder: fakeEmbedder,
|
|
109
|
+
collection: "pi-harness-skills-test",
|
|
110
|
+
});
|
|
111
|
+
registry.register(localSkill("local-one"));
|
|
112
|
+
|
|
113
|
+
await registry.findVector("shared");
|
|
114
|
+
|
|
115
|
+
// The local count stays local - that is what the startup line shows.
|
|
116
|
+
expect(registry.list()).toHaveLength(1);
|
|
117
|
+
expect(registry.list().map((skill) => skill.id)).toEqual(["local-one"]);
|
|
118
|
+
expect(registry.listRemote().map((skill) => skill.id)).toEqual(["shared"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("can be loaded without a local directory", async () => {
|
|
122
|
+
const client = fakeClient([
|
|
123
|
+
{ id: 19, score: 0.9, payload: { name: "shared", description: "d", body: "the body" } },
|
|
124
|
+
]);
|
|
125
|
+
const registry = createSkillRegistry({
|
|
126
|
+
qdrantClient: client,
|
|
127
|
+
embedder: fakeEmbedder,
|
|
128
|
+
collection: "pi-harness-skills-test",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await registry.findVector("shared");
|
|
132
|
+
const loaded = await registry.load("shared");
|
|
133
|
+
|
|
134
|
+
expect(loaded).not.toBeNull();
|
|
135
|
+
expect(loaded?.body).toBe("the body");
|
|
136
|
+
expect(loaded?.path).toBe("");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("marks a local hit as local", async () => {
|
|
140
|
+
const client = fakeClient([
|
|
141
|
+
{ id: 1, score: 0.9, payload: { name: "local-one", description: "d", body: "b" } },
|
|
142
|
+
]);
|
|
143
|
+
const registry = createSkillRegistry({
|
|
144
|
+
qdrantClient: client,
|
|
145
|
+
embedder: fakeEmbedder,
|
|
146
|
+
collection: "pi-harness-skills-test",
|
|
147
|
+
});
|
|
148
|
+
registry.register(localSkill("local-one"));
|
|
149
|
+
|
|
150
|
+
const results = await registry.findVector("local");
|
|
151
|
+
|
|
152
|
+
expect(results).toHaveLength(1);
|
|
153
|
+
expect(results[0].source).toBe("local");
|
|
154
|
+
expect(results[0].skill.path).toBe("/tmp/skills/local-one");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("skips a hit without a name", async () => {
|
|
158
|
+
const client = fakeClient([
|
|
159
|
+
{ id: 1, score: 0.9, payload: { description: "no name" } },
|
|
160
|
+
{ id: 2, score: 0.8, payload: { name: "", description: "empty name" } },
|
|
161
|
+
]);
|
|
162
|
+
const registry = createSkillRegistry({
|
|
163
|
+
qdrantClient: client,
|
|
164
|
+
embedder: fakeEmbedder,
|
|
165
|
+
collection: "pi-harness-skills-test",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(await registry.findVector("anything")).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("the state of the vector store", () => {
|
|
173
|
+
it("reports the number of skills", async () => {
|
|
174
|
+
const client = fakeClient([], {
|
|
175
|
+
points_count: 79,
|
|
176
|
+
config: { params: { vectors: { size: 1536, distance: "Cosine" } } },
|
|
177
|
+
});
|
|
178
|
+
const registry = createSkillRegistry({
|
|
179
|
+
qdrantClient: client,
|
|
180
|
+
embedder: fakeEmbedder,
|
|
181
|
+
collection: "pi-harness-skills-openai-v1",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const stats = await registry.getQdrantStats();
|
|
185
|
+
|
|
186
|
+
expect(stats.state).toBe("populated");
|
|
187
|
+
expect(stats.count).toBe(79);
|
|
188
|
+
expect(stats.dimensions).toBe(1536);
|
|
189
|
+
expect(stats.schemaMismatch).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("names the real cause of an incompatible collection", async () => {
|
|
193
|
+
const client = fakeClient([], {
|
|
194
|
+
points_count: 79,
|
|
195
|
+
config: { params: { vectors: { size: 384, distance: "Cosine" } } },
|
|
196
|
+
});
|
|
197
|
+
const registry = createSkillRegistry({
|
|
198
|
+
qdrantClient: client,
|
|
199
|
+
embedder: fakeEmbedder,
|
|
200
|
+
collection: "pi-harness-skills",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const stats = await registry.getQdrantStats();
|
|
204
|
+
|
|
205
|
+
// The mismatch names the numbers, so the reader knows WHICH schema
|
|
206
|
+
// the collection was built with.
|
|
207
|
+
expect(stats.schemaMismatch).toContain("384");
|
|
208
|
+
expect(stats.schemaMismatch).toContain("1536");
|
|
209
|
+
expect(stats.schemaMismatch).toContain("sync-to-qdrant.ts");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("reports an empty collection", async () => {
|
|
213
|
+
const client = fakeClient([], {
|
|
214
|
+
points_count: 0,
|
|
215
|
+
config: { params: { vectors: { size: 1536, distance: "Cosine" } } },
|
|
216
|
+
});
|
|
217
|
+
const registry = createSkillRegistry({
|
|
218
|
+
qdrantClient: client,
|
|
219
|
+
embedder: fakeEmbedder,
|
|
220
|
+
collection: "pi-harness-skills-openai-v1",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const stats = await registry.getQdrantStats();
|
|
224
|
+
|
|
225
|
+
expect(stats.state).toBe("empty");
|
|
226
|
+
expect(stats.count).toBe(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("reports a collection that does not exist", async () => {
|
|
230
|
+
const client = fakeClient([]);
|
|
231
|
+
const registry = createSkillRegistry({
|
|
232
|
+
qdrantClient: client,
|
|
233
|
+
embedder: fakeEmbedder,
|
|
234
|
+
collection: "pi-harness-skills-openai-v1",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const stats = await registry.getQdrantStats();
|
|
238
|
+
|
|
239
|
+
expect(stats.state).toBe("missing");
|
|
240
|
+
expect(stats.count).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skills that live on the server.
|
|
3
|
+
*
|
|
4
|
+
* The vector store is an index - it says WHICH skill fits a question. The
|
|
5
|
+
* service holds the authoritative files, so a machine without a local copy
|
|
6
|
+
* can still use a skill.
|
|
7
|
+
*
|
|
8
|
+
* The states must stay apart: a service that refused the call needs a
|
|
9
|
+
* different fix than a service that could not be reached, and both differ
|
|
10
|
+
* from a service that holds nothing.
|
|
11
|
+
*
|
|
12
|
+
* The source is injected, so the test neither reaches the network nor
|
|
13
|
+
* replaces a process-global module.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import { createSkillRegistry } from "../src/loader.js";
|
|
19
|
+
import type { RemoteSkill, RemoteSkillSource } from "../src/remote-source.js";
|
|
20
|
+
|
|
21
|
+
/** A source that holds the given skills. */
|
|
22
|
+
class FakeSource implements RemoteSkillSource {
|
|
23
|
+
/** The skills the source holds. */
|
|
24
|
+
readonly skills: RemoteSkill[];
|
|
25
|
+
/** The ids that were asked for. */
|
|
26
|
+
readonly requested: string[] = [];
|
|
27
|
+
/** The error the next call raises. */
|
|
28
|
+
failure: { message: string; code: "denied" | "unavailable" } | null = null;
|
|
29
|
+
|
|
30
|
+
constructor(skills: RemoteSkill[] = []) {
|
|
31
|
+
this.skills = skills;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async list(): Promise<RemoteSkill[]> {
|
|
35
|
+
if (this.failure) {
|
|
36
|
+
const error = new Error(this.failure.message) as Error & {
|
|
37
|
+
code: string;
|
|
38
|
+
};
|
|
39
|
+
error.code = this.failure.code;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return this.skills;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(id: string): Promise<RemoteSkill | null> {
|
|
46
|
+
this.requested.push(id);
|
|
47
|
+
if (this.failure) {
|
|
48
|
+
const error = new Error(this.failure.message) as Error & {
|
|
49
|
+
code: string;
|
|
50
|
+
};
|
|
51
|
+
error.code = this.failure.code;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
return this.skills.find((skill) => skill.id === id) ?? null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("the skills of the server", () => {
|
|
59
|
+
it("are listed by the registry", async () => {
|
|
60
|
+
const source = new FakeSource([
|
|
61
|
+
{
|
|
62
|
+
id: "shared-skill",
|
|
63
|
+
name: "shared-skill",
|
|
64
|
+
description: "the shared skill",
|
|
65
|
+
version: "1.2.0",
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
const registry = createSkillRegistry();
|
|
69
|
+
registry.setRemoteSource(source);
|
|
70
|
+
|
|
71
|
+
const stats = await registry.refreshRemoteSkills();
|
|
72
|
+
|
|
73
|
+
expect(stats.state).toBe("populated");
|
|
74
|
+
expect(stats.count).toBe(1);
|
|
75
|
+
expect(registry.listRemote().map((skill) => skill.id)).toEqual(["shared-skill"]);
|
|
76
|
+
// The local count stays local.
|
|
77
|
+
expect(registry.list()).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("are not local skills", async () => {
|
|
81
|
+
const source = new FakeSource([
|
|
82
|
+
{ id: "shared-skill", name: "shared-skill", description: "d", version: "1.0.0" },
|
|
83
|
+
]);
|
|
84
|
+
const registry = createSkillRegistry();
|
|
85
|
+
registry.setRemoteSource(source);
|
|
86
|
+
await registry.refreshRemoteSkills();
|
|
87
|
+
|
|
88
|
+
expect(registry.list()).toHaveLength(0);
|
|
89
|
+
expect(registry.listRemote()).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("replace the cached skill when the version changed", async () => {
|
|
93
|
+
const source = new FakeSource([
|
|
94
|
+
{ id: "shared-skill", name: "shared-skill", description: "old", version: "1.0.0" },
|
|
95
|
+
]);
|
|
96
|
+
const registry = createSkillRegistry();
|
|
97
|
+
registry.setRemoteSource(source);
|
|
98
|
+
await registry.refreshRemoteSkills();
|
|
99
|
+
expect(registry.listRemote()[0].frontmatter.description).toBe("old");
|
|
100
|
+
|
|
101
|
+
// The server publishes a new version.
|
|
102
|
+
source.skills[0] = {
|
|
103
|
+
id: "shared-skill",
|
|
104
|
+
name: "shared-skill",
|
|
105
|
+
description: "new",
|
|
106
|
+
version: "1.1.0",
|
|
107
|
+
};
|
|
108
|
+
await registry.refreshRemoteSkills();
|
|
109
|
+
|
|
110
|
+
expect(registry.listRemote()[0].frontmatter.description).toBe("new");
|
|
111
|
+
expect(registry.listRemote()[0].frontmatter.version).toBe("1.1.0");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stay when the version is unknown", async () => {
|
|
115
|
+
const source = new FakeSource([
|
|
116
|
+
{ id: "shared-skill", name: "shared-skill", description: "known", version: "1.0.0" },
|
|
117
|
+
]);
|
|
118
|
+
const registry = createSkillRegistry();
|
|
119
|
+
registry.setRemoteSource(source);
|
|
120
|
+
await registry.refreshRemoteSkills();
|
|
121
|
+
|
|
122
|
+
// A record without a version is not evidence of a change.
|
|
123
|
+
source.skills[0] = {
|
|
124
|
+
id: "shared-skill",
|
|
125
|
+
name: "shared-skill",
|
|
126
|
+
description: "changed without a version",
|
|
127
|
+
};
|
|
128
|
+
await registry.refreshRemoteSkills();
|
|
129
|
+
|
|
130
|
+
expect(registry.listRemote()[0].frontmatter.description).toBe("known");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("are loaded completely from the service", async () => {
|
|
134
|
+
const source = new FakeSource([
|
|
135
|
+
{
|
|
136
|
+
id: "shared-skill",
|
|
137
|
+
name: "shared-skill",
|
|
138
|
+
description: "the shared skill",
|
|
139
|
+
version: "1.0.0",
|
|
140
|
+
content: "the complete body of the skill",
|
|
141
|
+
contentComplete: true,
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
const registry = createSkillRegistry();
|
|
145
|
+
registry.setRemoteSource(source);
|
|
146
|
+
|
|
147
|
+
const loaded = await registry.load("shared-skill");
|
|
148
|
+
|
|
149
|
+
expect(loaded).not.toBeNull();
|
|
150
|
+
expect(loaded?.body).toBe("the complete body of the skill");
|
|
151
|
+
expect(source.requested).toContain("shared-skill");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("are not asked for again once the content is complete", async () => {
|
|
155
|
+
const source = new FakeSource([
|
|
156
|
+
{
|
|
157
|
+
id: "shared-skill",
|
|
158
|
+
name: "shared-skill",
|
|
159
|
+
description: "d",
|
|
160
|
+
version: "1.0.0",
|
|
161
|
+
content: "the body",
|
|
162
|
+
contentComplete: true,
|
|
163
|
+
},
|
|
164
|
+
]);
|
|
165
|
+
const registry = createSkillRegistry();
|
|
166
|
+
registry.setRemoteSource(source);
|
|
167
|
+
|
|
168
|
+
await registry.load("shared-skill");
|
|
169
|
+
const afterFirst = source.requested.length;
|
|
170
|
+
await registry.load("shared-skill");
|
|
171
|
+
|
|
172
|
+
expect(source.requested.length).toBe(afterFirst);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("are reported as empty when the service holds nothing", async () => {
|
|
176
|
+
const registry = createSkillRegistry();
|
|
177
|
+
registry.setRemoteSource(new FakeSource([]));
|
|
178
|
+
|
|
179
|
+
const stats = await registry.refreshRemoteSkills();
|
|
180
|
+
|
|
181
|
+
expect(stats.state).toBe("empty");
|
|
182
|
+
expect(stats.count).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("are reported as not configured without a service", async () => {
|
|
186
|
+
const registry = createSkillRegistry();
|
|
187
|
+
|
|
188
|
+
const stats = await registry.getRemoteStats();
|
|
189
|
+
|
|
190
|
+
expect(stats.state).toBe("not-configured");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("a service that does not answer", () => {
|
|
195
|
+
it("reports a refusal as denied", async () => {
|
|
196
|
+
const source = new FakeSource();
|
|
197
|
+
source.failure = { message: "unauthorized", code: "denied" };
|
|
198
|
+
const registry = createSkillRegistry();
|
|
199
|
+
registry.setRemoteSource(source);
|
|
200
|
+
|
|
201
|
+
const stats = await registry.refreshRemoteSkills();
|
|
202
|
+
|
|
203
|
+
expect(stats.state).toBe("denied");
|
|
204
|
+
expect(stats.error).toContain("unauthorized");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("reports an unreachable service as unavailable", async () => {
|
|
208
|
+
const source = new FakeSource();
|
|
209
|
+
source.failure = { message: "connection refused", code: "unavailable" };
|
|
210
|
+
const registry = createSkillRegistry();
|
|
211
|
+
registry.setRemoteSource(source);
|
|
212
|
+
|
|
213
|
+
const stats = await registry.refreshRemoteSkills();
|
|
214
|
+
|
|
215
|
+
expect(stats.state).toBe("unavailable");
|
|
216
|
+
expect(stats.error).toContain("connection refused");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("keeps the cached skills after a failed refresh", async () => {
|
|
220
|
+
const source = new FakeSource([
|
|
221
|
+
{ id: "shared-skill", name: "shared-skill", description: "d", version: "1.0.0" },
|
|
222
|
+
]);
|
|
223
|
+
const registry = createSkillRegistry();
|
|
224
|
+
registry.setRemoteSource(source);
|
|
225
|
+
await registry.refreshRemoteSkills();
|
|
226
|
+
|
|
227
|
+
source.failure = { message: "connection refused", code: "unavailable" };
|
|
228
|
+
const stats = await registry.refreshRemoteSkills();
|
|
229
|
+
|
|
230
|
+
expect(stats.state).toBe("unavailable");
|
|
231
|
+
// A failed refresh must not throw the cached skills away.
|
|
232
|
+
expect(registry.listRemote()).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("does not put a credential into the message", async () => {
|
|
236
|
+
const source = new FakeSource();
|
|
237
|
+
source.failure = {
|
|
238
|
+
message: "Bearer sk-abcdefghijklmnopqrstuvwxyz0123456789 refused",
|
|
239
|
+
code: "denied",
|
|
240
|
+
};
|
|
241
|
+
const registry = createSkillRegistry();
|
|
242
|
+
registry.setRemoteSource(source);
|
|
243
|
+
|
|
244
|
+
const stats = await registry.refreshRemoteSkills();
|
|
245
|
+
|
|
246
|
+
expect(stats.error).not.toContain("sk-abcdefghijklmnopqrstuvwxyz0123456789");
|
|
247
|
+
});
|
|
248
|
+
});
|