@peakflo/agent-harness 0.2.4-rc.1 → 0.2.4-rc.2

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.
@@ -24,6 +24,14 @@
24
24
  * every heartbeat rather than trusting the registration snapshot.
25
25
  */
26
26
  import { RunnerCapabilities } from './types';
27
+ /**
28
+ * What this harness build can do beyond the baseline. The control plane reads
29
+ * these to decide what to send: a feature missing here is never sent to.
30
+ *
31
+ * - `spaceFiles`: syncs `space-files/` (read-only) and serves
32
+ * `knowledge_files_list` / `knowledge_file_download` (P9).
33
+ */
34
+ export declare const HARNESS_FEATURES: readonly ["spaceFiles"];
27
35
  export interface ProbeResult {
28
36
  installed: boolean;
29
37
  version: string | null;
@@ -25,6 +25,7 @@
25
25
  * every heartbeat rather than trusting the registration snapshot.
26
26
  */
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.HARNESS_FEATURES = void 0;
28
29
  exports.probe = probe;
29
30
  exports.probeCapabilities = probeCapabilities;
30
31
  const node_child_process_1 = require("node:child_process");
@@ -33,6 +34,14 @@ const node_os_1 = require("node:os");
33
34
  const node_path_1 = require("node:path");
34
35
  const version_1 = require("./version");
35
36
  const PROBE_TIMEOUT_MS = 10_000;
37
+ /**
38
+ * What this harness build can do beyond the baseline. The control plane reads
39
+ * these to decide what to send: a feature missing here is never sent to.
40
+ *
41
+ * - `spaceFiles`: syncs `space-files/` (read-only) and serves
42
+ * `knowledge_files_list` / `knowledge_file_download` (P9).
43
+ */
44
+ exports.HARNESS_FEATURES = ['spaceFiles'];
36
45
  /**
37
46
  * The coding-agent backends this harness knows how to drive, and the command
38
47
  * that reports each one's version.
@@ -156,6 +165,7 @@ async function probeCapabilities(options = {}) {
156
165
  git: git.installed,
157
166
  docker: docker.installed,
158
167
  maxConcurrentSessions: options.maxConcurrentSessions ?? 1,
168
+ features: [...exports.HARNESS_FEATURES],
159
169
  labels: options.labels
160
170
  };
161
171
  }
@@ -194,6 +194,10 @@ export declare const ROUTES: {
194
194
  readonly lease: (runnerId: string, waitSeconds?: number, protocolVersion?: number) => string;
195
195
  /** Bearer: runner credential. Visibility-timeout renewal. */
196
196
  readonly leaseRenew: (runnerId: string, leaseId: string) => string;
197
+ /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
198
+ readonly knowledgeFiles: (runnerId: string, leaseId: string) => string;
199
+ /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
200
+ readonly knowledgeDownload: (runnerId: string, leaseId: string) => string;
197
201
  /**
198
202
  * Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
199
203
  * a customer VM and the 20x desktop all call this identical path.
package/dist/contract.js CHANGED
@@ -234,6 +234,10 @@ exports.ROUTES = {
234
234
  },
235
235
  /** Bearer: runner credential. Visibility-timeout renewal. */
236
236
  leaseRenew: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/renew`,
237
+ /** Bearer: runner credential. Metadata of the knowledge files this turn may read. */
238
+ knowledgeFiles: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/files`,
239
+ /** Bearer: runner credential. A short-lived download URL for one knowledge file. */
240
+ knowledgeDownload: (runnerId, leaseId) => `/api/runners/${encodeURIComponent(runnerId)}/lease/${encodeURIComponent(leaseId)}/knowledge/download`,
237
241
  /**
238
242
  * Bearer: runner credential. THE RUNTIME-NEUTRAL ROUTE — a Daytona sandbox,
239
243
  * a customer VM and the 20x desktop all call this identical path.
@@ -1,4 +1,4 @@
1
- import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
1
+ import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, KnowledgeDownloadResponse, KnowledgeFilesResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
2
2
  export declare class ControlPlaneError extends Error {
3
3
  readonly status: number;
4
4
  /** The stable machine-readable code from the body, when the server sent one. */
@@ -130,6 +130,13 @@ export declare class ControlPlaneClient {
130
130
  */
131
131
  lease(waitSeconds?: number): Promise<LeaseResponse | null>;
132
132
  renewLease(leaseId: string, status?: RunnerStatus, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<LeaseRenewResponse>;
133
+ /** Metadata of the knowledge files the lease's turn may read. Never a URL. */
134
+ knowledgeFiles(leaseId: string, datastoreId?: string): Promise<KnowledgeFilesResponse>;
135
+ /**
136
+ * A short-lived signed URL for one knowledge file, for the harness to fetch.
137
+ * The caller must not log the response's `url`.
138
+ */
139
+ knowledgeDownload(leaseId: string, datastoreId: string, fileId: string): Promise<KnowledgeDownloadResponse>;
133
140
  /**
134
141
  * THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
135
142
  * desktop all call exactly this.
@@ -263,6 +263,32 @@ class ControlPlaneClient {
263
263
  });
264
264
  }
265
265
  /* ------------------------------------------------------------------ */
266
+ /* 4b. knowledge files (P9) */
267
+ /* ------------------------------------------------------------------ */
268
+ /** Metadata of the knowledge files the lease's turn may read. Never a URL. */
269
+ async knowledgeFiles(leaseId, datastoreId) {
270
+ const runnerId = this.requireRunnerId();
271
+ return this.send({
272
+ method: 'POST',
273
+ path: contract_1.ROUTES.knowledgeFiles(runnerId, leaseId),
274
+ token: this.requireCredential(),
275
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, datastoreId }
276
+ });
277
+ }
278
+ /**
279
+ * A short-lived signed URL for one knowledge file, for the harness to fetch.
280
+ * The caller must not log the response's `url`.
281
+ */
282
+ async knowledgeDownload(leaseId, datastoreId, fileId) {
283
+ const runnerId = this.requireRunnerId();
284
+ return this.send({
285
+ method: 'POST',
286
+ path: contract_1.ROUTES.knowledgeDownload(runnerId, leaseId),
287
+ token: this.requireCredential(),
288
+ body: { protocolVersion: contract_1.PROTOCOL_VERSION, datastoreId, fileId }
289
+ });
290
+ }
291
+ /* ------------------------------------------------------------------ */
266
292
  /* 5. ingest */
267
293
  /* ------------------------------------------------------------------ */
268
294
  /**
package/dist/harness.d.ts CHANGED
@@ -119,6 +119,8 @@ export declare class Harness {
119
119
  private readonly client;
120
120
  private readonly health;
121
121
  private readonly proxy;
122
+ /** One loopback host for every session this process runs (P9 knowledge tools). */
123
+ private readonly knowledgeHost;
122
124
  private readonly store;
123
125
  /** The model-facing conversation log. See `ports.ts#ConversationStore`. */
124
126
  private readonly conversation;
package/dist/harness.js CHANGED
@@ -12,6 +12,7 @@ const github_workspace_1 = require("./github-workspace");
12
12
  const health_1 = require("./health");
13
13
  const model_probe_1 = require("./model-probe");
14
14
  const ports_default_1 = require("./ports-default");
15
+ const knowledge_tools_1 = require("./runtime/knowledge-tools");
15
16
  const lease_renewal_1 = require("./runtime/lease-renewal");
16
17
  const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
17
18
  const session_runner_1 = require("./runtime/session-runner");
@@ -94,6 +95,8 @@ class Harness {
94
95
  client;
95
96
  health;
96
97
  proxy;
98
+ /** One loopback host for every session this process runs (P9 knowledge tools). */
99
+ knowledgeHost = new knowledge_tools_1.KnowledgeToolHost({ logger: console });
97
100
  store;
98
101
  /** The model-facing conversation log. See `ports.ts#ConversationStore`. */
99
102
  conversation;
@@ -1293,6 +1296,13 @@ class Harness {
1293
1296
  store: this.store,
1294
1297
  workspace: this.workspace,
1295
1298
  proxy: this.proxy,
1299
+ knowledge: {
1300
+ host: this.knowledgeHost,
1301
+ control: {
1302
+ list: (datastoreId) => this.client.knowledgeFiles(lease.leaseId, datastoreId),
1303
+ download: (datastoreId, fileId) => this.client.knowledgeDownload(lease.leaseId, datastoreId, fileId)
1304
+ }
1305
+ },
1296
1306
  secrets: this.secrets,
1297
1307
  events: this.events,
1298
1308
  telemetry: this.telemetry,
@@ -60,6 +60,12 @@ export declare function sanitizeAttachmentMimeType(raw: unknown): string;
60
60
  * the id's own shape.
61
61
  */
62
62
  export declare function turnToken(turnId: string): string;
63
+ /**
64
+ * Add one pattern to a repository workspace's local exclude file. Shared by
65
+ * attachments and Space files (P9): both are mirrors of stored bytes that an
66
+ * agent must not commit by accident.
67
+ */
68
+ export declare function excludePatternFromGit(workspaceDir: string, pattern: string, logger: Pick<Console, 'warn'>): Promise<void>;
63
69
  /**
64
70
  * Download every attachment into `<workspaceDir>/attachments/`.
65
71
  *
@@ -4,6 +4,7 @@ exports.ATTACHMENT_DOWNLOAD_TIMEOUT_MS = exports.MAX_ATTACHMENT_BYTES = exports.
4
4
  exports.sanitizeAttachmentName = sanitizeAttachmentName;
5
5
  exports.sanitizeAttachmentMimeType = sanitizeAttachmentMimeType;
6
6
  exports.turnToken = turnToken;
7
+ exports.excludePatternFromGit = excludePatternFromGit;
7
8
  exports.syncAttachmentsToWorkspace = syncAttachmentsToWorkspace;
8
9
  exports.attachmentPromptNote = attachmentPromptNote;
9
10
  /**
@@ -192,25 +193,15 @@ async function resolveGitDir(workspaceDir) {
192
193
  }
193
194
  }
194
195
  /**
195
- * Best-effort: keep `attachments/` out of `git status` in a repository-mode
196
- * workspace, so an agent that stages "everything that changed" cannot commit
197
- * a user's uploaded file into the repository it is working on.
198
- *
199
- * Written to `<gitdir>/info/exclude`, NOT `.gitignore` — the former is local
200
- * to this checkout and is never itself a tracked file the agent could commit
201
- * or a diff it could see, which matters because `.gitignore` churn would be
202
- * a confusing, unexplained line in every turn's diff. A no-op for a
203
- * `scratch` workspace (no `.git` at all, resolved by `resolveGitDir`) and for
204
- * anything that goes wrong reading or writing it — this is housekeeping, not
205
- * a security boundary on its own (the per-file and per-attachment safeguards
206
- * above are), and must never fail a turn.
196
+ * Add one pattern to a repository workspace's local exclude file. Shared by
197
+ * attachments and Space files (P9): both are mirrors of stored bytes that an
198
+ * agent must not commit by accident.
207
199
  */
208
- async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
200
+ async function excludePatternFromGit(workspaceDir, pattern, logger) {
209
201
  const gitDir = await resolveGitDir(workspaceDir);
210
202
  if (!gitDir)
211
203
  return;
212
204
  const excludePath = (0, node_path_1.join)(gitDir, 'info', 'exclude');
213
- const pattern = `/${exports.ATTACHMENTS_DIR}/`;
214
205
  try {
215
206
  let existing = '';
216
207
  try {
@@ -226,9 +217,26 @@ async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
226
217
  await (0, promises_1.appendFile)(excludePath, `${prefix}${pattern}\n`);
227
218
  }
228
219
  catch (error) {
229
- logger.warn(`[attachments] could not add ${exports.ATTACHMENTS_DIR}/ to ${excludePath}: ${error instanceof Error ? error.message : String(error)}`);
220
+ logger.warn(`[attachments] could not add ${pattern} to ${excludePath}: ${error instanceof Error ? error.message : String(error)}`);
230
221
  }
231
222
  }
223
+ /**
224
+ * Best-effort: keep `attachments/` out of `git status` in a repository-mode
225
+ * workspace, so an agent that stages "everything that changed" cannot commit
226
+ * a user's uploaded file into the repository it is working on.
227
+ *
228
+ * Written to `<gitdir>/info/exclude`, NOT `.gitignore` — the former is local
229
+ * to this checkout and is never itself a tracked file the agent could commit
230
+ * or a diff it could see, which matters because `.gitignore` churn would be
231
+ * a confusing, unexplained line in every turn's diff. A no-op for a
232
+ * `scratch` workspace (no `.git` at all, resolved by `resolveGitDir`) and for
233
+ * anything that goes wrong reading or writing it — this is housekeeping, not
234
+ * a security boundary on its own (the per-file and per-attachment safeguards
235
+ * above are), and must never fail a turn.
236
+ */
237
+ async function excludeAttachmentsDirFromGit(workspaceDir, logger) {
238
+ await excludePatternFromGit(workspaceDir, `/${exports.ATTACHMENTS_DIR}/`, logger);
239
+ }
232
240
  /**
233
241
  * Download every attachment into `<workspaceDir>/attachments/`.
234
242
  *
@@ -72,6 +72,22 @@ export declare function trimHistory(history: ModelMessage[], maxChars?: number):
72
72
  * Returns the raw input unchanged when there is nothing to replay, so a first
73
73
  * turn is byte-for-byte what it always was and cannot regress.
74
74
  */
75
+ /**
76
+ * THE KEY ONE AGENT'S CONVERSATION IS STORED UNDER on this runner.
77
+ *
78
+ * A chat can be answered by several agents, and each agent keeps its own
79
+ * thread: its model memory and its backend checkpoint. Keying by the chat alone
80
+ * would hand agent B the replay of agent A's answers. The transcript itself
81
+ * stays keyed by the chat; only this memory is per agent.
82
+ *
83
+ * A session with no agent id (an older control plane) keeps the chat-only key.
84
+ */
85
+ export declare function conversationKey(session: {
86
+ sessionId: string;
87
+ agent?: {
88
+ agentId?: string;
89
+ } | null;
90
+ }): string;
75
91
  export declare function buildReplayPrompt(history: ModelMessage[], input: string): string;
76
92
  /**
77
93
  * Reduce a turn's projected parts to the assistant text the model produced.
@@ -36,6 +36,7 @@
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.MAX_REPLAY_CHARS = void 0;
38
38
  exports.trimHistory = trimHistory;
39
+ exports.conversationKey = conversationKey;
39
40
  exports.buildReplayPrompt = buildReplayPrompt;
40
41
  exports.assistantTextFrom = assistantTextFrom;
41
42
  /**
@@ -121,6 +122,20 @@ function trimHistory(history, maxChars = exports.MAX_REPLAY_CHARS) {
121
122
  * Returns the raw input unchanged when there is nothing to replay, so a first
122
123
  * turn is byte-for-byte what it always was and cannot regress.
123
124
  */
125
+ /**
126
+ * THE KEY ONE AGENT'S CONVERSATION IS STORED UNDER on this runner.
127
+ *
128
+ * A chat can be answered by several agents, and each agent keeps its own
129
+ * thread: its model memory and its backend checkpoint. Keying by the chat alone
130
+ * would hand agent B the replay of agent A's answers. The transcript itself
131
+ * stays keyed by the chat; only this memory is per agent.
132
+ *
133
+ * A session with no agent id (an older control plane) keeps the chat-only key.
134
+ */
135
+ function conversationKey(session) {
136
+ const agentId = session.agent?.agentId;
137
+ return agentId ? `${session.sessionId}#${agentId}` : session.sessionId;
138
+ }
124
139
  function buildReplayPrompt(history, input) {
125
140
  const trimmed = trimHistory(history);
126
141
  if (trimmed.length === 0)
@@ -0,0 +1,44 @@
1
+ import type { KnowledgeDownloadResponse, KnowledgeFilesResponse } from '../types';
2
+ /** The name the agent sees the server under. */
3
+ export declare const KNOWLEDGE_SERVER_NAME = "pf-knowledge";
4
+ export declare const KNOWLEDGE_FILES_LIST_TOOL = "knowledge_files_list";
5
+ export declare const KNOWLEDGE_FILE_DOWNLOAD_TOOL = "knowledge_file_download";
6
+ /** The control plane's answers, as this module needs them. Injected for tests. */
7
+ export interface KnowledgeControl {
8
+ list(datastoreId?: string): Promise<KnowledgeFilesResponse>;
9
+ download(datastoreId: string, fileId: string): Promise<KnowledgeDownloadResponse>;
10
+ }
11
+ /** The active turn a session's tools act for. Absent between turns. */
12
+ export interface KnowledgeTurn {
13
+ control: KnowledgeControl;
14
+ workspaceDir: string;
15
+ }
16
+ /** A name safe as one path segment, for a download's name. */
17
+ export declare function safeDownloadName(raw: unknown): string;
18
+ export interface KnowledgeToolHostOptions {
19
+ fetchImpl?: typeof fetch;
20
+ logger: Pick<Console, 'log' | 'warn' | 'error'>;
21
+ downloadTimeoutMs?: number;
22
+ }
23
+ export declare class KnowledgeToolHost {
24
+ private readonly options;
25
+ private server;
26
+ private port;
27
+ private starting;
28
+ private readonly tokenBySession;
29
+ private readonly sessionByToken;
30
+ private readonly turns;
31
+ constructor(options: KnowledgeToolHostOptions);
32
+ /** The endpoint a session's agent is configured with. Starts the listener on first use. */
33
+ endpointFor(sessionId: string): Promise<string>;
34
+ /** Bind the current turn's lease, or clear it with null when the turn ends. */
35
+ bindTurn(sessionId: string, turn: KnowledgeTurn | null): void;
36
+ stop(): Promise<void>;
37
+ private start;
38
+ private handle;
39
+ private send;
40
+ private dispatch;
41
+ private callTool;
42
+ private list;
43
+ private download;
44
+ }