@cassiomc1/forgeloop 1.10.2 → 1.11.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 (84) hide show
  1. package/.cursor/rules/project-loop.mdc +2 -2
  2. package/.forgeloop/forgeloop.gitignore +1 -0
  3. package/.github/copilot-instructions.md +2 -2
  4. package/AGENTS.md +1 -0
  5. package/AGENT_COMPATIBILITY.md +7 -0
  6. package/CLAUDE.md +1 -0
  7. package/DOCS_INDEX.md +10 -0
  8. package/LOOP_SYSTEM_DESIGN.md +12 -0
  9. package/ORCHESTRATOR_INTEGRATION.md +9 -0
  10. package/PROTOCOL_INTEGRATION.md +17 -0
  11. package/README.md +12 -10
  12. package/THIRD_PARTY_NOTICES.md +11 -0
  13. package/THREAT_MODEL.md +21 -0
  14. package/benchmarks/repository-index/README.md +73 -0
  15. package/benchmarks/repository-index/queries.json +12 -0
  16. package/benchmarks/repository-index/run-hot-path.mjs +142 -0
  17. package/benchmarks/repository-index/run-persistent-transport.mjs +169 -0
  18. package/completions/_forgeloop +7 -1
  19. package/completions/forgeloop.bash +13 -1
  20. package/completions/forgeloop.fish +40 -1
  21. package/docs/AGENT_PROTOCOL_SUMMARY.md +8 -1
  22. package/docs/CLI_REFERENCE.md +114 -2
  23. package/docs/DOCUMENTATION_GUIDE.md +10 -5
  24. package/docs/GETTING_STARTED.md +17 -0
  25. package/docs/MCP.md +16 -1
  26. package/docs/PACKAGE_CONTENTS.md +7 -1
  27. package/docs/PERSISTENT_SEARCH_TRANSPORT.md +289 -0
  28. package/docs/RECIPES.md +30 -0
  29. package/docs/RELEASE_CHECKLIST.md +34 -0
  30. package/docs/REPOSITORY_INDEX.md +553 -0
  31. package/docs/TROUBLESHOOTING.md +147 -0
  32. package/docs/UNIVERSAL_INTEGRATION.md +30 -0
  33. package/docs/diagrams/README.md +10 -0
  34. package/package.json +11 -2
  35. package/scripts/update-tgrep-manifest.mjs +86 -0
  36. package/scripts/verify-tgrep-manifest.mjs +17 -0
  37. package/src/cli.js +35 -0
  38. package/src/commands/doctor.js +76 -1
  39. package/src/commands/index-rebuild.js +1 -0
  40. package/src/commands/index-setup.js +1 -0
  41. package/src/commands/index-start.js +1 -0
  42. package/src/commands/index-status.js +1 -0
  43. package/src/commands/index-stop.js +1 -0
  44. package/src/commands/init.js +39 -1
  45. package/src/commands/repository-index.js +111 -0
  46. package/src/commands/search.js +1 -0
  47. package/src/commands/update.js +32 -4
  48. package/src/core/cli-command-definitions.js +97 -3
  49. package/src/core/command-executors.js +35 -2
  50. package/src/core/command-input.js +23 -0
  51. package/src/core/error-codes.js +195 -0
  52. package/src/core/filesystem.js +10 -1
  53. package/src/core/integration-invocation-policy.js +27 -0
  54. package/src/core/integration-resources.js +16 -1
  55. package/src/core/protocol-info.js +23 -0
  56. package/src/integration.d.ts +90 -0
  57. package/src/integration.js +21 -0
  58. package/src/persistent-transport/client.js +293 -0
  59. package/src/persistent-transport/constants.js +24 -0
  60. package/src/persistent-transport/errors.js +38 -0
  61. package/src/persistent-transport/framing.js +61 -0
  62. package/src/persistent-transport/lifecycle.js +116 -0
  63. package/src/persistent-transport/ownership.js +184 -0
  64. package/src/persistent-transport/paths.js +31 -0
  65. package/src/persistent-transport/protocol.js +95 -0
  66. package/src/persistent-transport/server.js +256 -0
  67. package/src/persistent-transport/state.js +49 -0
  68. package/src/repository-index/args.js +59 -0
  69. package/src/repository-index/binary-manager.js +413 -0
  70. package/src/repository-index/constants.js +45 -0
  71. package/src/repository-index/errors.js +38 -0
  72. package/src/repository-index/lifecycle.js +17 -0
  73. package/src/repository-index/lock.js +113 -0
  74. package/src/repository-index/manifest.js +132 -0
  75. package/src/repository-index/metrics.js +30 -0
  76. package/src/repository-index/normalize-json.js +187 -0
  77. package/src/repository-index/paths.js +39 -0
  78. package/src/repository-index/platform.js +20 -0
  79. package/src/repository-index/process.js +140 -0
  80. package/src/repository-index/readiness.js +62 -0
  81. package/src/repository-index/search.js +262 -0
  82. package/src/repository-index/server.js +432 -0
  83. package/src/repository-index/status.js +397 -0
  84. package/src/repository-index/tgrep-manifest.json +38 -0
