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