agentlas 1.0.65 → 1.0.67
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.
- package/CHANGELOG.md +94 -0
- package/README.md +12 -4
- package/bin/agentlas.cjs +354 -16
- package/engine/acp/server.cjs +34 -3
- package/engine/agentlas-api-agent.cjs +151 -120
- package/engine/agentlas-banner.cjs +6 -2
- package/engine/agentlas-capabilities.cjs +17 -10
- package/engine/agentlas-cloud-runtime.cjs +4 -5
- package/engine/agentlas-config.cjs +19 -54
- package/engine/agentlas-core-harness.cjs +59 -5
- package/engine/agentlas-desktop-loadout.cjs +17 -3
- package/engine/agentlas-evolution.cjs +75 -18
- package/engine/agentlas-experience-exchange.cjs +145 -14
- package/engine/agentlas-experience-intake.cjs +2 -2
- package/engine/agentlas-i18n.cjs +0 -2
- package/engine/agentlas-input.cjs +273 -20
- package/engine/agentlas-judgment.cjs +0 -0
- package/engine/agentlas-mcp-env.cjs +84 -23
- package/engine/agentlas-mcp-wrapper.cjs +36 -4
- package/engine/agentlas-memory-governance.cjs +2 -1
- package/engine/agentlas-memory-import.cjs +49 -12
- package/engine/agentlas-native-host.cjs +279 -44
- package/engine/agentlas-onboard.cjs +23 -1
- package/engine/agentlas-permissions.cjs +5 -1
- package/engine/agentlas-sqlite-policy.cjs +40 -2
- package/engine/agentlas-tasks.cjs +7 -0
- package/engine/agentlas-tools.cjs +37 -10
- package/engine/agentlas-ui.cjs +26 -4
- package/engine/agentlas-workforce.cjs +30 -5
- package/engine/agentlas-workload-routing.cjs +83 -10
- package/engine/agentlas.cjs +21 -4
- package/engine/agents/builder.cjs +35 -10
- package/engine/agents/files.cjs +81 -10
- package/engine/agents/import-local.cjs +10 -2
- package/engine/agents/registry.cjs +10 -3
- package/engine/agents/router.cjs +7 -7
- package/engine/agents/routes.cjs +31 -9
- package/engine/automation/daemon.cjs +154 -40
- package/engine/automation/launchd.cjs +395 -16
- package/engine/automation/schedule.cjs +90 -3
- package/engine/automation/store.cjs +163 -59
- package/engine/browser/cdp.cjs +190 -17
- package/engine/browser/vault.cjs +1 -0
- package/engine/cli-output.cjs +96 -30
- package/engine/cloud/auth.cjs +131 -13
- package/engine/cloud/hub-client.cjs +41 -16
- package/engine/cloud-assets/cas.cjs +18 -9
- package/engine/cloud-assets/commands.cjs +108 -30
- package/engine/cloud-assets/package.cjs +66 -3
- package/engine/cloud-assets/restore.cjs +12 -9
- package/engine/cloud-assets/state.cjs +609 -116
- package/engine/commands/acp.cjs +13 -2
- package/engine/commands/automation.cjs +75 -24
- package/engine/commands/billing.cjs +6 -1
- package/engine/commands/browser.cjs +61 -29
- package/engine/commands/build.cjs +72 -14
- package/engine/commands/call.cjs +1 -1
- package/engine/commands/cd.cjs +1 -1
- package/engine/commands/cloud.cjs +2 -1
- package/engine/commands/connect.cjs +38 -13
- package/engine/commands/creds.cjs +74 -29
- package/engine/commands/doctor.cjs +17 -1
- package/engine/commands/document.cjs +38 -10
- package/engine/commands/env.cjs +6 -1
- package/engine/commands/firm.cjs +42 -12
- package/engine/commands/graph.cjs +36 -32
- package/engine/commands/help.cjs +11 -1
- package/engine/commands/hep.cjs +1 -1
- package/engine/commands/import.cjs +5 -4
- package/engine/commands/index.cjs +1 -1
- package/engine/commands/install.cjs +6 -4
- package/engine/commands/list.cjs +56 -16
- package/engine/commands/login.cjs +5 -0
- package/engine/commands/logout.cjs +6 -1
- package/engine/commands/mcp.cjs +150 -37
- package/engine/commands/memory.cjs +3 -2
- package/engine/commands/multimodal.cjs +12 -0
- package/engine/commands/native.cjs +5 -1
- package/engine/commands/one.cjs +78 -33
- package/engine/commands/plugin.cjs +13 -2
- package/engine/commands/project.cjs +15 -12
- package/engine/commands/research.cjs +18 -7
- package/engine/commands/roles.cjs +104 -28
- package/engine/commands/route.cjs +2 -2
- package/engine/commands/run.cjs +42 -9
- package/engine/commands/search.cjs +23 -2
- package/engine/commands/setup.cjs +6 -1
- package/engine/commands/storm.cjs +44 -6
- package/engine/commands/swarm.cjs +47 -9
- package/engine/commands/telegram.cjs +6 -1
- package/engine/commands/uninstall.cjs +12 -6
- package/engine/commands/update.cjs +5 -0
- package/engine/commands/upload.cjs +2 -1
- package/engine/commands/usage.cjs +6 -1
- package/engine/commands/version.cjs +6 -1
- package/engine/commands/whoami.cjs +6 -1
- package/engine/commands/workforce.cjs +60 -16
- package/engine/core/capability-grants.cjs +7 -2
- package/engine/core/daemon-client.cjs +36 -7
- package/engine/core/db.cjs +10 -2
- package/engine/core/desktop-core-fetch.cjs +477 -77
- package/engine/core/schema-ensure.cjs +23 -11
- package/engine/experience/build.cjs +5 -1
- package/engine/experience/intents.cjs +35 -82
- package/engine/experience/runtime.cjs +7 -1
- package/engine/experience/variant.cjs +58 -4
- package/engine/firms/orchestrate.cjs +9 -6
- package/engine/graph/ask-model.cjs +22 -11
- package/engine/graph/interview.cjs +74 -23
- package/engine/graph/package.cjs +63 -8
- package/engine/hephaestus/local-core.cjs +18 -5
- package/engine/hephaestus/runtime.cjs +48 -43
- package/engine/hub/install.cjs +484 -63
- package/engine/hub/plugins.cjs +391 -29
- package/engine/mcp/consent.cjs +25 -17
- package/engine/mcp/contract.cjs +229 -24
- package/engine/mcp/inventory.cjs +46 -10
- package/engine/mcp/plan.cjs +10 -1
- package/engine/mcp/probe.cjs +24 -9
- package/engine/memory-cli/curate.cjs +74 -16
- package/engine/oberon/common.cjs +37 -12
- package/engine/oberon/manifest.cjs +25 -2
- package/engine/oberon/outputs.cjs +49 -19
- package/engine/oberon/render.cjs +665 -40
- package/engine/project/career-graph.cjs +15 -2
- package/engine/project/controller.cjs +133 -15
- package/engine/project/credentials.cjs +382 -34
- package/engine/project/env-file.cjs +93 -8
- package/engine/project/memory-context.cjs +11 -57
- package/engine/project/ontology.cjs +66 -25
- package/engine/project/paths.cjs +42 -8
- package/engine/project/seed.cjs +88 -51
- package/engine/project/state.cjs +41 -16
- package/engine/project/team.cjs +26 -1
- package/engine/runtime-refusal.cjs +4 -1
- package/engine/runtimes/acp-driver.cjs +56 -9
- package/engine/runtimes/auth-evidence.cjs +21 -2
- package/engine/runtimes/detect.cjs +28 -8
- package/engine/runtimes/kinds.cjs +12 -4
- package/engine/runtimes/overrides.cjs +4 -3
- package/engine/runtimes/resolve.cjs +8 -7
- package/engine/runtimes/roles.cjs +7 -5
- package/engine/sessions/apply-fences.cjs +396 -56
- package/engine/sessions/fences.cjs +111 -27
- package/engine/sessions/memory-turn.cjs +62 -3
- package/engine/sessions/orchestrator.cjs +11 -1
- package/engine/sessions/prompt.cjs +66 -9
- package/engine/sessions/session.cjs +70 -10
- package/engine/sessions/sink.cjs +78 -12
- package/engine/sessions/store.cjs +29 -7
- package/engine/storm/storm.cjs +26 -3
- package/engine/storm/swarm.cjs +31 -3
- package/engine/telegram/connect.cjs +185 -22
- package/engine/ui/commands-catalog.cjs +1 -1
- package/engine/ui/palette.cjs +12 -2
- package/engine/ui/repl.cjs +57 -9
- package/engine/ui/screens.cjs +2 -4
- package/engine/vendor/desktop-core.manifest.json +5 -5
- package/engine/workforce/capture.cjs +9 -27
- package/engine/workforce/deps.cjs +18 -6
- package/engine/workforce/local-core-transport.cjs +68 -5
- package/package.json +1 -1
- package/engine/commands/career-graph.cjs +0 -51
package/engine/sessions/sink.cjs
CHANGED
|
@@ -14,6 +14,53 @@
|
|
|
14
14
|
const i18n = require("../agentlas-i18n.cjs");
|
|
15
15
|
|
|
16
16
|
const NOOP_PALETTE = new Proxy({}, { get: () => (s) => String(s) });
|
|
17
|
+
const STATUS_TEXT_MAX = 8 * 1024;
|
|
18
|
+
const EVENT_TEXT_MAX = 64 * 1024;
|
|
19
|
+
const TOOL_NAME_MAX = 256;
|
|
20
|
+
const TOOL_SUMMARY_MAX = 16 * 1024;
|
|
21
|
+
const TASK_TOOL_INPUTS = new Set(["todowrite", "write_todos", "taskcreate", "taskupdate"]);
|
|
22
|
+
const TASK_TOOL_RESULTS = new Set(["taskcreate", "taskupdate", "tasklist"]);
|
|
23
|
+
const TASK_RECORD_KEYS = new Set([
|
|
24
|
+
"id", "taskId", "content", "subject", "description", "text", "title", "activeForm", "status", "completed",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function taskScalar(key, value) {
|
|
28
|
+
if (typeof value === "boolean" || typeof value === "number") return value;
|
|
29
|
+
if (typeof value !== "string") return undefined;
|
|
30
|
+
const max = key === "id" || key === "taskId" ? 256 : key === "status" ? 64 : 2_000;
|
|
31
|
+
return Array.from(value).slice(0, max).join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function taskRecord(value) {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
36
|
+
const projected = {};
|
|
37
|
+
for (const [key, child] of Object.entries(value)) {
|
|
38
|
+
if (!TASK_RECORD_KEYS.has(key)) continue;
|
|
39
|
+
const safe = taskScalar(key, child);
|
|
40
|
+
if (safe !== undefined) projected[key] = safe;
|
|
41
|
+
}
|
|
42
|
+
return projected;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function taskPayload(value) {
|
|
46
|
+
if (Array.isArray(value)) return value.map(taskRecord).filter(Boolean).slice(0, 500);
|
|
47
|
+
const projected = taskRecord(value);
|
|
48
|
+
if (!projected) return null;
|
|
49
|
+
for (const key of ["todos", "items", "tasks"]) {
|
|
50
|
+
if (Array.isArray(value[key])) projected[key] = value[key].map(taskRecord).filter(Boolean).slice(0, 500);
|
|
51
|
+
}
|
|
52
|
+
if (value.task && typeof value.task === "object" && !Array.isArray(value.task)) {
|
|
53
|
+
projected.task = taskRecord(value.task);
|
|
54
|
+
}
|
|
55
|
+
return projected;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function boundedText(value, max, suffix = "… [truncated]") {
|
|
59
|
+
const text = String(value || "");
|
|
60
|
+
if (text.length <= max) return text;
|
|
61
|
+
const keep = Math.max(0, max - suffix.length);
|
|
62
|
+
return text.slice(0, keep) + suffix;
|
|
63
|
+
}
|
|
17
64
|
|
|
18
65
|
class EventSink {
|
|
19
66
|
constructor({ lang = "en", onEvent, onPrivateEvidence } = {}) {
|
|
@@ -29,36 +76,55 @@ class EventSink {
|
|
|
29
76
|
this._onEvent({ type, at: Date.now(), ...data });
|
|
30
77
|
}
|
|
31
78
|
|
|
32
|
-
status(text) { this._emit("status", { text:
|
|
33
|
-
warn(text) { this._emit("warn", { text:
|
|
79
|
+
status(text) { this._emit("status", { text: boundedText(text, STATUS_TEXT_MAX) }); }
|
|
80
|
+
warn(text) { this._emit("warn", { text: boundedText(text, STATUS_TEXT_MAX) }); }
|
|
34
81
|
// Runtime/provider failures are recovery evidence, never presentation copy.
|
|
35
82
|
// Session owns the recovery loop and may give this evidence to the controller;
|
|
36
83
|
// Renderer must never receive it first.
|
|
37
|
-
error(text) { this._onPrivateEvidence(
|
|
38
|
-
line(text) { this._emit("line", { text:
|
|
39
|
-
tool(name, summary) {
|
|
84
|
+
error(text) { this._onPrivateEvidence(boundedText(text, EVENT_TEXT_MAX)); }
|
|
85
|
+
line(text) { this._emit("line", { text: boundedText(text, EVENT_TEXT_MAX) }); }
|
|
86
|
+
tool(name, summary) {
|
|
87
|
+
this._emit("tool", {
|
|
88
|
+
name: boundedText(name || "tool", TOOL_NAME_MAX),
|
|
89
|
+
summary: boundedText(summary, TOOL_SUMMARY_MAX),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
40
92
|
toolResult(text, ok) {
|
|
41
93
|
if (ok === false) {
|
|
42
|
-
this._onPrivateEvidence(
|
|
94
|
+
this._onPrivateEvidence(boundedText(text, EVENT_TEXT_MAX));
|
|
43
95
|
return;
|
|
44
96
|
}
|
|
45
|
-
this._emit("tool-result", { text:
|
|
97
|
+
this._emit("tool-result", { text: boundedText(text, EVENT_TEXT_MAX), ok: true });
|
|
46
98
|
}
|
|
47
99
|
streamStart() {
|
|
48
100
|
if (!this._streamOpen) { this._streamOpen = true; this._emit("stream-start", {}); }
|
|
49
101
|
}
|
|
50
102
|
streamDelta(text) {
|
|
51
103
|
if (!this._streamOpen) this.streamStart();
|
|
52
|
-
|
|
104
|
+
const value = String(text || "");
|
|
105
|
+
if (!value) { this._emit("stream-delta", { text: "" }); return; }
|
|
106
|
+
// Preserve live output byte-for-byte while ensuring no single retained
|
|
107
|
+
// ring event can own an unbounded provider/MCP payload.
|
|
108
|
+
for (let offset = 0; offset < value.length; offset += EVENT_TEXT_MAX) {
|
|
109
|
+
this._emit("stream-delta", { text: value.slice(offset, offset + EVENT_TEXT_MAX) });
|
|
110
|
+
}
|
|
53
111
|
}
|
|
54
112
|
streamEnd() {
|
|
55
113
|
if (this._streamOpen) { this._streamOpen = false; this._emit("stream-end", {}); }
|
|
56
114
|
}
|
|
57
|
-
applyTaskTool(name, input, id) {
|
|
58
|
-
|
|
59
|
-
|
|
115
|
+
applyTaskTool(name, input, id) {
|
|
116
|
+
const tool = String(name || "").toLowerCase();
|
|
117
|
+
if (!TASK_TOOL_INPUTS.has(tool)) return;
|
|
118
|
+
this._emit("task-tool", { name: tool, input: taskPayload(input), id: taskScalar("id", String(id || "")) });
|
|
119
|
+
}
|
|
120
|
+
applyTaskResult(name, result, id) {
|
|
121
|
+
const tool = String(name || "").toLowerCase();
|
|
122
|
+
if (!TASK_TOOL_RESULTS.has(tool)) return;
|
|
123
|
+
this._emit("task-result", { name: tool, result: taskPayload(result), id: taskScalar("id", String(id || "")) });
|
|
124
|
+
}
|
|
125
|
+
info(text) { this._emit("line", { text: boundedText(text, EVENT_TEXT_MAX) }); }
|
|
60
126
|
cost(usage) { this._emit("cost", { usage }); }
|
|
61
127
|
stopSpinner() { /* 스피너는 렌더러 소관 — 이벤트 불필요 */ }
|
|
62
128
|
}
|
|
63
129
|
|
|
64
|
-
module.exports = { EventSink };
|
|
130
|
+
module.exports = { EventSink, taskPayload, boundedText, EVENT_TEXT_MAX };
|
|
@@ -84,24 +84,46 @@ function loadRuntimeSession(db, chatId, kind, fingerprint, agentId) {
|
|
|
84
84
|
return {};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function saveRuntimeSession(db, chatId, kind, session, fingerprint, agentId) {
|
|
87
|
+
function saveRuntimeSession(db, chatId, kind, session, fingerprint, agentId, expectedSessionId = null) {
|
|
88
88
|
const sessionId = session && session.id ? String(session.id) : "";
|
|
89
89
|
if (!sessionId) return false;
|
|
90
90
|
const agent = normalizeRuntimeAgentId(agentId);
|
|
91
|
+
const expected = typeof expectedSessionId === "string" ? expectedSessionId : "";
|
|
91
92
|
try {
|
|
92
|
-
runWriteTransaction(db, () => {
|
|
93
|
-
db.prepare(
|
|
94
|
-
"
|
|
95
|
-
).
|
|
93
|
+
const saved = runWriteTransaction(db, () => {
|
|
94
|
+
const current = db.prepare(
|
|
95
|
+
"SELECT session_id, fingerprint FROM chat_runtime_sessions WHERE chat_id=? AND kind=? AND agent_id=?",
|
|
96
|
+
).get(chatId, kind, agent);
|
|
97
|
+
let changed = 0;
|
|
98
|
+
if (!current) {
|
|
99
|
+
const inserted = db.prepare(
|
|
100
|
+
"INSERT OR IGNORE INTO chat_runtime_sessions (chat_id, kind, agent_id, session_id, fingerprint, updated_at) VALUES (?,?,?,?,?,?)",
|
|
101
|
+
).run(chatId, kind, agent, sessionId, fingerprint, nowIso());
|
|
102
|
+
changed = inserted.changes ?? inserted.rowsAffected ?? 0;
|
|
103
|
+
} else if (
|
|
104
|
+
(expected && current.session_id === expected && current.fingerprint === fingerprint) ||
|
|
105
|
+
(!expected && current.fingerprint !== fingerprint)
|
|
106
|
+
) {
|
|
107
|
+
// Compare-and-swap: two sessions that began from the same provider id
|
|
108
|
+
// cannot finish out of order and overwrite the newer continuation.
|
|
109
|
+
const updated = db.prepare(
|
|
110
|
+
"UPDATE chat_runtime_sessions SET session_id=?, fingerprint=?, updated_at=? WHERE chat_id=? AND kind=? AND agent_id=? AND session_id=? AND fingerprint=?",
|
|
111
|
+
).run(sessionId, fingerprint, nowIso(), chatId, kind, agent, current.session_id, current.fingerprint);
|
|
112
|
+
changed = updated.changes ?? updated.rowsAffected ?? 0;
|
|
113
|
+
} else if (current.session_id === sessionId && current.fingerprint === fingerprint) {
|
|
114
|
+
changed = 1; // idempotent retry of the already-persisted continuation
|
|
115
|
+
}
|
|
116
|
+
if (!changed) return false;
|
|
96
117
|
// 레거시 행을 승계했다면 이제 새 키가 정본이다 — 같은 세션을 가리키는 '' 행을
|
|
97
118
|
// 정리해 다음 점유자가 이 봇의 세션을 승계 후보로 오인하지 않게 한다.
|
|
98
119
|
if (agent !== "") {
|
|
99
120
|
db.prepare(
|
|
100
121
|
"DELETE FROM chat_runtime_sessions WHERE chat_id=? AND kind=? AND agent_id='' AND session_id=?",
|
|
101
|
-
).run(chatId, kind, sessionId);
|
|
122
|
+
).run(chatId, kind, expected || sessionId);
|
|
102
123
|
}
|
|
124
|
+
return true;
|
|
103
125
|
});
|
|
104
|
-
return true;
|
|
126
|
+
return saved === true;
|
|
105
127
|
} catch (error) {
|
|
106
128
|
// 턴 자체는 유효하므로 던지지 않는다. 다만 조용히 지나가지도 않는다 — 이 자리의
|
|
107
129
|
// 침묵이 resume 유실을 여러 판 동안 숨겼다.
|
package/engine/storm/storm.cjs
CHANGED
|
@@ -208,10 +208,33 @@ function create(deps) {
|
|
|
208
208
|
cwd: executionContext.cwd || (typeof D.projectCwd === "function" ? D.projectCwd() : D.runCwd()),
|
|
209
209
|
runtimeOverride,
|
|
210
210
|
};
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
let passthrough = false;
|
|
211
213
|
for (let i = 0; i < args.length; i++) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
const token = String(args[i]);
|
|
215
|
+
if (passthrough) { rest.push(token); continue; }
|
|
216
|
+
if (token === "--") { passthrough = true; continue; }
|
|
217
|
+
if (token === "--research" || token === "--research-evidence") {
|
|
218
|
+
if (seen.has("research")) {
|
|
219
|
+
const ui = executionContext.ui || newUi();
|
|
220
|
+
ui.error("duplicate option: --research", { reveal: true });
|
|
221
|
+
return { ok: false, error: "duplicate-option" };
|
|
222
|
+
}
|
|
223
|
+
seen.add("research");
|
|
224
|
+
ctx.research = true;
|
|
225
|
+
} else if (token === "--background") {
|
|
226
|
+
if (seen.has("background")) {
|
|
227
|
+
const ui = executionContext.ui || newUi();
|
|
228
|
+
ui.error("duplicate option: --background", { reveal: true });
|
|
229
|
+
return { ok: false, error: "duplicate-option" };
|
|
230
|
+
}
|
|
231
|
+
seen.add("background");
|
|
232
|
+
ctx.background = true;
|
|
233
|
+
} else if (token.startsWith("-")) {
|
|
234
|
+
const ui = executionContext.ui || newUi();
|
|
235
|
+
ui.error(`unknown option ${token} — storm accepts: --research | --background | --runtime <kind>`, { reveal: true });
|
|
236
|
+
return { ok: false, error: "unknown-option" };
|
|
237
|
+
} else rest.push(token);
|
|
215
238
|
}
|
|
216
239
|
const r = await stormRun(db, rest.join(" "), ctx);
|
|
217
240
|
if (!r.ok) process.exitCode = 1;
|
package/engine/storm/swarm.cjs
CHANGED
|
@@ -429,16 +429,44 @@ function create(deps) {
|
|
|
429
429
|
async function cmdSwarm(db, args, runtimeOverride, executionContext = {}) {
|
|
430
430
|
const rest = [];
|
|
431
431
|
let concurrency;
|
|
432
|
+
let parallelError = null;
|
|
433
|
+
let seenParallel = false;
|
|
434
|
+
let passthrough = false;
|
|
435
|
+
let strayFlag = null;
|
|
432
436
|
for (let i = 0; i < args.length; i++) {
|
|
433
|
-
|
|
434
|
-
|
|
437
|
+
const token = String(args[i]);
|
|
438
|
+
if (passthrough) { rest.push(token); continue; }
|
|
439
|
+
if (token === "--") { passthrough = true; continue; }
|
|
440
|
+
if (token === "--parallel" || token === "-n" || token.startsWith("--parallel=")) {
|
|
441
|
+
if (seenParallel) {
|
|
442
|
+
parallelError = "duplicate option";
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
seenParallel = true;
|
|
446
|
+
const inline = token.startsWith("--parallel=");
|
|
447
|
+
const raw = inline ? token.slice(11) : args[++i];
|
|
448
|
+
const parsed = Number(raw);
|
|
449
|
+
if (raw === undefined || raw === "" || (!inline && String(raw).startsWith("--")) || !Number.isInteger(parsed) || parsed < 1) {
|
|
450
|
+
parallelError = raw === undefined ? "missing value" : String(raw);
|
|
451
|
+
} else {
|
|
452
|
+
concurrency = Math.min(8, parsed);
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
if (token.startsWith("-") && strayFlag === null) strayFlag = token;
|
|
456
|
+
rest.push(token);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (parallelError !== null) {
|
|
460
|
+
const ui = executionContext.ui || newUi();
|
|
461
|
+
ui.error(`invalid --parallel value ${parallelError} — use a positive integer (maximum 8)`, { reveal: true });
|
|
462
|
+
process.exitCode = 1;
|
|
463
|
+
return { ok: false, error: "invalid-parallel" };
|
|
435
464
|
}
|
|
436
465
|
// storm.cjs stormRun 의 leading-dash 가드와 동일 계약. 여기서 안 막으면 미지원·
|
|
437
466
|
// 오타 플래그(--permission, --model …)가 rest 에 남아 rest.join(" ") 로 목표가
|
|
438
467
|
// 되어버린다: 유료 플래너가 플래그 텍스트를 목표로 실제 실행되고, 플래그가
|
|
439
468
|
// 의도한 동작(예: 권한 상승)은 조용히 일어나지 않는다. 목표는 프롬프트까지
|
|
440
469
|
// 오염된다. 그래서 소비되지 않은 대시 토큰은 실행 전에 fail-closed 로 거절한다.
|
|
441
|
-
const strayFlag = rest.find((token) => String(token).startsWith("-"));
|
|
442
470
|
if (strayFlag) {
|
|
443
471
|
const ui = executionContext.ui || newUi();
|
|
444
472
|
ui.error(`unknown option ${strayFlag} — swarm accepts: --parallel N | -n N, --runtime <kind>`, { reveal: true });
|
|
@@ -20,16 +20,34 @@ const { userDataDir } = require("../core/paths.cjs");
|
|
|
20
20
|
const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
|
|
21
21
|
|
|
22
22
|
const TELEGRAM_REQUEST_TIMEOUT_MS = 20_000;
|
|
23
|
+
const TELEGRAM_TOKEN_MAX_BYTES = 1024;
|
|
24
|
+
const TELEGRAM_BINDING_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
25
|
+
|
|
26
|
+
function assertTelegramBindingId(id) {
|
|
27
|
+
const value = String(id || "");
|
|
28
|
+
if (!TELEGRAM_BINDING_ID_RE.test(value)) throw new Error("invalid Telegram binding id");
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
23
31
|
|
|
24
32
|
/** 순수 HTTPS. fetch 주입 가능(테스트). */
|
|
25
33
|
async function telegramApi(token, method, payload, { fetchImpl } = {}) {
|
|
26
34
|
const doFetch = fetchImpl || globalThis.fetch;
|
|
27
35
|
if (typeof doFetch !== "function") throw new Error("this runtime has no fetch");
|
|
28
|
-
const
|
|
36
|
+
const safeToken = String(token || "");
|
|
37
|
+
const safeMethod = String(method || "");
|
|
38
|
+
if (!safeToken || Buffer.byteLength(safeToken, "utf8") > TELEGRAM_TOKEN_MAX_BYTES || !/^[A-Za-z0-9:_-]+$/.test(safeToken)) {
|
|
39
|
+
throw new Error("Telegram bot token has an unsafe format");
|
|
40
|
+
}
|
|
41
|
+
if (!/^[A-Za-z][A-Za-z0-9]{0,63}$/.test(safeMethod)) throw new Error("Telegram API method is invalid");
|
|
42
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Telegram API payload must be an object");
|
|
43
|
+
const requestedLongPoll = safeMethod === "getUpdates" && Number.isFinite(Number(payload.timeout))
|
|
44
|
+
? Number(payload.timeout)
|
|
45
|
+
: 0;
|
|
46
|
+
const longPoll = Math.max(0, Math.min(50, requestedLongPoll));
|
|
29
47
|
const controller = new AbortController();
|
|
30
|
-
const timer = setTimeout(() => controller.abort(new Error(`Telegram ${
|
|
48
|
+
const timer = setTimeout(() => controller.abort(new Error(`Telegram ${safeMethod} timed out`)), TELEGRAM_REQUEST_TIMEOUT_MS + longPoll * 1000);
|
|
31
49
|
try {
|
|
32
|
-
const res = await doFetch(`https://api.telegram.org/bot${
|
|
50
|
+
const res = await doFetch(`https://api.telegram.org/bot${safeToken}/${safeMethod}`, {
|
|
33
51
|
method: "POST",
|
|
34
52
|
headers: { "content-type": "application/json" },
|
|
35
53
|
body: JSON.stringify(payload),
|
|
@@ -37,7 +55,11 @@ async function telegramApi(token, method, payload, { fetchImpl } = {}) {
|
|
|
37
55
|
});
|
|
38
56
|
const json = await res.json().catch(() => null);
|
|
39
57
|
if (!res.ok || !json || json.ok !== true) {
|
|
40
|
-
|
|
58
|
+
const raw = (json && json.description) || `Telegram ${safeMethod} failed (${res.status})`;
|
|
59
|
+
const message = String(raw).replaceAll(safeToken, "[redacted]").replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, 1000);
|
|
60
|
+
const error = new Error(message || `Telegram ${safeMethod} failed`);
|
|
61
|
+
error.telegramStatus = Number(res.status) || null;
|
|
62
|
+
throw error;
|
|
41
63
|
}
|
|
42
64
|
return json.result;
|
|
43
65
|
} finally {
|
|
@@ -52,24 +74,124 @@ async function verifyBotToken(token, opts) {
|
|
|
52
74
|
}
|
|
53
75
|
|
|
54
76
|
// ── 토큰 비밀 저장 (0600 파일) ─────────────────────────────────────────────
|
|
55
|
-
function tokenDir() {
|
|
77
|
+
function tokenDir({ create = true } = {}) {
|
|
56
78
|
const dir = path.join(userDataDir(), "telegram");
|
|
57
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
79
|
+
if (create) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
80
|
+
let stat;
|
|
81
|
+
try { stat = fs.lstatSync(dir); }
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error && error.code === "ENOENT" && !create) return dir;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Telegram token directory must be a real directory");
|
|
58
87
|
if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
|
|
59
88
|
return dir;
|
|
60
89
|
}
|
|
61
|
-
function tokenFile(id
|
|
90
|
+
function tokenFile(id, options) {
|
|
91
|
+
const bindingId = assertTelegramBindingId(id);
|
|
92
|
+
return path.join(tokenDir(options), `${bindingId}.token`);
|
|
93
|
+
}
|
|
62
94
|
function saveToken(id, token) {
|
|
63
95
|
const file = tokenFile(id);
|
|
64
|
-
|
|
65
|
-
if (
|
|
96
|
+
const value = String(token || "");
|
|
97
|
+
if (!value || Buffer.byteLength(value, "utf8") > TELEGRAM_TOKEN_MAX_BYTES || !/^[A-Za-z0-9:_-]+$/.test(value)) {
|
|
98
|
+
throw new Error("Telegram bot token has an unsafe format");
|
|
99
|
+
}
|
|
100
|
+
let before = null;
|
|
101
|
+
try {
|
|
102
|
+
before = fs.lstatSync(file);
|
|
103
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error("Telegram token target must be a regular private file");
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
106
|
+
}
|
|
107
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
108
|
+
try {
|
|
109
|
+
fs.writeFileSync(temp, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
110
|
+
if (before) {
|
|
111
|
+
const current = fs.lstatSync(file);
|
|
112
|
+
if (
|
|
113
|
+
!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 ||
|
|
114
|
+
current.dev !== before.dev || current.ino !== before.ino || current.size !== before.size || current.mtimeMs !== before.mtimeMs
|
|
115
|
+
) throw new Error("Telegram token target changed before replacement");
|
|
116
|
+
} else {
|
|
117
|
+
try {
|
|
118
|
+
fs.lstatSync(file);
|
|
119
|
+
throw new Error("Telegram token target appeared before replacement");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
fs.renameSync(temp, file);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (
|
|
128
|
+
process.platform !== "win32" || !before ||
|
|
129
|
+
!["EEXIST", "EPERM", "EACCES"].includes(error && error.code)
|
|
130
|
+
) throw error;
|
|
131
|
+
const backup = `${file}.${process.pid}.${crypto.randomUUID()}.bak`;
|
|
132
|
+
fs.renameSync(file, backup);
|
|
133
|
+
try {
|
|
134
|
+
fs.renameSync(temp, file);
|
|
135
|
+
} catch (replaceError) {
|
|
136
|
+
try { if (!fs.existsSync(file)) fs.renameSync(backup, file); } catch { /* leave recoverable backup */ }
|
|
137
|
+
throw replaceError;
|
|
138
|
+
}
|
|
139
|
+
try { fs.rmSync(backup, { force: true }); } catch { /* committed target is authoritative */ }
|
|
140
|
+
}
|
|
141
|
+
if (process.platform !== "win32") fs.chmodSync(file, 0o600);
|
|
142
|
+
} finally {
|
|
143
|
+
try { fs.rmSync(temp, { force: true }); } catch { /* best-effort cleanup */ }
|
|
144
|
+
}
|
|
66
145
|
}
|
|
67
146
|
function readToken(id) {
|
|
68
|
-
|
|
147
|
+
const file = tokenFile(id, { create: false });
|
|
148
|
+
let fd;
|
|
149
|
+
try {
|
|
150
|
+
const before = fs.lstatSync(file);
|
|
151
|
+
if (
|
|
152
|
+
!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 ||
|
|
153
|
+
before.size <= 0 || before.size > TELEGRAM_TOKEN_MAX_BYTES ||
|
|
154
|
+
(process.platform !== "win32" && (before.mode & 0o077) !== 0)
|
|
155
|
+
) throw new Error("Telegram token file is unsafe");
|
|
156
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
157
|
+
const opened = fs.fstatSync(fd);
|
|
158
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size !== before.size) {
|
|
159
|
+
throw new Error("Telegram token file changed while opening");
|
|
160
|
+
}
|
|
161
|
+
const token = fs.readFileSync(fd, "utf8").trim();
|
|
162
|
+
if (!token || !/^[A-Za-z0-9:_-]+$/.test(token)) throw new Error("Telegram token file has an unsafe format");
|
|
163
|
+
return token;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error && error.code === "ENOENT") return null;
|
|
166
|
+
throw error;
|
|
167
|
+
} finally {
|
|
168
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function deleteToken(id) {
|
|
172
|
+
const file = tokenFile(id, { create: false });
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.lstatSync(file);
|
|
175
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Telegram token target is unsafe");
|
|
176
|
+
fs.unlinkSync(file);
|
|
177
|
+
return true;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error && error.code === "ENOENT") return false;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
69
182
|
}
|
|
70
|
-
function deleteToken(id) { try { fs.rmSync(tokenFile(id)); } catch { /* gone */ } }
|
|
71
183
|
function tokenFingerprint(token) { return crypto.createHash("sha256").update(token).digest("hex").slice(0, 24); }
|
|
72
184
|
|
|
185
|
+
function readBindingToken(row) {
|
|
186
|
+
if (!row) throw new Error("binding not found");
|
|
187
|
+
const token = readToken(row.id);
|
|
188
|
+
if (!token) throw new Error("no Terminal-owned token for this binding");
|
|
189
|
+
if (row.token_fingerprint && tokenFingerprint(token) !== row.token_fingerprint) {
|
|
190
|
+
throw new Error("stored Telegram token does not match the binding fingerprint");
|
|
191
|
+
}
|
|
192
|
+
return token;
|
|
193
|
+
}
|
|
194
|
+
|
|
73
195
|
// ── 바인딩 ─────────────────────────────────────────────────────────────────
|
|
74
196
|
function listBindings(db) {
|
|
75
197
|
try { return db.prepare("SELECT * FROM telegram_bindings ORDER BY rowid DESC").all(); } catch { return []; }
|
|
@@ -80,6 +202,10 @@ function getBinding(db, id) {
|
|
|
80
202
|
|
|
81
203
|
/** 봇 토큰으로 바인딩을 만든다(방 미페어링). 반환: {id, botUsername}. */
|
|
82
204
|
async function startConnection(db, targetKind, targetId, token, opts) {
|
|
205
|
+
if (!new Set(["agent", "firm", "one"]).has(String(targetKind))) throw new Error("invalid Telegram binding target kind");
|
|
206
|
+
if (typeof targetId !== "string" || !targetId.trim() || targetId.length > 512 || /[\u0000\r\n]/.test(targetId)) {
|
|
207
|
+
throw new Error("invalid Telegram binding target id");
|
|
208
|
+
}
|
|
83
209
|
const me = await verifyBotToken(token, opts);
|
|
84
210
|
const id = crypto.randomUUID();
|
|
85
211
|
const now = new Date().toISOString();
|
|
@@ -95,7 +221,7 @@ async function startConnection(db, targetKind, targetId, token, opts) {
|
|
|
95
221
|
db.prepare("UPDATE telegram_bindings SET token_saved=1, updated_at=? WHERE id=?").run(new Date().toISOString(), id);
|
|
96
222
|
});
|
|
97
223
|
} catch (error) {
|
|
98
|
-
deleteToken(id);
|
|
224
|
+
try { deleteToken(id); } catch { /* preserve the original storage failure */ }
|
|
99
225
|
try {
|
|
100
226
|
runWriteTransaction(db, () => db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(id));
|
|
101
227
|
} catch { /* preserve the original storage failure */ }
|
|
@@ -113,16 +239,26 @@ async function startConnection(db, targetKind, targetId, token, opts) {
|
|
|
113
239
|
* 두 Terminal 프로세스가 같은 바인딩을 동시에 덮어쓰지 못하게 한다.
|
|
114
240
|
*/
|
|
115
241
|
async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {}) {
|
|
116
|
-
|
|
117
|
-
|
|
242
|
+
assertTelegramBindingId(id);
|
|
243
|
+
const initialRow = getBinding(db, id);
|
|
244
|
+
if (!initialRow) throw new Error("binding not found");
|
|
245
|
+
const token = readBindingToken(initialRow);
|
|
118
246
|
const deadline = Date.now() + timeoutMs;
|
|
119
|
-
let offset = (
|
|
247
|
+
let offset = Number.isSafeInteger(Number(initialRow.last_update_id)) && Number(initialRow.last_update_id) >= 0
|
|
248
|
+
? Number(initialRow.last_update_id)
|
|
249
|
+
: 0;
|
|
250
|
+
let lastApiError = null;
|
|
120
251
|
while (Date.now() < deadline) {
|
|
121
252
|
if (typeof onWait === "function") onWait();
|
|
122
253
|
let updates;
|
|
123
254
|
try {
|
|
124
255
|
updates = await telegramApi(token, "getUpdates", { offset: offset + 1, timeout: 25, allowed_updates: ["message"] }, opts);
|
|
125
|
-
|
|
256
|
+
if (!Array.isArray(updates)) throw new Error("Telegram getUpdates returned an invalid result");
|
|
257
|
+
lastApiError = null;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
lastApiError = error;
|
|
260
|
+
updates = [];
|
|
261
|
+
}
|
|
126
262
|
for (const update of updates || []) {
|
|
127
263
|
if (typeof update.update_id === "number") offset = Math.max(offset, update.update_id);
|
|
128
264
|
const message = update.message;
|
|
@@ -150,25 +286,52 @@ async function pairByPolling(db, id, { timeoutMs = 120_000, opts, onWait } = {})
|
|
|
150
286
|
});
|
|
151
287
|
}
|
|
152
288
|
}
|
|
289
|
+
if (lastApiError) throw lastApiError;
|
|
153
290
|
return null;
|
|
154
291
|
}
|
|
155
292
|
|
|
156
293
|
/** 페어링된 방에 확인 메시지를 보낸다. */
|
|
157
294
|
async function sendTest(db, id, text, opts) {
|
|
295
|
+
assertTelegramBindingId(id);
|
|
158
296
|
const row = getBinding(db, id);
|
|
159
297
|
if (!row) throw new Error("binding not found");
|
|
160
298
|
if (!row.telegram_chat_id) throw new Error("this binding is not paired to a chat yet");
|
|
161
|
-
const token =
|
|
162
|
-
if (!token) throw new Error("no stored token for this binding");
|
|
299
|
+
const token = readBindingToken(row);
|
|
163
300
|
await telegramApi(token, "sendMessage", { chat_id: row.telegram_chat_id, text }, opts);
|
|
164
301
|
return true;
|
|
165
302
|
}
|
|
166
303
|
|
|
167
304
|
function removeBinding(db, id) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
305
|
+
const bindingId = assertTelegramBindingId(id);
|
|
306
|
+
const row = getBinding(db, bindingId);
|
|
307
|
+
if (!row) throw new Error("binding not found");
|
|
308
|
+
const file = tokenFile(bindingId, { create: false });
|
|
309
|
+
let tokenExists = false;
|
|
310
|
+
try {
|
|
311
|
+
const stat = fs.lstatSync(file);
|
|
312
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Telegram token target is unsafe");
|
|
313
|
+
tokenExists = true;
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
316
|
+
}
|
|
317
|
+
if (Number(row.token_saved) === 1 && !tokenExists) {
|
|
318
|
+
throw new Error("this binding secret is not Terminal-owned; remove it from Agentlas Desktop so its keychain secret is cleaned up");
|
|
319
|
+
}
|
|
320
|
+
const tombstone = `${file}.${process.pid}.${crypto.randomUUID()}.delete`;
|
|
321
|
+
if (tokenExists) fs.renameSync(file, tombstone);
|
|
322
|
+
try {
|
|
323
|
+
const removed = runWriteTransaction(db, () => {
|
|
324
|
+
return db.prepare("DELETE FROM telegram_bindings WHERE id=?").run(bindingId).changes === 1;
|
|
325
|
+
});
|
|
326
|
+
if (!removed) throw new Error("binding disappeared before removal");
|
|
327
|
+
} catch (error) {
|
|
328
|
+
if (tokenExists) {
|
|
329
|
+
try { if (!fs.existsSync(file)) fs.renameSync(tombstone, file); } catch { /* leave recoverable tombstone */ }
|
|
330
|
+
}
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
if (tokenExists) fs.unlinkSync(tombstone);
|
|
334
|
+
return true;
|
|
172
335
|
}
|
|
173
336
|
|
|
174
337
|
/*
|
|
@@ -118,7 +118,7 @@ const CATALOG = [
|
|
|
118
118
|
{ name: "telegram", group: "advanced", tier: "more", surfaces: BOTH, args: "[sub]", ko: "텔레그램 연결", en: "Telegram bindings" },
|
|
119
119
|
{ name: "connect", group: "advanced", tier: "more", surfaces: BOTH, aliases: ["hep-connect"], args: "<target>", argsKo: "<대상>", ko: "에이전트·팀 연결", en: "Connect an agent or team" },
|
|
120
120
|
{ name: "browser", group: "advanced", tier: "more", surfaces: BOTH, aliases: ["hep-browser"], args: "<url|status|sites|login>", ko: "브라우저 하드포인트", en: "Browser hardpoint" },
|
|
121
|
-
{ name: "research", group: "advanced", tier: "more", surfaces: BOTH, args: "<
|
|
121
|
+
{ name: "research", group: "advanced", tier: "more", surfaces: BOTH, args: "<subcommand> [args]", argsKo: "<하위-명령> [인자]", ko: "Research Engine (전체 목록: research --help)", en: "Research Engine (full list: research --help)" },
|
|
122
122
|
{ name: "document", group: "advanced", tier: "more", surfaces: BOTH, args: "pdf <html|url>", ko: "문서 PDF 내보내기", en: "Export a document to PDF" },
|
|
123
123
|
{ name: "oberon", group: "advanced", tier: "more", surfaces: BOTH, aliases: ["film"], args: "<scaffold|render|list|open>", ko: "AI 필름 렌더", en: "AI film render" },
|
|
124
124
|
{ name: "variant", group: "advanced", tier: "more", surfaces: BOTH, args: "resolve --base-release", ko: "로컬 변형 선택", en: "Local variant selection" },
|
package/engine/ui/palette.cjs
CHANGED
|
@@ -34,11 +34,21 @@ const EFFORT_LEVELS = ["none", "minimal", "low", "medium", "high", "xhigh", "max
|
|
|
34
34
|
const PERM_LEVELS = ["read", "write", "full"];
|
|
35
35
|
// 세션 인자를 받는 명령 — 완성 후보를 살아있는 세션 키(s1, s2…)로 채운다.
|
|
36
36
|
const SESSION_ARG_COMMANDS = new Set(["/s", "/switch", "/kill", "/rm"]);
|
|
37
|
+
// DB-backed agent/firm/session lists must not freeze readline or allocate an
|
|
38
|
+
// unbounded candidate array when a local store contains many rows.
|
|
39
|
+
const MAX_COMPLETION_CANDIDATES = 100;
|
|
37
40
|
|
|
38
41
|
function uniqStartsWith(list, prefix) {
|
|
39
42
|
const p = String(prefix || "");
|
|
40
|
-
const hits = [
|
|
41
|
-
|
|
43
|
+
const hits = [];
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (const value of Array.isArray(list) ? list : []) {
|
|
46
|
+
if (typeof value !== "string" || !value.startsWith(p) || seen.has(value)) continue;
|
|
47
|
+
seen.add(value);
|
|
48
|
+
hits.push(value);
|
|
49
|
+
if (hits.length >= MAX_COMPLETION_CANDIDATES) break;
|
|
50
|
+
}
|
|
51
|
+
return hits;
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
/**
|