@pentatonic-ai/ai-agent-sdk 0.5.11 → 0.7.0

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 (119) hide show
  1. package/README.md +345 -174
  2. package/bin/__tests__/callback-server.test.js +70 -0
  3. package/bin/__tests__/credentials.test.js +58 -0
  4. package/bin/__tests__/login.test.js +210 -0
  5. package/bin/__tests__/pkce.test.js +39 -0
  6. package/bin/__tests__/whoami.test.js +77 -0
  7. package/bin/cli.js +109 -440
  8. package/bin/commands/config.js +251 -0
  9. package/bin/commands/login.js +219 -0
  10. package/bin/commands/whoami.js +41 -0
  11. package/bin/lib/callback-server.js +137 -0
  12. package/bin/lib/credentials.js +100 -0
  13. package/bin/lib/pkce.js +26 -0
  14. package/package.json +4 -2
  15. package/packages/doctor/__tests__/detect.test.js +2 -6
  16. package/packages/doctor/src/checks/local-memory.js +164 -196
  17. package/packages/doctor/src/detect.js +11 -3
  18. package/packages/memory/src/__tests__/corpus-chunkers.test.js +143 -0
  19. package/packages/memory/src/__tests__/corpus-discover.test.js +175 -0
  20. package/packages/memory/src/__tests__/corpus-ingest.test.js +236 -0
  21. package/packages/memory/src/__tests__/corpus-signatures.test.js +175 -0
  22. package/packages/memory/src/__tests__/corpus-state.test.js +161 -0
  23. package/packages/memory/src/__tests__/ingest-corpus-opts.test.js +129 -0
  24. package/packages/memory/src/__tests__/search-kind.test.js +108 -0
  25. package/packages/memory/src/corpus/adapters.js +398 -0
  26. package/packages/memory/src/corpus/chunkers.js +328 -0
  27. package/packages/memory/src/corpus/cli.js +613 -0
  28. package/packages/memory/src/corpus/discover.js +379 -0
  29. package/packages/memory/src/corpus/index.js +68 -0
  30. package/packages/memory/src/corpus/ingest.js +356 -0
  31. package/packages/memory/src/corpus/signatures.js +280 -0
  32. package/packages/memory/src/corpus/state.js +134 -0
  33. package/packages/memory/src/index.js +18 -0
  34. package/packages/memory/src/ingest.js +20 -11
  35. package/packages/memory/src/openclaw/index.js +39 -1
  36. package/packages/memory/src/search.js +30 -7
  37. package/packages/memory-engine/.env.example +13 -0
  38. package/packages/memory-engine/README.md +131 -0
  39. package/packages/memory-engine/bench/README.md +99 -0
  40. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +1115 -0
  41. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +819 -0
  42. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +1278 -0
  43. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +1018 -0
  44. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +1038 -0
  45. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +961 -0
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +1115 -0
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +819 -0
  48. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +1278 -0
  49. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +1018 -0
  50. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +1038 -0
  51. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +937 -0
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +1115 -0
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +819 -0
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +1278 -0
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +1018 -0
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +1038 -0
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +961 -0
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +1115 -0
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +819 -0
  60. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +1278 -0
  61. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +1018 -0
  62. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +1038 -0
  63. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +883 -0
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +1115 -0
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +819 -0
  66. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +1278 -0
  67. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +1018 -0
  68. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +1038 -0
  69. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +937 -0
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +1115 -0
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +1115 -0
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +819 -0
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +542 -0
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +1278 -0
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +894 -0
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +1018 -0
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +680 -0
  78. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +1038 -0
  79. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +693 -0
  80. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +961 -0
  81. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +727 -0
  82. package/packages/memory-engine/compat/Dockerfile +11 -0
  83. package/packages/memory-engine/compat/server.py +680 -0
  84. package/packages/memory-engine/docker-compose.yml +243 -0
  85. package/packages/memory-engine/docs/MIGRATION.md +178 -0
  86. package/packages/memory-engine/docs/RUNBOOK-AWS.md +375 -0
  87. package/packages/memory-engine/docs/why-v05-underperforms.md +138 -0
  88. package/packages/memory-engine/engine/README.md +52 -0
  89. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +1543 -0
  90. package/packages/memory-engine/engine/l5-comms-layer.py +663 -0
  91. package/packages/memory-engine/engine/l6-document-store.py +1018 -0
  92. package/packages/memory-engine/engine/services/l2/Dockerfile +41 -0
  93. package/packages/memory-engine/engine/services/l2/init_databases.py +81 -0
  94. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +1543 -0
  95. package/packages/memory-engine/engine/services/l4/Dockerfile +15 -0
  96. package/packages/memory-engine/engine/services/l4/server.py +235 -0
  97. package/packages/memory-engine/engine/services/l5/Dockerfile +9 -0
  98. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +678 -0
  99. package/packages/memory-engine/engine/services/l6/Dockerfile +11 -0
  100. package/packages/memory-engine/engine/services/l6/l6-document-store.py +1016 -0
  101. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +28 -0
  102. package/packages/memory-engine/engine/services/nv-embed/server.py +152 -0
  103. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  104. package/packages/memory-engine/pme_memory/__main__.py +129 -0
  105. package/packages/memory-engine/pme_memory/artifacts.py +95 -0
  106. package/packages/memory-engine/pme_memory/embed.py +74 -0
  107. package/packages/memory-engine/pme_memory/health.py +36 -0
  108. package/packages/memory-engine/pme_memory/hygiene.py +159 -0
  109. package/packages/memory-engine/pme_memory/indexer.py +200 -0
  110. package/packages/memory-engine/pme_memory/needs.py +55 -0
  111. package/packages/memory-engine/pme_memory/provenance.py +80 -0
  112. package/packages/memory-engine/pme_memory/scoring.py +168 -0
  113. package/packages/memory-engine/pme_memory/search.py +52 -0
  114. package/packages/memory-engine/pme_memory/store.py +86 -0
  115. package/packages/memory-engine/pme_memory/synthesis.py +114 -0
  116. package/packages/memory-engine/pyproject.toml +65 -0
  117. package/packages/memory-engine/scripts/kg-extractor.py +557 -0
  118. package/packages/memory-engine/scripts/kg-preflexor-v2.py +738 -0
  119. package/packages/memory-engine/tests/test_api_contract.sh +57 -0
