agen-vektor 0.3.30 → 0.3.32

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.
@@ -5,6 +5,7 @@ const factory_1 = require("../providers/factory");
5
5
  const registry_1 = require("../tools/registry");
6
6
  const filesystem_1 = require("../tools/filesystem");
7
7
  const search_1 = require("../tools/search");
8
+ const semantic_search_1 = require("../tools/semantic-search");
8
9
  const shell_1 = require("../tools/shell");
9
10
  const git_1 = require("../tools/git");
10
11
  const web_1 = require("../tools/web");
@@ -59,6 +60,9 @@ class Agent {
59
60
  this.tools.registerMany([
60
61
  ...(0, filesystem_1.createFilesystemTools)(),
61
62
  ...(0, search_1.createSearchTools)(),
63
+ // Semantic code search (via gateway embeddings relay): semantic_index /
64
+ // semantic_search — cari kode by MEANING, zero-config (key di gateway).
65
+ ...(0, semantic_search_1.createSemanticSearchTools)(),
62
66
  (0, shell_1.createShellTool)(),
63
67
  (0, git_1.createGitTool)(),
64
68
  ...(0, web_1.createWebTools)(),
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.takeCheckpoint = takeCheckpoint;
37
+ exports.takeCheckpointAsync = takeCheckpointAsync;
37
38
  exports.listCheckpoints = listCheckpoints;
38
39
  exports.restoreCheckpoint = restoreCheckpoint;
39
40
  exports.pruneCheckpoints = pruneCheckpoints;
@@ -63,6 +64,17 @@ exports.resetCheckpoints = resetCheckpoints;
63
64
  * - Everything is best-effort: a missing git binary or a read-only home must
64
65
  * NEVER break a run. All failures resolve to null/empty and the run
65
66
  * proceeds without a safety net.
67
+ *
68
+ * Latency rules (bug "mentok di Edit", 2026-09-15):
69
+ * - The agent loop uses takeCheckpointAsync (off the event loop) so the TUI
70
+ * keeps painting while a big tree is scanned; the tool card renders BEFORE
71
+ * the snapshot starts (loop.ts), never behind it.
72
+ * - node_modules / .vector / .git are excluded from BOTH the size-count walk
73
+ * and the actual `git add` (pathspec excludes) — a bare /root work-tree
74
+ * used to stage 84k files ≈ 41s per snapshot and ballooned the shadow repo
75
+ * to 521 MB.
76
+ * - Work-trees above CP_MAX_FILES candidate files skip the snapshot entirely:
77
+ * the net costs more than it protects there.
66
78
  */
67
79
  const node_child_process_1 = require("node:child_process");
68
80
  const fs = __importStar(require("node:fs"));
@@ -82,9 +94,122 @@ function gitDirFor(cwd) {
82
94
  return path.join((0, paths_1.getVectorDir)(), 'checkpoints', slug);
83
95
  }
84
96
  function git(gitDir, args, workTree) {
85
- const r = (0, node_child_process_1.spawnSync)(GIT, [`--git-dir=${gitDir}`, ...(workTree ? [`--work-tree=${workTree}`] : []), ...args], { encoding: 'utf8', timeout: 20_000 });
97
+ const r = (0, node_child_process_1.spawnSync)(GIT, [`--git-dir=${gitDir}`, ...(workTree ? [`--work-tree=${workTree}`] : []), ...args],
98
+ // cwd MUST be the work-tree when one is set: git resolves relative
99
+ // pathspecs (our `:(exclude)` patterns) against the process cwd — from a
100
+ // different directory the excludes silently stop matching (probe-proven).
101
+ { encoding: 'utf8', timeout: 20_000, ...(workTree ? { cwd: workTree } : {}) });
86
102
  return { ok: r.status === 0, out: (r.stdout || '') + (r.stderr || '') };
87
103
  }
104
+ /** Async variant of `git(...)` — the child runs off the event loop so the
105
+ * TUI keeps rendering while a big snapshot scans the tree. 120s timeout
106
+ * (a slow disk scanning 100k+ files must not wedge the run forever). */
107
+ function gitAsync(gitDir, args, workTree) {
108
+ return new Promise((resolve) => {
109
+ let child;
110
+ try {
111
+ child = (0, node_child_process_1.spawn)(GIT, [`--git-dir=${gitDir}`, ...(workTree ? [`--work-tree=${workTree}`] : []), ...args],
112
+ // Same cwd rule as git(): relative pathspecs resolve against cwd.
113
+ { stdio: ['ignore', 'pipe', 'pipe'], ...(workTree ? { cwd: workTree } : {}) });
114
+ }
115
+ catch {
116
+ resolve({ ok: false, out: 'spawn failed' });
117
+ return;
118
+ }
119
+ let out = '';
120
+ let done = false;
121
+ const finish = (ok, tail) => {
122
+ if (done)
123
+ return;
124
+ done = true;
125
+ resolve({ ok, out: out.slice(-4000) + tail });
126
+ };
127
+ const timer = setTimeout(() => {
128
+ try {
129
+ child.kill('SIGKILL');
130
+ }
131
+ catch {
132
+ /* ignore */
133
+ }
134
+ finish(false, '\ngit timeout');
135
+ }, 120_000);
136
+ child.stdout?.on('data', (d) => {
137
+ out += d.toString();
138
+ if (out.length > 4096)
139
+ out = out.slice(-2048); // bounded
140
+ });
141
+ child.stderr?.on('data', (d) => {
142
+ out += d.toString();
143
+ if (out.length > 4096)
144
+ out = out.slice(-2048);
145
+ });
146
+ child.on('error', () => {
147
+ clearTimeout(timer);
148
+ finish(false, '\nspawn error');
149
+ });
150
+ child.on('close', (code) => {
151
+ clearTimeout(timer);
152
+ finish(code === 0, code === 0 ? '' : `\nexit ${code}`);
153
+ });
154
+ });
155
+ }
156
+ /**
157
+ * Folders the safety net should NEVER track even when the project has no
158
+ * .gitignore of its own: dependency folders and vector's own state (a
159
+ * ~/.vector tree inside the work-tree would make the snapshot
160
+ * self-referential and explode the object store — seen live: 521 MB shadow
161
+ * repo for /root).
162
+ */
163
+ const CP_EXCLUDE = ['node_modules', '.vector', '.git'];
164
+ /** Upper bound on work-tree scan size: beyond this, snapshotting takes
165
+ * minutes (VPS bench: 84k files = 41s per run) and the safety net costs
166
+ * more than it protects — skip with a clear reason instead. */
167
+ const CP_MAX_FILES = 20_000;
168
+ function countCandidateFiles(cwd) {
169
+ try {
170
+ let n = 0;
171
+ const walk = (dir, depth) => {
172
+ if (depth > 12 || n > CP_MAX_FILES)
173
+ return;
174
+ let entries;
175
+ try {
176
+ entries = fs.readdirSync(dir, { withFileTypes: true });
177
+ }
178
+ catch {
179
+ return;
180
+ }
181
+ for (const e of entries) {
182
+ if (CP_EXCLUDE.includes(e.name))
183
+ continue;
184
+ if (e.isDirectory())
185
+ walk(path.join(dir, e.name), depth + 1);
186
+ else if (e.isFile()) {
187
+ n++;
188
+ if (n > CP_MAX_FILES)
189
+ return;
190
+ }
191
+ }
192
+ };
193
+ walk(cwd, 0);
194
+ return n;
195
+ }
196
+ catch {
197
+ return null;
198
+ }
199
+ }
200
+ /** `git add -A` arguments with the exclude pathspecs applied. Runs with
201
+ * cwd = work-tree (see git()/gitAsync()), so the excludes are RELATIVE to
202
+ * the work-tree root: the bare name excludes the top-level folder and the
203
+ * trailing-slash glob excludes nested ones (git 2.43 probe-verified — the
204
+ * glob alone misses the root folder). */
205
+ function addArgs() {
206
+ const excludes = [];
207
+ for (const d of CP_EXCLUDE) {
208
+ excludes.push(`:(exclude)${d}`);
209
+ excludes.push(`:(exclude)**/${d}/`);
210
+ }
211
+ return ['add', '-A', '--', '.', ...excludes];
212
+ }
88
213
  function ensureShadowRepo(gitDir) {
89
214
  try {
90
215
  if (!fs.existsSync(path.join(gitDir, 'HEAD'))) {
@@ -104,9 +229,24 @@ function ensureShadowRepo(gitDir) {
104
229
  return false;
105
230
  }
106
231
  }
232
+ function stampRef(reason, task) {
233
+ const stamp = new Date();
234
+ const pad = (n) => String(n).padStart(2, '0');
235
+ const t = `${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}` +
236
+ `-${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}`;
237
+ const suffix = (cpSeq++).toString(36).padStart(4, '0');
238
+ const ref = `cp-${t}-${suffix}`;
239
+ const msg = `checkpoint ${ref} (${reason}) ${task.slice(0, 80)}`.trim();
240
+ return { ref, time: stamp.toISOString(), msg, stamp };
241
+ }
242
+ function parseHash(out) {
243
+ const hash = out.trim().split('\n')[0]?.trim() ?? '';
244
+ return /^[0-9a-f]{40}$/.test(hash) ? hash : null;
245
+ }
107
246
  /**
108
- * Take a checkpoint. Returns null when snapshotting is impossible — callers
109
- * must treat that as "no safety net", never as an error.
247
+ * Take a checkpoint (SYNC legacy path). Returns null when snapshotting is
248
+ * impossible — callers must treat that as "no safety net", never as an
249
+ * error. The agent loop uses the async variant below.
110
250
  */
111
251
  function takeCheckpoint(cwd, reason, task = '') {
112
252
  try {
@@ -115,35 +255,79 @@ function takeCheckpoint(cwd, reason, task = '') {
115
255
  const gitDir = gitDirFor(cwd);
116
256
  if (!ensureShadowRepo(gitDir))
117
257
  return null;
118
- // -A stages the full tree incl. deletions and untracked files. A
119
- // .gitignore in the user's project is respected (their intent), but
120
- // that also means ignored files are NOT protected by checkpoints —
121
- // documented trade-off, same as Claude Code.
122
- if (!git(gitDir, ['add', '-A', '--'], cwd).ok)
258
+ // -A stages the full tree incl. deletions and untracked files, minus the
259
+ // CP_EXCLUDE folders (pathspec excludes). A .gitignore in the user's
260
+ // project is respected (their intent), but that also means ignored files
261
+ // are NOT protected by checkpoints — documented trade-off, same as
262
+ // Claude Code.
263
+ if (!git(gitDir, addArgs(), cwd).ok)
123
264
  return null;
124
265
  const tree = git(gitDir, ['write-tree']);
125
266
  if (!tree.ok)
126
267
  return null;
127
- const treeHash = tree.out.trim().split('\n')[0].trim();
128
- if (!/^[0-9a-f]{40}$/.test(treeHash))
268
+ const treeHash = parseHash(tree.out);
269
+ if (!treeHash)
129
270
  return null;
130
- const stamp = new Date();
131
- const pad = (n) => String(n).padStart(2, '0');
132
- const t = `${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}` +
133
- `-${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}`;
134
- const suffix = (cpSeq++).toString(36).padStart(4, '0');
135
- const ref = `cp-${t}-${suffix}`;
136
- const msg = `checkpoint ${ref} (${reason}) ${task.slice(0, 80)}`.trim();
271
+ const { ref, time, msg } = stampRef(reason, task);
137
272
  // Independent root commit — commit-tree gives full control over parents.
138
273
  const commit = git(gitDir, ['commit-tree', treeHash, '-m', msg]);
139
274
  if (!commit.ok)
140
275
  return null;
141
- const hash = commit.out.trim().split('\n')[0].trim();
142
- if (!/^[0-9a-f]{40}$/.test(hash))
276
+ const hash = parseHash(commit.out);
277
+ if (!hash)
143
278
  return null;
144
279
  if (!git(gitDir, ['update-ref', `${REF_PREFIX}${ref}`, hash]).ok)
145
280
  return null;
146
- return { ref, time: stamp.toISOString(), reason, task: task.slice(0, 80), hash };
281
+ return { ref, time, reason, task: task.slice(0, 80), hash };
282
+ }
283
+ catch {
284
+ return null;
285
+ }
286
+ }
287
+ /**
288
+ * ASYNC checkpoint for the agent loop: same guarantees as takeCheckpoint,
289
+ * but the git child processes run off the event loop so the TUI keeps
290
+ * painting (Working line, spinner) while a big tree is scanned. Additional
291
+ * guards the sync version never had:
292
+ * - skip when the work-tree exceeds CP_MAX_FILES candidate files,
293
+ * - prune old refs after a successful snapshot so the shadow repo stays
294
+ * bounded (seen live: 521 MB after a week without pruning).
295
+ */
296
+ async function takeCheckpointAsync(cwd, reason, task = '') {
297
+ try {
298
+ if (!fs.existsSync(cwd))
299
+ return null;
300
+ const n = countCandidateFiles(cwd);
301
+ if (n !== null && n > CP_MAX_FILES)
302
+ return null;
303
+ const gitDir = gitDirFor(cwd);
304
+ if (!ensureShadowRepo(gitDir))
305
+ return null;
306
+ // -A stages the full tree incl. deletions and untracked files, minus
307
+ // CP_EXCLUDE via pathspec excludes (the candidate walk above is only
308
+ // the size guard; git itself must also skip the folders).
309
+ const add = await gitAsync(gitDir, addArgs(), cwd);
310
+ if (!add.ok)
311
+ return null;
312
+ const tree = await gitAsync(gitDir, ['write-tree']);
313
+ if (!tree.ok)
314
+ return null;
315
+ const treeHash = parseHash(tree.out);
316
+ if (!treeHash)
317
+ return null;
318
+ const { ref, time, msg } = stampRef(reason, task);
319
+ const commit = await gitAsync(gitDir, ['commit-tree', treeHash, '-m', msg]);
320
+ if (!commit.ok)
321
+ return null;
322
+ const hash = parseHash(commit.out);
323
+ if (!hash)
324
+ return null;
325
+ const upd = await gitAsync(gitDir, ['update-ref', `${REF_PREFIX}${ref}`, hash]);
326
+ if (!upd.ok)
327
+ return null;
328
+ // Bound the shadow repo: keep the newest MAX_CHECKPOINTS refs.
329
+ pruneCheckpoints(cwd);
330
+ return { ref, time, reason, task: task.slice(0, 80), hash };
147
331
  }
148
332
  catch {
149
333
  return null;
@@ -146,15 +146,25 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
146
146
  // Best-effort: null = no net, the run proceeds regardless.
147
147
  const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'delete_file', 'apply_patch', 'shell', 'git', 'run_in_background']);
148
148
  let checkpointTaken = false;
149
+ // Snapshot is ASYNC and runs INSIDE runOne (after onToolCall): the tool
150
+ // card must appear IMMEDIATELY, never behind the snapshot. The old sync
151
+ // design (spawnSync before onToolCall) froze the TUI for the whole
152
+ // snapshot — on a big cwd that read as "mentok di Edit" for 40+ seconds
153
+ // (bench VPS: git add -A pada /root = 41s).
149
154
  const maybeCheckpoint = (toolName) => {
150
155
  if (checkpointTaken || !MUTATING_TOOLS.has(toolName))
151
- return;
156
+ return Promise.resolve();
152
157
  checkpointTaken = true;
153
- const cp = (0, checkpoints_1.takeCheckpoint)(cwd, toolName, userRequest);
154
- if (cp) {
155
- callbacks.onStatus?.(`Checkpoint siap (${cp.ref})`);
156
- callbacks.onActivity?.('checkpoint', `safety snapshot ${cp.ref}`);
157
- }
158
+ return (0, checkpoints_1.takeCheckpointAsync)(cwd, toolName, userRequest)
159
+ .then((cp) => {
160
+ if (cp) {
161
+ callbacks.onStatus?.(`Checkpoint siap (${cp.ref})`);
162
+ callbacks.onActivity?.('checkpoint', `safety snapshot ${cp.ref}`);
163
+ }
164
+ })
165
+ .catch(() => {
166
+ /* best-effort — never break the run */
167
+ });
158
168
  };
159
169
  const checkAbort = () => {
160
170
  if (signal?.aborted) {
@@ -399,7 +409,10 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
399
409
  'output_from_background',
400
410
  ]);
401
411
  const runOne = async (tc) => {
412
+ // Tool card FIRST (visible instantly), snapshot AFTER — the card
413
+ // must never wait for the checkpoint git scan.
402
414
  callbacks.onToolCall?.(tc.name, tc.arguments, tc.id);
415
+ const cpPromise = maybeCheckpoint(tc.name);
403
416
  let toolResult;
404
417
  try {
405
418
  toolResult = await tools.execute(tc.name, tc.arguments, {
@@ -416,6 +429,9 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
416
429
  // as a failed result so the TUI can finalize the card.
417
430
  toolResult = { output: `ERROR: ${err.message}`, summary: 'tool error' };
418
431
  }
432
+ // The run waits for the (async) safety net before the next model
433
+ // step, but the tool card above is already visible.
434
+ await cpPromise;
419
435
  return { tc, toolResult };
420
436
  };
421
437
  try {
@@ -437,11 +453,9 @@ async function runAgentLoop(userRequest, opts, callbacks = {}) {
437
453
  for (const batch of batches) {
438
454
  checkAbort();
439
455
  const parallel = batch.length > 1 && batch.every((tc) => PARALLEL_TOOLS.has(tc.name));
440
- // Safety net BEFORE the first mutating call of this run — armed
441
- // once, lazily; parallel batches are read-only by construction and
442
- // never trigger it.
443
- if (!parallel)
444
- maybeCheckpoint(batch[0].name);
456
+ // Safety net moved INSIDE runOne (after onToolCall) so the tool
457
+ // card renders instantly; armed once per run, lazily. Parallel
458
+ // batches are read-only by construction and never trigger it.
445
459
  toolCalls += batch.length;
446
460
  if (!parallel) {
447
461
  callbacks.onStatus?.(`Running ${batch[0].name}`);
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createSemanticSearchTools = createSemanticSearchTools;
37
+ /**
38
+ * Semantic code search — "agent yang hafal codebase" (2026-09-14).
39
+ *
40
+ * Alur: chunk file proyek → embedding via GATEWAY relay (/v1/embeddings,
41
+ * zero-config & zero-key — key provider sk- hidup di gateway, pola sama
42
+ * dengan e2b.ts/web.ts) → simpan vektor di file lokal (~/.vector/index.json)
43
+ * → cosine similarity saat query → top-k potongan paling relevan.
44
+ *
45
+ * Zero dependency: embedding = HTTP call, similarity = dot product manual,
46
+ * storage = satu file JSON. Index dibangun manual via tool semantic_index
47
+ * (atau otomatis saat semantic_search pertama tanpa index).
48
+ *
49
+ * Batas wajar (anti-abuse & anti-bloat):
50
+ * - max file 400 (arg hingga 2000), skip >200KB & file binary-ish
51
+ * - max chunk 4000; batch embedding 16 input/request; jeda antar-batch
52
+ * 1.1s (rate limit gateway 60/menit — env VECTOR_EMBED_DELAY_MS utk test)
53
+ * - skip nama file sensitif (credential/secret/.env/key/pem) — index tidak
54
+ * boleh jadi salinan rahasia
55
+ */
56
+ const fs = __importStar(require("node:fs"));
57
+ const path = __importStar(require("node:path"));
58
+ const free_tier_1 = require("../config/free-tier");
59
+ const paths_1 = require("../utils/paths");
60
+ const GW_BASE = free_tier_1.FREE_GATEWAY_URL.replace(/\/v1$/, '');
61
+ const EMBED_URL = GW_BASE + '/v1/embeddings';
62
+ const DEFAULT_EMBED_MODEL = 'text-embedding-v4';
63
+ const IGNORE = new Set(['.git', 'node_modules', 'vendor', 'dist', 'build', '.cache', '.next', 'target', 'coverage', 'sessions', 'logs', 'memory']);
64
+ const TEXT_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.py', '.go', '.rs', '.java', '.rb', '.php', '.sh', '.bash', '.yml', '.yaml', '.toml', '.html', '.css', '.scss', '.sql', '.c', '.h', '.cpp', '.hpp', '.cs', '.swift', '.kt', '.txt', '.dockerfile', '.ini', '.cfg']);
65
+ const SENSITIVE = /credential|secret|\.env|\.pem|\.key|id_rsa|password/i;
66
+ const MAX_FILE_BYTES = 200_000;
67
+ const MAX_FILES_DEFAULT = 400;
68
+ const MAX_FILES_HARD = 2000;
69
+ const MAX_CHUNKS = 4000;
70
+ const CHUNK_LINES = 64;
71
+ const BATCH = 16;
72
+ const DELAY_MS = Math.max(0, Number(process.env.VECTOR_EMBED_DELAY_MS ?? 1100));
73
+ function indexPath() {
74
+ return path.join((0, paths_1.getVectorDir)(), 'index.json');
75
+ }
76
+ function loadIndex() {
77
+ try {
78
+ const raw = fs.readFileSync(indexPath(), 'utf8');
79
+ const j = JSON.parse(raw);
80
+ return j && j.version === 1 && Array.isArray(j.chunks) ? j : null;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ function saveIndex(idx) {
87
+ fs.mkdirSync((0, paths_1.getVectorDir)(), { recursive: true });
88
+ fs.writeFileSync(indexPath(), JSON.stringify(idx), { mode: 0o600 });
89
+ }
90
+ function walkFiles(dir, out, max) {
91
+ let entries;
92
+ try {
93
+ entries = fs.readdirSync(dir, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ return;
97
+ }
98
+ for (const e of entries) {
99
+ if (out.length >= max)
100
+ return;
101
+ if (IGNORE.has(e.name) || e.name.startsWith('.'))
102
+ continue;
103
+ if (SENSITIVE.test(e.name))
104
+ continue;
105
+ const full = path.join(dir, e.name);
106
+ if (e.isDirectory())
107
+ walkFiles(full, out, max);
108
+ else if (TEXT_EXT.has(path.extname(e.name).toLowerCase()) || e.name === 'Dockerfile')
109
+ out.push(full);
110
+ }
111
+ }
112
+ function chunkText(content) {
113
+ const lines = content.split('\n');
114
+ const chunks = [];
115
+ for (let i = 0; i < lines.length && chunks.length < MAX_CHUNKS; i += CHUNK_LINES) {
116
+ const slice = lines.slice(i, i + CHUNK_LINES);
117
+ const text = slice.join('\n').trim();
118
+ if (text)
119
+ chunks.push({ start: i + 1, end: i + slice.length, text });
120
+ }
121
+ return chunks;
122
+ }
123
+ async function embed(inputs, model) {
124
+ const controller = new AbortController();
125
+ const timer = setTimeout(() => controller.abort(), 30_000);
126
+ try {
127
+ const res = await fetch(EMBED_URL, {
128
+ method: 'POST',
129
+ signal: controller.signal,
130
+ headers: { 'Content-Type': 'application/json', 'User-Agent': 'VectorHead-AI-Agent/0.1' },
131
+ body: JSON.stringify({ model, input: inputs }),
132
+ });
133
+ const data = await res.json().catch(() => null);
134
+ if (!res.ok || !data || !Array.isArray(data.data)) {
135
+ const snippet = data && data.error ? JSON.stringify(data.error).slice(0, 200) : `HTTP ${res.status}`;
136
+ throw new Error(`gateway embeddings gagal: ${snippet}`);
137
+ }
138
+ // OpenAI format: data[i].embedding — urut by index utk aman.
139
+ const sorted = [...data.data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
140
+ return sorted.map((d) => d.embedding);
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
147
+ function cosine(a, b) {
148
+ let dot = 0, na = 0, nb = 0;
149
+ const n = Math.min(a.length, b.length);
150
+ for (let i = 0; i < n; i++) {
151
+ dot += a[i] * b[i];
152
+ na += a[i] * a[i];
153
+ nb += b[i] * b[i];
154
+ }
155
+ return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
156
+ }
157
+ async function buildIndex(root, maxFiles) {
158
+ const files = [];
159
+ walkFiles(root, files, maxFiles);
160
+ if (!files.length)
161
+ return `ERROR: tidak ada file teks yang bisa di-index di ${root}`;
162
+ const model = DEFAULT_EMBED_MODEL;
163
+ const chunks = [];
164
+ for (const full of files) {
165
+ let content;
166
+ try {
167
+ if (fs.statSync(full).size > MAX_FILE_BYTES)
168
+ continue;
169
+ content = fs.readFileSync(full, 'utf8');
170
+ }
171
+ catch {
172
+ continue;
173
+ }
174
+ if (content.includes('\u0000'))
175
+ continue; // binary-ish
176
+ const rel = path.relative(root, full).split(path.sep).join('/');
177
+ for (const c of chunkText(content))
178
+ chunks.push({ file: rel, ...c, embedding: [] });
179
+ }
180
+ if (!chunks.length)
181
+ return 'ERROR: tidak ada chunk yang bisa di-index';
182
+ for (let i = 0; i < chunks.length; i += BATCH) {
183
+ const vecs = await embed(chunks.slice(i, i + BATCH).map((c) => c.text), model);
184
+ if (vecs.length !== Math.min(BATCH, chunks.length - i)) {
185
+ return `ERROR: jumlah vektor upstream tidak cocok (${vecs.length})`;
186
+ }
187
+ vecs.forEach((v, j) => { chunks[i + j].embedding = v; });
188
+ if (i + BATCH < chunks.length && DELAY_MS)
189
+ await sleep(DELAY_MS);
190
+ }
191
+ saveIndex({ version: 1, model, builtAt: Date.now(), chunks });
192
+ const preview = [...new Set(chunks.map((c) => c.file))].slice(0, 5).join(', ');
193
+ return `OK: index dibangun — ${chunks.length} chunk dari ${files.length} file (model ${model}) → ${indexPath()}${preview ? '\nContoh: ' + preview : ''}`;
194
+ }
195
+ function searchIndex(idx, queryVec, k) {
196
+ const scored = idx.chunks
197
+ .map((c) => ({ c, score: cosine(queryVec, c.embedding) }))
198
+ .sort((a, b) => b.score - a.score)
199
+ .slice(0, Math.max(1, k));
200
+ if (!scored.length || scored[0].score <= 0) {
201
+ return 'Tidak ada hasil relevan (index kosong / skor 0) — coba semantic_index ulang.';
202
+ }
203
+ const lines = scored.map((s, i) => {
204
+ const head = s.c.text.replace(/\s+/g, ' ').slice(0, 180);
205
+ return `${i + 1}. ${s.c.file}:${s.c.start}-${s.c.end} (skor ${s.score.toFixed(3)})\n ${head}`;
206
+ });
207
+ return `Hasil semantic search (top ${scored.length} dari ${idx.chunks.length} chunk):\n${lines.join('\n')}`;
208
+ }
209
+ function createSemanticSearchTools() {
210
+ return [
211
+ {
212
+ definition: {
213
+ name: 'semantic_index',
214
+ description: 'Build/refresh the local semantic index: embed project files (via the VectorHead gateway, no API key needed) and store vectors in ~/.vector/index.json. Run once per project (or after big changes) — then semantic_search can find code by MEANING, not just keywords.',
215
+ parameters: {
216
+ type: 'object',
217
+ properties: {
218
+ path: { type: 'string', description: 'Directory to index, relative to project root (default ".")' },
219
+ max_files: { type: 'number', description: 'Max files to index (default 400, max 2000)' },
220
+ },
221
+ required: [],
222
+ },
223
+ },
224
+ async execute(args, ctx) {
225
+ const base = path.resolve(ctx.cwd, String(args.path || '.'));
226
+ const max = Math.min(MAX_FILES_HARD, Math.max(1, Number(args.max_files) || MAX_FILES_DEFAULT));
227
+ try {
228
+ return { output: await buildIndex(base, max) };
229
+ }
230
+ catch (e) {
231
+ return { output: 'ERROR: ' + String(e.message || e) };
232
+ }
233
+ },
234
+ },
235
+ {
236
+ definition: {
237
+ name: 'semantic_search',
238
+ description: 'Search the codebase by MEANING (semantic similarity over embedded chunks). Automatically builds the index first if none exists. Use for "where is the retry logic?" style questions where grep/keywords fail.',
239
+ parameters: {
240
+ type: 'object',
241
+ properties: {
242
+ query: { type: 'string', description: 'Natural-language query, e.g. "where do we validate the device id"' },
243
+ k: { type: 'number', description: 'Max results (default 5, max 20)' },
244
+ path: { type: 'string', description: 'Directory that was indexed (default ".")' },
245
+ },
246
+ required: ['query'],
247
+ },
248
+ },
249
+ async execute(args, ctx) {
250
+ const query = String(args.query || '').trim();
251
+ if (!query)
252
+ return { output: 'ERROR: query wajib' };
253
+ const k = Math.min(20, Math.max(1, Number(args.k) || 5));
254
+ try {
255
+ let idx = loadIndex();
256
+ if (!idx) {
257
+ const base = path.resolve(ctx.cwd, String(args.path || '.'));
258
+ const built = await buildIndex(base, MAX_FILES_DEFAULT);
259
+ if (built.startsWith('ERROR'))
260
+ return { output: built };
261
+ idx = loadIndex();
262
+ if (!idx)
263
+ return { output: 'ERROR: index gagal dibaca setelah build' };
264
+ }
265
+ const [qv] = await embed([query], idx.model || DEFAULT_EMBED_MODEL);
266
+ if (!qv)
267
+ return { output: 'ERROR: embedding query kosong' };
268
+ return { output: searchIndex(idx, qv, k) };
269
+ }
270
+ catch (e) {
271
+ return { output: 'ERROR: ' + String(e.message || e) };
272
+ }
273
+ },
274
+ },
275
+ ];
276
+ }
package/dist/tui/chat.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LOGO_SHEEN_MAX = exports.LOGO_SHEEN_INTERVAL_MS = exports.LOGO_SHEEN_STEP = exports.LOGO_FULL_MIN_COLS = exports.SIDE_GUTTER = exports.mergeStreamDelta = void 0;
3
+ exports.LOGO_SHEEN_MAX = exports.LOGO_SHEEN_INTERVAL_MS = exports.LOGO_SHEEN_STEP = exports.LOGO_FULL_ASCII_MIN_COLS = exports.LOGO_FULL_MIN_COLS = exports.SIDE_GUTTER = exports.mergeStreamDelta = void 0;
4
4
  exports.toolIcon = toolIcon;
5
5
  exports.toolLabel = toolLabel;
6
6
  exports.followupsGroupOf = followupsGroupOf;
@@ -1484,31 +1484,75 @@ function clampScroll(offset, messages, width, height) {
1484
1484
  /**
1485
1485
  * Full-width VECTORHEAD ASCII logo — font "ANSI Shadow", the same figlet
1486
1486
  * font Freebuff uses for its logo (CodebuffAI/freebuff
1487
- * `cli/src/login/constants.ts` LOGO). All 6 rows are exactly 83 columns
1488
- * (letter blocks: V=9 E=8 C=8 T=9 O=9 R=8 H=8 E=8 A=8 D=8) so the art is
1487
+ * `cli/src/login/constants.ts` LOGO). All 6 rows are exactly 56 columns
1488
+ * (letters V=9 E=8 C=8 T=9 O=9 R=8, joined by 1 space) so the art is
1489
1489
  * column-aligned and never wraps. Coloring follows Freebuff's use-logo
1490
1490
  * rules with a VectorHead accent: solid blocks (█) render in `logoBlock`
1491
1491
  * (white), shadow/border box-drawing chars render in orange #FF8534.
1492
+ *
1493
+ * ASCII safe mode (VECTOR_ASCII=1) uses DEDICATED plain-ASCII art instead
1494
+ * (VECTORHEAD_LOGO_ASCII below, figlet "Standard") — squeezing THIS font
1495
+ * 1:1 (█→#, ╔→+) keeps widths exact but destroys the letterforms.
1492
1496
  */
1493
1497
  const VECTORHEAD_LOGO = [
1494
- '██╗ ██╗███████╗ ██████╗████████╗ ██████╗ ██████╗ ██╗ ██╗███████╗ █████╗ ██████╗ ',
1495
- '██║ ██║██╔════╝██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗██║ ██║██╔════╝██╔══██╗██╔══██╗',
1496
- '██║ ██║█████╗ ██║ ██║ ██║ ██║██████╔╝███████║█████╗ ███████║██║ ██║',
1497
- '╚██╗ ██╔╝██╔══╝ ██║ ██║ ██║ ██║██╔══██╗██╔══██║██╔══╝ ██╔══██║██║ ██║',
1498
- ' ╚████╔╝ ███████╗╚██████╗ ██║ ╚██████╔╝██║ ██║██║ ██║███████╗██║ ██║██████╔╝',
1499
- ' ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ',
1498
+ '██╗ ██╗ ███████╗ ██████╗ ████████╗ ██████╗ ██████╗ ',
1499
+ '██║ ██║ ██╔════╝ ██╔════╝ ╚══██╔══╝ ██╔═══██╗ ██╔══██╗',
1500
+ '██║ ██║ █████╗ ██║ ██║ ██║ ██║ ██████╔╝',
1501
+ '╚██╗ ██╔╝ ██╔══╝ ██║ ██║ ██║ ██║ ██╔══██╗',
1502
+ ' ╚████╔╝ ███████╗ ╚██████╗ ██║ ╚██████╔╝ ██║ ██║',
1503
+ ' ╚═══╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝',
1504
+ ];
1505
+ /** Minimum terminal width for the full logo (~56 cols + margin). */
1506
+ exports.LOGO_FULL_MIN_COLS = 62;
1507
+ /** Minimum terminal width for the full ASCII (figlet) logo — 64 cols + margin. */
1508
+ exports.LOGO_FULL_ASCII_MIN_COLS = 68;
1509
+ /**
1510
+ * Plain-ASCII logo (figlet "Standard") for ASCII safe mode — real ASCII art
1511
+ * designed as art, NOT the block font squeezed char-per-char. All 6 rows
1512
+ * exactly 64 columns; pure \x20-\x7E so it renders 1 column per char on
1513
+ * EVERY font (the whole point of ASCII mode).
1514
+ */
1515
+ const VECTORHEAD_LOGO_ASCII = [
1516
+ ' __ _______ ____ _____ ___ ____ _ _ _____ _ ____ ',
1517
+ ' \\ \\ / / ____/ ___|_ _/ _ \\| _ \\| | | | ____| / \\ | _ \\ ',
1518
+ ' \\ \\ / /| _|| | | || | | | |_) | |_| | _| / _ \\ | | | |',
1519
+ ' \\ V / | |__| |___ | || |_| | _ <| _ | |___ / ___ \\| |_| |',
1520
+ ' \\_/ |_____\\____| |_| \\___/|_| \\ \\_| |_|_____/_/ \\ \\____/ ',
1521
+ ' ',
1522
+ ];
1523
+ /** Compact "VE" plain-ASCII logo (figlet "Standard") — 16 cols. */
1524
+ const VECTORHEAD_LOGO_SMALL_ASCII = [
1525
+ ' __ _______ ',
1526
+ ' \\ \\ / / ____|',
1527
+ ' \\ \\ / /| _| ',
1528
+ ' \\ V / | |___ ',
1529
+ ' \\_/ |_____|',
1530
+ ' ',
1500
1531
  ];
1501
- /** Minimum terminal width for the full logo (~82 cols + margin). */
1502
- exports.LOGO_FULL_MIN_COLS = 84;
1503
- /** Compact "VE" logo (Freebuff LOGO_SMALL tier: first letters, same font;
1504
- * column starts align across rows — trailing widths vary like Freebuff's). */
1532
+ /**
1533
+ * Pick the logo art + its full-tier width threshold for this terminal:
1534
+ * dedicated figlet ASCII art in ASCII safe mode, the ANSI Shadow block
1535
+ * font otherwise (Freebuff-style tiers).
1536
+ */
1537
+ function logoArt(width) {
1538
+ if ((0, terminal_1.asciiModeEnabled)()) {
1539
+ return width >= exports.LOGO_FULL_ASCII_MIN_COLS
1540
+ ? { rows: VECTORHEAD_LOGO_ASCII, minCols: exports.LOGO_FULL_ASCII_MIN_COLS }
1541
+ : { rows: VECTORHEAD_LOGO_SMALL_ASCII, minCols: 20 };
1542
+ }
1543
+ return width >= exports.LOGO_FULL_MIN_COLS
1544
+ ? { rows: VECTORHEAD_LOGO, minCols: exports.LOGO_FULL_MIN_COLS }
1545
+ : { rows: VECTORHEAD_LOGO_SMALL, minCols: 20 };
1546
+ }
1547
+ /** Compact "VE" logo (Freebuff LOGO_SMALL tier: same font, letters joined
1548
+ * by 1 space — all 6 rows exactly 18 columns, column-aligned). */
1505
1549
  const VECTORHEAD_LOGO_SMALL = [
1506
- '██╗ ██╗███████╗',
1507
- '██║ ██║██╔════╝',
1508
- '██║ ██║█████╗ ',
1509
- '╚██╗ ██╔╝██╔══╝ ',
1510
- ' ╚████╔╝███████╗',
1511
- ' ╚═══╝ ╚══════╝',
1550
+ '██╗ ██╗ ███████╗',
1551
+ '██║ ██║ ██╔════╝',
1552
+ '██║ ██║ █████╗ ',
1553
+ '╚██╗ ██╔╝ ██╔══╝ ',
1554
+ ' ╚████╔╝ ███████╗',
1555
+ ' ╚═══╝ ╚══════╝',
1512
1556
  ];
1513
1557
  /** Shadow/border logo characters — Freebuff SHADOW_CHARS (login/constants.ts). */
1514
1558
  const SHADOW_CHARS = new Set(['╚', '═', '╝', '║', '╔', '╗', '╠', '╣', '╦', '╩', '╬']);
@@ -1540,8 +1584,14 @@ function logoFgFor(char, charIndex, sheen) {
1540
1584
  }
1541
1585
  return theme_1.THEME.textBright;
1542
1586
  }
1543
- /** Paint one logo line with per-character colors (optional sheen sweep). */
1587
+ /** Paint one logo line with per-character colors (optional sheen sweep).
1588
+ * Plain-ASCII art rows (no block/shadow glyphs) render single-tone — the
1589
+ * two-tone painting below is defined for the block font only. */
1544
1590
  function paintLogoLine(line, sheen) {
1591
+ if (!/[█║═╔╗╚╝]/.test(line)) {
1592
+ // figlet ASCII art row: understated single-tone brand mark.
1593
+ return `${theme_1.THEME.muted}${line}${theme_1.THEME.reset}`;
1594
+ }
1545
1595
  let out = '';
1546
1596
  let color = '';
1547
1597
  for (let i = 0; i < line.length; i++) {
@@ -1575,9 +1625,10 @@ function paintLogoLine(line, sheen) {
1575
1625
  function buildWelcomeBlock(width, cwd, sheen = null) {
1576
1626
  const lines = [];
1577
1627
  // Freebuff use-logo tiers: full logo when wide, compact logo when medium,
1578
- // single-line wordmark on very narrow terminals.
1579
- const logo = width >= exports.LOGO_FULL_MIN_COLS ? VECTORHEAD_LOGO : VECTORHEAD_LOGO_SMALL;
1580
- if (width >= 20) {
1628
+ // single-line wordmark on very narrow terminals (art picked by logoArt —
1629
+ // dedicated figlet ASCII in ASCII safe mode, block font otherwise).
1630
+ const { rows: logo, minCols } = logoArt(width);
1631
+ if (width >= minCols) {
1581
1632
  for (const l of logo)
1582
1633
  lines.push(paintLogoLine(l, sheen));
1583
1634
  }
@@ -1600,8 +1651,8 @@ function buildWelcomeBlock(width, cwd, sheen = null) {
1600
1651
  function renderWelcome(width, height, cwd, opts = {}) {
1601
1652
  const sheen = opts.sheen ?? null;
1602
1653
  const lines = [];
1603
- const logo = width >= exports.LOGO_FULL_MIN_COLS ? VECTORHEAD_LOGO : VECTORHEAD_LOGO_SMALL;
1604
- if (width >= 20) {
1654
+ const { rows: logo, minCols } = logoArt(width);
1655
+ if (width >= minCols) {
1605
1656
  for (const l of logo)
1606
1657
  lines.push(paintLogoLine(l, sheen));
1607
1658
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agen-vektor",
3
- "version": "0.3.30",
3
+ "version": "0.3.32",
4
4
  "description": "VectorHead (agen-vektor) — AI Coding Agent CLI/TUI for Linux & Termux. Multi-provider, tool calling, session, permission system.",
5
5
  "type": "commonjs",
6
6
  "bin": {