@@ -0,0 +1,256 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { chmod, unlink } from "node:fs/promises";
6
+
7
+ import { searchRepository } from "../repository-index/search.js";
8
+ import { getPackageRoot } from "../core/templates.js";
9
+ import { PERSISTENT_TRANSPORT_DEFAULTS, PERSISTENT_TRANSPORT_PROTOCOL_VERSION } from "./constants.js";
10
+ import { encodeFrame, FrameDecoder, parseFrame } from "./framing.js";
11
+ import { createErrorResponse, createSuccessResponse, validateRequest, assertSearchParams } from "./protocol.js";
12
+ import { PERSISTENT_TRANSPORT_ERROR_CODES, persistentTransportError } from "./errors.js";
13
+ import { getPersistentTransportPaths } from "./paths.js";
14
+ import { writePersistentTransportState, removePersistentTransportState } from "./state.js";
15
+ import { ensurePersistentTransportDirectory } from "./lifecycle.js";
16
+
17
+ function argumentValue(name, fallback = null) {
18
+ const prefix = `${name}=`;
19
+ const argument = process.argv.find((value) => value.startsWith(prefix));
20
+ return argument ? argument.slice(prefix.length) : fallback;
21
+ }
22
+
23
+ function numberArgument(name, fallback) {
24
+ const value = Number(argumentValue(name, ""));
25
+ return Number.isSafeInteger(value) && value > 0 ? value : fallback;
26
+ }
27
+
28
+ function safeError(error) {
29
+ if (error?.code) return error;
30
+ return persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_REQUEST, error?.message ?? "Transport request failed");
31
+ }
32
+
33
+ function publicSearchRequest(query) {
34
+ return {
35
+ pattern: query.pattern,
36
+ globs: query.globs,
37
+ types: query.types,
38
+ context: query.context,
39
+ beforeContext: query.beforeContext,
40
+ afterContext: query.afterContext,
41
+ maxCount: query.maxCount,
42
+ filesWithMatches: query.filesWithMatches,
43
+ stats: query.stats,
44
+ fixedStrings: query.fixedStrings,
45
+ ignoreCase: query.ignoreCase,
46
+ smartCase: query.smartCase,
47
+ wordRegexp: query.wordRegexp,
48
+ packageRoot: getPackageRoot(),
49
+ };
50
+ }
51
+
52
+ class PersistentSearchHost {
53
+ #homeDirectory;
54
+ #paths;
55
+ #idleTimeoutMs;
56
+ #server = null;
57
+ #state = null;
58
+ #connections = new Set();
59
+ #inflight = 0;
60
+ #idleTimer = null;
61
+ #closing = false;
62
+ #repositoryQueues = new Map();
63
+
64
+ constructor({ homeDirectory, idleTimeoutMs }) {
65
+ this.#homeDirectory = homeDirectory;
66
+ this.#paths = getPersistentTransportPaths({ homeDirectory });
67
+ this.#idleTimeoutMs = idleTimeoutMs;
68
+ }
69
+
70
+ async start() {
71
+ await ensurePersistentTransportDirectory({ homeDirectory: this.#homeDirectory });
72
+ this.#state = {
73
+ pid: process.pid,
74
+ protocolVersion: PERSISTENT_TRANSPORT_PROTOCOL_VERSION,
75
+ forgeLoopVersion: await this.#packageVersion(),
76
+ scopeId: this.#paths.scopeId,
77
+ nonce: randomUUID(),
78
+ endpoint: this.#paths.endpoint,
79
+ entrypoint: path.resolve(fileURLToPath(import.meta.url)),
80
+ startedAt: new Date().toISOString(),
81
+ };
82
+ this.#server = net.createServer((socket) => this.#handleConnection(socket));
83
+ await new Promise((resolve, reject) => {
84
+ const onError = (error) => {
85
+ this.#server.off("listening", onListening);
86
+ reject(error);
87
+ };
88
+ const onListening = () => {
89
+ this.#server.off("error", onError);
90
+ resolve();
91
+ };
92
+ this.#server.once("error", onError);
93
+ this.#server.once("listening", onListening);
94
+ this.#server.listen(this.#paths.endpoint);
95
+ });
96
+ if (process.platform !== "win32") await chmod(this.#paths.endpoint, 0o600);
97
+ await writePersistentTransportState(this.#paths.statePath, this.#state);
98
+ this.#scheduleIdleShutdown();
99
+ return this.#state;
100
+ }
101
+
102
+ async #packageVersion() {
103
+ const { readFile } = await import("node:fs/promises");
104
+ const packageJson = JSON.parse(await readFile(path.join(getPackageRoot(), "package.json"), "utf8"));
105
+ return packageJson.version ?? null;
106
+ }
107
+
108
+ #handleConnection(socket) {
109
+ this.#connections.add(socket);
110
+ const decoder = new FrameDecoder({ maxFrameBytes: PERSISTENT_TRANSPORT_DEFAULTS.maxRequestFrameBytes });
111
+ const seenIds = new Set();
112
+ let handshaken = false;
113
+ let queue = Promise.resolve();
114
+ const close = () => {
115
+ this.#connections.delete(socket);
116
+ this.#scheduleIdleShutdown();
117
+ };
118
+ socket.on("close", close);
119
+ socket.on("error", close);
120
+ socket.on("data", (chunk) => {
121
+ try {
122
+ for (const frame of decoder.push(chunk)) {
123
+ let raw;
124
+ try {
125
+ raw = parseFrame(frame);
126
+ } catch (error) {
127
+ this.#write(socket, createErrorResponse(null, safeError(error)));
128
+ socket.destroy();
129
+ return;
130
+ }
131
+ queue = queue
132
+ .then(async () => {
133
+ let request;
134
+ try {
135
+ request = validateRequest(raw);
136
+ if (seenIds.has(request.id)) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_REQUEST, "Transport request id was reused on one connection");
137
+ seenIds.add(request.id);
138
+ const result = await this.#dispatch(request, () => { handshaken = true; }, () => handshaken);
139
+ this.#write(socket, createSuccessResponse(request.id, result));
140
+ } catch (error) {
141
+ this.#write(socket, createErrorResponse(typeof raw?.id === "string" ? raw.id : null, safeError(error)));
142
+ }
143
+ })
144
+ .catch(() => {
145
+ socket.destroy();
146
+ });
147
+ }
148
+ } catch (error) {
149
+ this.#write(socket, createErrorResponse(null, safeError(error)));
150
+ socket.destroy();
151
+ }
152
+ });
153
+ socket.on("end", () => {
154
+ try { decoder.end(); } catch { socket.destroy(); }
155
+ });
156
+ }
157
+
158
+ #write(socket, value) {
159
+ if (!socket.destroyed) {
160
+ try { socket.write(encodeFrame(value)); } catch { socket.destroy(); }
161
+ }
162
+ }
163
+
164
+ async #dispatch(request, markHandshake, isHandshaken) {
165
+ if (request.method === "handshake") {
166
+ if (request.params.scopeId !== this.#paths.scopeId) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "Transport scope does not match the host");
167
+ markHandshake();
168
+ return {
169
+ protocolVersion: PERSISTENT_TRANSPORT_PROTOCOL_VERSION,
170
+ forgeLoopVersion: this.#state.forgeLoopVersion,
171
+ pid: this.#state.pid,
172
+ nonce: this.#state.nonce,
173
+ scopeId: this.#state.scopeId,
174
+ };
175
+ }
176
+ if (!isHandshaken()) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_REQUEST, "Transport handshake is required before this method");
177
+ if (request.method === "transport.status") {
178
+ return { status: "READY", protocolVersion: this.#state.protocolVersion, forgeLoopVersion: this.#state.forgeLoopVersion };
179
+ }
180
+ if (request.method === "transport.shutdown") {
181
+ if (request.params.nonce !== this.#state.nonce) throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.OWNERSHIP_UNVERIFIED, "Transport shutdown nonce does not match the host");
182
+ globalThis.setImmediate(() => this.shutdown());
183
+ return { status: "SHUTTING_DOWN" };
184
+ }
185
+ if (request.method === "repository.search") {
186
+ const params = assertSearchParams(request.params);
187
+ const repositoryRoot = params.repository;
188
+ return this.#enqueueRepository(repositoryRoot, () => searchRepository(repositoryRoot, publicSearchRequest(params.query)));
189
+ }
190
+ throw persistentTransportError(PERSISTENT_TRANSPORT_ERROR_CODES.INVALID_REQUEST, `Unsupported transport method: ${request.method}`);
191
+ }
192
+
193
+ #enqueueRepository(repositoryRoot, callback) {
194
+ const previous = this.#repositoryQueues.get(repositoryRoot) ?? Promise.resolve();
195
+ const next = previous.catch(() => {}).then(async () => {
196
+ this.#inflight += 1;
197
+ this.#idleTimer && clearTimeout(this.#idleTimer);
198
+ try {
199
+ return await callback();
200
+ } finally {
201
+ this.#inflight -= 1;
202
+ if (this.#repositoryQueues.get(repositoryRoot) === next) this.#repositoryQueues.delete(repositoryRoot);
203
+ this.#scheduleIdleShutdown();
204
+ }
205
+ });
206
+ this.#repositoryQueues.set(repositoryRoot, next);
207
+ return next;
208
+ }
209
+
210
+ #scheduleIdleShutdown() {
211
+ if (this.#closing || this.#idleTimeoutMs <= 0) return;
212
+ if (this.#inflight > 0 || this.#connections.size > 0) return;
213
+ if (this.#idleTimer) clearTimeout(this.#idleTimer);
214
+ this.#idleTimer = setTimeout(() => {
215
+ this.#idleTimer = null;
216
+ if (this.#inflight === 0 && this.#connections.size === 0) this.shutdown().catch(() => {});
217
+ else this.#scheduleIdleShutdown();
218
+ }, this.#idleTimeoutMs);
219
+ this.#idleTimer.unref?.();
220
+ }
221
+
222
+ async shutdown() {
223
+ if (this.#closing) return;
224
+ this.#closing = true;
225
+ if (this.#idleTimer) clearTimeout(this.#idleTimer);
226
+ await removePersistentTransportState(this.#paths.statePath, this.#state?.nonce ?? null);
227
+ for (const socket of this.#connections) socket.end();
228
+ await new Promise((resolve) => {
229
+ if (!this.#server) return resolve();
230
+ this.#server.close(() => resolve());
231
+ setTimeout(resolve, 1_000).unref?.();
232
+ });
233
+ if (process.platform !== "win32") {
234
+ try { await unlink(this.#paths.endpoint); } catch (error) { if (error.code !== "ENOENT") throw error; }
235
+ }
236
+ }
237
+ }
238
+
239
+ export async function runPersistentSearchHost({ homeDirectory, idleTimeoutMs = PERSISTENT_TRANSPORT_DEFAULTS.idleTimeoutMs } = {}) {
240
+ const host = new PersistentSearchHost({ homeDirectory, idleTimeoutMs });
241
+ await host.start();
242
+ const stop = () => { host.shutdown().catch(() => {}); };
243
+ process.once("SIGTERM", stop);
244
+ process.once("SIGINT", stop);
245
+ return host;
246
+ }
247
+
248
+ if (process.argv.includes("--persistent-transport-server")) {
249
+ runPersistentSearchHost({
250
+ homeDirectory: argumentValue("--home-directory", process.env.FORGELOOP_PERSISTENT_TRANSPORT_HOME),
251
+ idleTimeoutMs: numberArgument("--idle-timeout-ms", PERSISTENT_TRANSPORT_DEFAULTS.idleTimeoutMs),
252
+ }).catch((error) => {
253
+ process.stderr.write(`${error.message}\n`);
254
+ process.exitCode = 1;
255
+ });
256
+ }
@@ -0,0 +1,49 @@
1
+ import { lstat, readFile, unlink } from "node:fs/promises";
2
+
3
+ import { writeFileAtomic } from "../core/filesystem.js";
4
+ import { PERSISTENT_TRANSPORT_SCHEMA_VERSION } from "./constants.js";
5
+
6
+ export async function readPersistentTransportState(statePath) {
7
+ try {
8
+ const info = await lstat(statePath);
9
+ if (!info.isFile() || info.isSymbolicLink()) return { state: null, invalid: true };
10
+ const state = JSON.parse(await readFile(statePath, "utf8"));
11
+ if (!state || typeof state !== "object" || state.schemaVersion !== PERSISTENT_TRANSPORT_SCHEMA_VERSION) return { state: null, invalid: true };
12
+ return { state, invalid: false };
13
+ } catch (error) {
14
+ if (error.code === "ENOENT") return { state: null, invalid: false };
15
+ if (error instanceof SyntaxError) return { state: null, invalid: true };
16
+ throw error;
17
+ }
18
+ }
19
+
20
+ export async function writePersistentTransportState(statePath, state) {
21
+ const record = {
22
+ schemaVersion: PERSISTENT_TRANSPORT_SCHEMA_VERSION,
23
+ pid: state.pid,
24
+ protocolVersion: state.protocolVersion,
25
+ forgeLoopVersion: state.forgeLoopVersion,
26
+ scopeId: state.scopeId,
27
+ nonce: state.nonce,
28
+ endpoint: state.endpoint,
29
+ entrypoint: state.entrypoint,
30
+ startedAt: state.startedAt,
31
+ lastActivityAt: state.lastActivityAt ?? state.startedAt,
32
+ };
33
+ await writeFileAtomic(statePath, `${JSON.stringify(record)}\n`);
34
+ return record;
35
+ }
36
+
37
+ export async function removePersistentTransportState(statePath, expectedNonce = null) {
38
+ if (expectedNonce !== null) {
39
+ const current = await readPersistentTransportState(statePath);
40
+ if (current.state?.nonce !== expectedNonce) return false;
41
+ }
42
+ try {
43
+ await unlink(statePath);
44
+ return true;
45
+ } catch (error) {
46
+ if (error.code === "ENOENT") return true;
47
+ throw error;
48
+ }
49
+ }
@@ -0,0 +1,59 @@
1
+ import { REPOSITORY_INDEX_DEFAULTS } from "./constants.js";
2
+
3
+ function pushPair(args, flag, value) {
4
+ args.push(flag, String(value));
5
+ }
6
+
7
+ export function getCanonicalRepositoryIndexArgs({ mode, indexPath, config = {} } = {}) {
8
+ if (!["index", "serve", "search", "status"].includes(mode)) {
9
+ throw new Error(`Unknown Repository Index command mode: ${mode}`);
10
+ }
11
+ if (typeof indexPath !== "string" || indexPath.trim() === "") {
12
+ throw new Error("Repository Index indexPath is required");
13
+ }
14
+
15
+ const effective = {
16
+ ...REPOSITORY_INDEX_DEFAULTS,
17
+ ...config,
18
+ };
19
+ const args = [];
20
+ pushPair(args, "--index-path", indexPath);
21
+ pushPair(args, "--max-filesize", effective.maxFileSize);
22
+ if (["index", "serve"].includes(mode)) {
23
+ args.push("--exclude", ".forgeloop/repository-index");
24
+ }
25
+ args.push("--no-require-git");
26
+
27
+ if (mode === "index") {
28
+ args.push("--index-strategy", "external");
29
+ }
30
+ if (mode === "serve") {
31
+ pushPair(args, "--max-cpu", effective.maxCpuPercent);
32
+ pushPair(args, "--watcher-queue-cap", effective.watcherQueueCap);
33
+ pushPair(args, "--auto-save-mutations", effective.autoSaveMutations);
34
+ }
35
+ return Object.freeze(args);
36
+ }
37
+
38
+ export function getCanonicalRepositorySearchArgs({ indexPath, pattern, options = {}, config = {} } = {}) {
39
+ const args = ["--regexp", pattern];
40
+ if (options.fixedStrings) args.push("--fixed-strings");
41
+ if (options.ignoreCase) args.push("--ignore-case");
42
+ if (options.smartCase) args.push("--smart-case");
43
+ if (options.wordRegexp) args.push("--word-regexp");
44
+ if (options.context !== undefined && options.context !== null) pushPair(args, "--context", options.context);
45
+ if (options.beforeContext !== undefined && options.beforeContext !== null) pushPair(args, "--before-context", options.beforeContext);
46
+ if (options.afterContext !== undefined && options.afterContext !== null) pushPair(args, "--after-context", options.afterContext);
47
+ if (options.maxCount !== undefined && options.maxCount !== null) pushPair(args, "--max-count", options.maxCount);
48
+ if (options.filesWithMatches) args.push("--files-with-matches");
49
+ if (options.stats) args.push("--stats");
50
+ args.push("--json");
51
+ args.push(...getCanonicalRepositoryIndexArgs({ mode: "search", indexPath, config }));
52
+ return Object.freeze(args);
53
+ }
54
+
55
+ export function appendSearchFilters(args, { globs = [], types = [] } = {}) {
56
+ for (const glob of globs) args.push("--glob", glob);
57
+ for (const type of types) args.push("--type", type);
58
+ return args;
59
+ }