@@ -0,0 +1,613 @@
1
+ /**
2
+ * CLI handlers for corpus commands. Imported by bin/cli.js.
3
+ *
4
+ * Each handler returns a process exit code (0 = ok, non-zero = failure).
5
+ * Output goes to stdout/stderr — no return values for human-facing
6
+ * formatting.
7
+ */
8
+
9
+ import { resolve } from "node:path";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { promises as fsp } from "node:fs";
12
+ import { execFileSync } from "node:child_process";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ import {
17
+ ingestCorpus,
18
+ syncCorpus,
19
+ ingestPaths,
20
+ estimateCorpus,
21
+ hostedAdapter,
22
+ engineAdapter,
23
+ loadState,
24
+ saveState,
25
+ defaultStatePath,
26
+ emptyState,
27
+ removeSource as removeSourceFromState,
28
+ recomputeStats,
29
+ } from "./index.js";
30
+
31
+ // --------------------------------------------------------------------
32
+ // Tenant resolution
33
+ // --------------------------------------------------------------------
34
+
35
+ function resolveTenant() {
36
+ const endpoint =
37
+ process.env.TES_ENDPOINT ||
38
+ process.env.PENTATONIC_ENDPOINT ||
39
+ null;
40
+ const clientId =
41
+ process.env.TES_CLIENT_ID || process.env.PENTATONIC_CLIENT_ID || null;
42
+ const apiKey =
43
+ process.env.TES_API_KEY || process.env.PENTATONIC_API_KEY || null;
44
+
45
+ if (endpoint && clientId && apiKey) {
46
+ return { endpoint, clientId, apiKey, source: "env" };
47
+ }
48
+
49
+ // Fall back to ~/.config/tes/credentials.json (written by `tes init`)
50
+ const credPath = join(
51
+ process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
52
+ "tes",
53
+ "credentials.json"
54
+ );
55
+ if (existsSync(credPath)) {
56
+ try {
57
+ const raw = JSON.parse(readFileSync(credPath, "utf-8"));
58
+ if (raw.endpoint && raw.clientId && raw.apiKey) {
59
+ return { ...raw, source: "credentials" };
60
+ }
61
+ } catch {
62
+ // fall through
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Read the Claude Code plugin config (tes-memory.local.md) to discover
70
+ * which memory backend the user has configured. Single source of truth
71
+ * for `tes ingest` so it routes the same way the plugin's hooks do.
72
+ *
73
+ * Returns: { mode: "local"|"hosted", memory_url?, tes_endpoint?, … } or null.
74
+ */
75
+ function readPluginConfig() {
76
+ const candidates = [
77
+ process.env.CLAUDE_CONFIG_DIR,
78
+ join(homedir(), ".claude-pentatonic"),
79
+ join(homedir(), ".claude"),
80
+ ].filter(Boolean);
81
+ for (const dir of candidates) {
82
+ const p = join(dir, "tes-memory.local.md");
83
+ if (!existsSync(p)) continue;
84
+ try {
85
+ const content = readFileSync(p, "utf-8");
86
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
87
+ if (!m) continue;
88
+ const out = { _path: p };
89
+ for (const line of m[1].split("\n")) {
90
+ const kv = line.match(/^(\w+):\s*(.+)$/);
91
+ if (kv) out[kv[1]] = kv[2].trim();
92
+ }
93
+ return out;
94
+ } catch {
95
+ continue;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ function buildAdapterOrFail() {
102
+ // 1. Env-var override (CI / scripts / explicit). Highest precedence.
103
+ const envEngineUrl =
104
+ process.env.MEMORY_ENGINE_URL || process.env.PENTATONIC_ENGINE_URL || null;
105
+ if (envEngineUrl) {
106
+ const arena =
107
+ process.env.MEMORY_ARENA ||
108
+ process.env.PENTATONIC_CLIENT_ID ||
109
+ process.env.TES_CLIENT_ID ||
110
+ "default";
111
+ return {
112
+ tenant: { source: "env-engine", engineUrl: envEngineUrl, arena },
113
+ adapter: engineAdapter({
114
+ engineUrl: envEngineUrl,
115
+ arena,
116
+ apiKey: process.env.MEMORY_ENGINE_API_KEY || null,
117
+ }),
118
+ };
119
+ }
120
+
121
+ // 2. Plugin config — same source of truth as the Claude Code hooks.
122
+ const pluginConfig = readPluginConfig();
123
+ if (pluginConfig?.mode === "local" && pluginConfig.memory_url) {
124
+ const arena = pluginConfig.client_id || "default";
125
+ return {
126
+ tenant: { source: `plugin-config (${pluginConfig._path})`, engineUrl: pluginConfig.memory_url, arena },
127
+ adapter: engineAdapter({ engineUrl: pluginConfig.memory_url, arena }),
128
+ };
129
+ }
130
+
131
+ // 3. Hosted/TES path: env vars + ~/.config/tes/credentials.json.
132
+ const tenant = resolveTenant();
133
+ if (!tenant) {
134
+ process.stderr.write(
135
+ "Error: no memory backend configured.\n\n" +
136
+ " Configure with one of:\n" +
137
+ " npx @pentatonic-ai/ai-agent-sdk config local # local engine\n" +
138
+ " npx @pentatonic-ai/ai-agent-sdk login # hosted TES\n\n" +
139
+ " Or set env vars directly:\n" +
140
+ " MEMORY_ENGINE_URL=http://localhost:8099\n" +
141
+ " TES_ENDPOINT=… TES_CLIENT_ID=… TES_API_KEY=…\n"
142
+ );
143
+ return null;
144
+ }
145
+ return {
146
+ tenant,
147
+ adapter: hostedAdapter(tenant, { source: "tes-corpus-cli" }),
148
+ };
149
+ }
150
+
151
+ // --------------------------------------------------------------------
152
+ // Formatting helpers
153
+ // --------------------------------------------------------------------
154
+
155
+ function fmtBytes(n) {
156
+ if (!Number.isFinite(n)) return "?";
157
+ if (n < 1024) return `${n} B`;
158
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
159
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
160
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
161
+ }
162
+
163
+ function fmtDuration(ms) {
164
+ if (ms < 1000) return `${ms}ms`;
165
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
166
+ return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
167
+ }
168
+
169
+ function progressBar(processed, total, width = 30) {
170
+ if (!total) return "";
171
+ const filled = Math.min(width, Math.floor((processed / total) * width));
172
+ return "█".repeat(filled) + "░".repeat(width - filled);
173
+ }
174
+
175
+ function liveProgress() {
176
+ let lastWrite = 0;
177
+ return (p) => {
178
+ if (p.phase !== "file") return;
179
+ const now = Date.now();
180
+ if (now - lastWrite < 100 && p.processed !== p.total) return;
181
+ lastWrite = now;
182
+ const bar = progressBar(p.processed, p.total);
183
+ const line = ` ${bar} ${p.processed}/${p.total} ${p.file}`;
184
+ process.stderr.write("\r\x1b[K" + line.slice(0, 120));
185
+ if (p.processed === p.total) process.stderr.write("\n");
186
+ };
187
+ }
188
+
189
+ // --------------------------------------------------------------------
190
+ // Subcommand: tes ingest <path>
191
+ // --------------------------------------------------------------------
192
+
193
+ export async function cmdIngest(args) {
194
+ const path = args[0];
195
+ if (!path) {
196
+ process.stderr.write("Usage: tes ingest <path>\n");
197
+ return 1;
198
+ }
199
+ const abs = resolve(path);
200
+ if (!existsSync(abs)) {
201
+ process.stderr.write(`Error: ${abs} does not exist\n`);
202
+ return 1;
203
+ }
204
+
205
+ const built = buildAdapterOrFail();
206
+ if (!built) return 1;
207
+ const { adapter } = built;
208
+
209
+ process.stdout.write(`Ingesting ${abs}...\n`);
210
+ const start = Date.now();
211
+ let warnings = 0;
212
+ try {
213
+ const totals = await ingestCorpus(adapter, abs, {
214
+ sourceUrl: detectGitRemote(abs),
215
+ onProgress: liveProgress(),
216
+ onWarning: (m) => {
217
+ warnings++;
218
+ if (warnings <= 5) process.stderr.write(` ! ${m}\n`);
219
+ },
220
+ });
221
+ const dur = Date.now() - start;
222
+ process.stdout.write(
223
+ `\nDone in ${fmtDuration(dur)}.\n` +
224
+ ` Files processed: ${totals.filesProcessed}\n` +
225
+ ` Files ingested: ${totals.filesIngested}\n` +
226
+ ` Files skipped: ${totals.filesSkipped}\n` +
227
+ ` Chunks created: ${totals.chunksCreated}\n` +
228
+ ` Bytes processed: ${fmtBytes(totals.bytesProcessed)}\n`
229
+ );
230
+ if (warnings > 5) {
231
+ process.stderr.write(` (${warnings - 5} additional warnings suppressed)\n`);
232
+ }
233
+ return 0;
234
+ } catch (err) {
235
+ process.stderr.write(`\nIngest failed: ${err.message}\n`);
236
+ return 2;
237
+ }
238
+ }
239
+
240
+ // --------------------------------------------------------------------
241
+ // Subcommand: tes onboard
242
+ // --------------------------------------------------------------------
243
+
244
+ export async function cmdOnboard(_args, { ask, close }) {
245
+ const built = buildAdapterOrFail();
246
+ if (!built) return 1;
247
+ const { tenant, adapter } = built;
248
+
249
+ process.stdout.write(
250
+ `\nWelcome. Let's give your memory something to retrieve.\n\n` +
251
+ ` Tenant: ${tenant.clientId} @ ${tenant.endpoint}\n\n`
252
+ );
253
+
254
+ // Default to current working directory
255
+ const defaultRepo = process.cwd();
256
+ const inputRepos = await ask(
257
+ ` Repos to ingest (comma-separated paths, blank for cwd):\n > `
258
+ );
259
+ const repos = (inputRepos || defaultRepo)
260
+ .split(",")
261
+ .map((s) => resolve(s.trim()))
262
+ .filter((s) => s && existsSync(s));
263
+
264
+ if (!repos.length) {
265
+ process.stdout.write(" No valid paths. Aborting.\n");
266
+ close();
267
+ return 1;
268
+ }
269
+
270
+ process.stdout.write(`\n Estimating cost (no data sent yet)...\n`);
271
+ const estimates = [];
272
+ let totalChunks = 0;
273
+ for (const r of repos) {
274
+ const est = await estimateCorpus(r);
275
+ estimates.push({ repo: r, ...est });
276
+ totalChunks += est.estimatedChunks;
277
+ process.stdout.write(
278
+ ` ${r}\n` +
279
+ ` ${est.fileCount} files, ${fmtBytes(est.totalBytes)}, ` +
280
+ `~${est.estimatedChunks} chunks (~${est.estimatedTokens.toLocaleString()} tokens)\n`
281
+ );
282
+ }
283
+
284
+ if (totalChunks > 100000) {
285
+ process.stdout.write(
286
+ `\n ⚠️ This run would create ${totalChunks.toLocaleString()} chunks, ` +
287
+ `exceeding the default 100,000 cap.\n Re-run individual repos with` +
288
+ ` --max-chunks <N> if needed.\n`
289
+ );
290
+ close();
291
+ return 1;
292
+ }
293
+
294
+ const confirm = await ask(`\n Continue? [Y/n]: `);
295
+ if (confirm.trim().toLowerCase() === "n") {
296
+ process.stdout.write(" Aborted.\n");
297
+ close();
298
+ return 0;
299
+ }
300
+
301
+ for (const { repo } of estimates) {
302
+ process.stdout.write(`\n Ingesting ${repo}...\n`);
303
+ const start = Date.now();
304
+ let warnings = 0;
305
+ try {
306
+ const totals = await ingestCorpus(adapter, repo, {
307
+ sourceUrl: detectGitRemote(repo),
308
+ onProgress: liveProgress(),
309
+ onWarning: (m) => {
310
+ warnings++;
311
+ if (warnings <= 3) process.stderr.write(` ! ${m}\n`);
312
+ },
313
+ });
314
+ process.stdout.write(
315
+ ` ✓ ${totals.filesIngested} files, ${totals.chunksCreated} chunks, ${fmtDuration(
316
+ Date.now() - start
317
+ )}.\n`
318
+ );
319
+ } catch (err) {
320
+ process.stderr.write(` ✗ ${err.message}\n`);
321
+ }
322
+ }
323
+
324
+ // Offer to install git hook
325
+ for (const { repo } of estimates) {
326
+ if (!isGitRepo(repo)) continue;
327
+ const hookPath = join(repo, ".git", "hooks", "post-commit");
328
+ if (existsSync(hookPath)) continue;
329
+ const yes = await ask(
330
+ `\n Install git post-commit hook in ${repo}?\n (re-ingests changed files on commit) [Y/n]: `
331
+ );
332
+ if (yes.trim().toLowerCase() !== "n") {
333
+ try {
334
+ await installGitHook(repo);
335
+ process.stdout.write(` ✓ Hook installed at ${hookPath}\n`);
336
+ } catch (err) {
337
+ process.stderr.write(` ✗ Failed to install hook: ${err.message}\n`);
338
+ }
339
+ }
340
+ }
341
+
342
+ process.stdout.write(
343
+ `\n Memory ready. Run \`tes status\` any time to see corpus health.\n\n`
344
+ );
345
+ close();
346
+ return 0;
347
+ }
348
+
349
+ // --------------------------------------------------------------------
350
+ // Subcommand: tes status
351
+ // --------------------------------------------------------------------
352
+
353
+ export async function cmdStatus() {
354
+ const tenant = resolveTenant();
355
+ const state = await loadState();
356
+ const sources = Object.entries(state.sources);
357
+
358
+ process.stdout.write(`\nTES corpus status\n`);
359
+ if (tenant) {
360
+ process.stdout.write(
361
+ ` Tenant: ${tenant.clientId}\n Endpoint: ${tenant.endpoint}\n`
362
+ );
363
+ } else {
364
+ process.stdout.write(` Tenant: <not configured>\n`);
365
+ }
366
+ process.stdout.write(` State file: ${defaultStatePath()}\n\n`);
367
+
368
+ if (!sources.length) {
369
+ process.stdout.write(
370
+ ` No repos tracked yet. Run \`tes onboard\` to add some.\n\n`
371
+ );
372
+ return 0;
373
+ }
374
+
375
+ let totalFiles = 0;
376
+ let totalChunks = 0;
377
+ let totalBytes = 0;
378
+ for (const [path, src] of sources) {
379
+ recomputeStats(src);
380
+ totalFiles += src.stats.fileCount;
381
+ totalChunks += src.stats.chunkCount;
382
+ totalBytes += src.stats.totalBytes || 0;
383
+ const lastSync = src.lastSyncedAt
384
+ ? new Date(src.lastSyncedAt).toLocaleString()
385
+ : "never";
386
+ process.stdout.write(
387
+ ` ${path}\n` +
388
+ ` ${src.stats.fileCount} files, ${src.stats.chunkCount} chunks` +
389
+ (src.stats.totalBytes ? `, ${fmtBytes(src.stats.totalBytes)}` : "") +
390
+ `\n` +
391
+ ` last sync: ${lastSync}\n` +
392
+ (src.sourceUrl ? ` git: ${src.sourceUrl}\n` : "")
393
+ );
394
+ }
395
+ process.stdout.write(
396
+ `\n Total: ${sources.length} repos, ${totalFiles} files, ${totalChunks} chunks\n\n`
397
+ );
398
+ return 0;
399
+ }
400
+
401
+ // --------------------------------------------------------------------
402
+ // Subcommand: tes resync [<path>]
403
+ // --------------------------------------------------------------------
404
+
405
+ export async function cmdResync(args) {
406
+ const built = buildAdapterOrFail();
407
+ if (!built) return 1;
408
+ const { adapter } = built;
409
+
410
+ const state = await loadState();
411
+ const sources = args[0]
412
+ ? [resolve(args[0])]
413
+ : Object.keys(state.sources);
414
+
415
+ if (!sources.length) {
416
+ process.stdout.write(
417
+ "No repos tracked. Run `tes onboard` or `tes ingest <path>` first.\n"
418
+ );
419
+ return 0;
420
+ }
421
+
422
+ for (const repo of sources) {
423
+ if (!existsSync(repo)) {
424
+ process.stderr.write(` ! ${repo} no longer exists; skipping\n`);
425
+ continue;
426
+ }
427
+ process.stdout.write(`Resyncing ${repo}...\n`);
428
+ const start = Date.now();
429
+ try {
430
+ const totals = await syncCorpus(adapter, repo, {
431
+ onProgress: liveProgress(),
432
+ onWarning: (m) => process.stderr.write(` ! ${m}\n`),
433
+ });
434
+ process.stdout.write(
435
+ ` ✓ ${totals.filesIngested} updated, ${totals.filesSkipped} unchanged, ${totals.chunksCreated} new chunks, ${fmtDuration(Date.now() - start)}\n`
436
+ );
437
+ } catch (err) {
438
+ process.stderr.write(` ✗ ${err.message}\n`);
439
+ }
440
+ }
441
+ return 0;
442
+ }
443
+
444
+ // --------------------------------------------------------------------
445
+ // Subcommand: tes corpus list / remove / reset
446
+ // --------------------------------------------------------------------
447
+
448
+ export async function cmdCorpus(args, { ask, close }) {
449
+ const sub = args[0];
450
+
451
+ if (sub === "list" || !sub) {
452
+ const state = await loadState();
453
+ const entries = Object.keys(state.sources);
454
+ if (!entries.length) {
455
+ process.stdout.write("No repos tracked.\n");
456
+ return 0;
457
+ }
458
+ for (const e of entries) process.stdout.write(`${e}\n`);
459
+ return 0;
460
+ }
461
+
462
+ if (sub === "remove") {
463
+ const repo = args[1];
464
+ if (!repo) {
465
+ process.stderr.write("Usage: tes corpus remove <path>\n");
466
+ return 1;
467
+ }
468
+ const state = await loadState();
469
+ const existed = removeSourceFromState(state, repo);
470
+ if (!existed) {
471
+ process.stdout.write(`Not tracked: ${resolve(repo)}\n`);
472
+ return 0;
473
+ }
474
+ await saveState(state);
475
+ process.stdout.write(
476
+ `Removed ${resolve(repo)} from tracked repos.\n` +
477
+ `Note: chunks already in hosted memory are not deleted by this command.\n` +
478
+ `Use the dashboard or a future \`tes corpus purge\` to remove server-side data.\n`
479
+ );
480
+ return 0;
481
+ }
482
+
483
+ if (sub === "reset") {
484
+ const yes = await ask(
485
+ `This will wipe local corpus state at ${defaultStatePath()}.\n` +
486
+ `Server-side memory data is NOT touched.\nProceed? [y/N]: `
487
+ );
488
+ if (yes.trim().toLowerCase() !== "y") {
489
+ process.stdout.write("Aborted.\n");
490
+ close();
491
+ return 0;
492
+ }
493
+ await saveState(emptyState());
494
+ process.stdout.write("Local corpus state cleared.\n");
495
+ close();
496
+ return 0;
497
+ }
498
+
499
+ process.stderr.write(
500
+ `Unknown corpus subcommand: ${sub}\n` +
501
+ `Usage: tes corpus [list|remove <path>|reset]\n`
502
+ );
503
+ return 1;
504
+ }
505
+
506
+ // --------------------------------------------------------------------
507
+ // Git helpers
508
+ // --------------------------------------------------------------------
509
+
510
+ function isGitRepo(dir) {
511
+ return existsSync(join(dir, ".git"));
512
+ }
513
+
514
+ function detectGitRemote(dir) {
515
+ try {
516
+ return execFileSync("git", ["-C", dir, "config", "--get", "remote.origin.url"], {
517
+ encoding: "utf-8",
518
+ }).trim() || null;
519
+ } catch {
520
+ return null;
521
+ }
522
+ }
523
+
524
+ async function installGitHook(repo) {
525
+ const hookDir = join(repo, ".git", "hooks");
526
+ if (!existsSync(hookDir)) {
527
+ throw new Error(`${hookDir} does not exist (not a git repo?)`);
528
+ }
529
+ const hookPath = join(hookDir, "post-commit");
530
+ // Use absolute node binary path to make the hook portable across
531
+ // shells that strip PATH (the default for git hooks).
532
+ const script = `#!/bin/sh
533
+ # Installed by @pentatonic-ai/ai-agent-sdk — do not edit by hand.
534
+ # Re-runs corpus ingest for files changed in the latest commit.
535
+ # Non-fatal: never blocks a commit.
536
+ CHANGED=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null)
537
+ if [ -z "$CHANGED" ]; then exit 0; fi
538
+ echo "$CHANGED" | npx --no-install @pentatonic-ai/ai-agent-sdk \\
539
+ ingest-paths --repo "$(pwd)" --stdin >/dev/null 2>&1 || true
540
+ `;
541
+ await fsp.writeFile(hookPath, script, { mode: 0o755 });
542
+ }
543
+
544
+ // --------------------------------------------------------------------
545
+ // Subcommand: tes install-git-hook (manual install in cwd)
546
+ // --------------------------------------------------------------------
547
+
548
+ export async function cmdInstallGitHook() {
549
+ const cwd = process.cwd();
550
+ if (!isGitRepo(cwd)) {
551
+ process.stderr.write(`Error: ${cwd} is not a git repo\n`);
552
+ return 1;
553
+ }
554
+ try {
555
+ await installGitHook(cwd);
556
+ process.stdout.write(
557
+ `✓ Installed post-commit hook at ${join(cwd, ".git", "hooks", "post-commit")}\n`
558
+ );
559
+ return 0;
560
+ } catch (err) {
561
+ process.stderr.write(`Error: ${err.message}\n`);
562
+ return 1;
563
+ }
564
+ }
565
+
566
+ // --------------------------------------------------------------------
567
+ // Subcommand: tes ingest-paths (used by the git hook itself)
568
+ // --------------------------------------------------------------------
569
+
570
+ export async function cmdIngestPaths(args) {
571
+ const repoIdx = args.indexOf("--repo");
572
+ const stdinIdx = args.indexOf("--stdin");
573
+ if (repoIdx === -1 || !args[repoIdx + 1]) {
574
+ process.stderr.write(
575
+ "Usage: tes ingest-paths --repo <path> --stdin (or paths as args)\n"
576
+ );
577
+ return 1;
578
+ }
579
+ const repo = resolve(args[repoIdx + 1]);
580
+
581
+ let paths;
582
+ if (stdinIdx !== -1) {
583
+ const buffers = [];
584
+ for await (const chunk of process.stdin) buffers.push(chunk);
585
+ paths = Buffer.concat(buffers)
586
+ .toString("utf-8")
587
+ .split(/\r?\n/)
588
+ .map((s) => s.trim())
589
+ .filter(Boolean);
590
+ } else {
591
+ paths = args.filter((a, i) => i > stdinIdx && !a.startsWith("--"));
592
+ }
593
+
594
+ if (!paths.length) return 0;
595
+
596
+ const built = buildAdapterOrFail();
597
+ if (!built) return 1;
598
+ const { adapter } = built;
599
+
600
+ try {
601
+ const totals = await ingestPaths(adapter, repo, paths, {
602
+ onWarning: () => {}, // silent in hook context
603
+ });
604
+ // Hook runs detached; minimal stdout noise
605
+ process.stdout.write(
606
+ `[tes] ${totals.filesIngested}/${totals.filesProcessed} files re-indexed (${totals.chunksCreated} chunks)\n`
607
+ );
608
+ return 0;
609
+ } catch (err) {
610
+ process.stderr.write(`[tes] ingest-paths failed: ${err.message}\n`);
611
+ return 2;
612
+ }
613
+ }