@byte5ai/palaia 2.0.12 → 2.1.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.
- package/package.json +1 -1
- package/src/hooks.ts +144 -28
- package/src/runner.ts +51 -10
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -293,10 +293,14 @@ export async function sendReaction(
|
|
|
293
293
|
|
|
294
294
|
/** Cached Slack bot token resolved from env or OpenClaw config. */
|
|
295
295
|
let _cachedSlackToken: string | null | undefined;
|
|
296
|
+
/** Timestamp when the token was cached (for TTL expiry). */
|
|
297
|
+
let _slackTokenCachedAt = 0;
|
|
298
|
+
/** TTL for cached Slack bot token in milliseconds (5 minutes). */
|
|
299
|
+
const SLACK_TOKEN_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
296
300
|
|
|
297
301
|
/**
|
|
298
302
|
* Resolve the Slack bot token from environment or OpenClaw config file.
|
|
299
|
-
* Caches the result
|
|
303
|
+
* Caches the result with a 5-minute TTL — re-resolves after expiry.
|
|
300
304
|
*
|
|
301
305
|
* Resolution order:
|
|
302
306
|
* 1. SLACK_BOT_TOKEN env var (explicit override)
|
|
@@ -306,12 +310,15 @@ let _cachedSlackToken: string | null | undefined;
|
|
|
306
310
|
* Config path: OPENCLAW_CONFIG env var → ~/.openclaw/openclaw.json
|
|
307
311
|
*/
|
|
308
312
|
async function resolveSlackBotToken(): Promise<string | null> {
|
|
309
|
-
if (_cachedSlackToken !== undefined)
|
|
313
|
+
if (_cachedSlackToken !== undefined && (Date.now() - _slackTokenCachedAt) < SLACK_TOKEN_CACHE_TTL_MS) {
|
|
314
|
+
return _cachedSlackToken;
|
|
315
|
+
}
|
|
310
316
|
|
|
311
317
|
// 1) Environment variable
|
|
312
318
|
const envToken = process.env.SLACK_BOT_TOKEN?.trim();
|
|
313
319
|
if (envToken) {
|
|
314
320
|
_cachedSlackToken = envToken;
|
|
321
|
+
_slackTokenCachedAt = Date.now();
|
|
315
322
|
return envToken;
|
|
316
323
|
}
|
|
317
324
|
|
|
@@ -330,6 +337,7 @@ async function resolveSlackBotToken(): Promise<string | null> {
|
|
|
330
337
|
const directToken = config?.channels?.slack?.botToken?.trim();
|
|
331
338
|
if (directToken) {
|
|
332
339
|
_cachedSlackToken = directToken;
|
|
340
|
+
_slackTokenCachedAt = Date.now();
|
|
333
341
|
return directToken;
|
|
334
342
|
}
|
|
335
343
|
|
|
@@ -337,6 +345,7 @@ async function resolveSlackBotToken(): Promise<string | null> {
|
|
|
337
345
|
const accountToken = config?.channels?.slack?.accounts?.default?.botToken?.trim();
|
|
338
346
|
if (accountToken) {
|
|
339
347
|
_cachedSlackToken = accountToken;
|
|
348
|
+
_slackTokenCachedAt = Date.now();
|
|
340
349
|
return accountToken;
|
|
341
350
|
}
|
|
342
351
|
} catch {
|
|
@@ -345,12 +354,14 @@ async function resolveSlackBotToken(): Promise<string | null> {
|
|
|
345
354
|
}
|
|
346
355
|
|
|
347
356
|
_cachedSlackToken = null;
|
|
357
|
+
_slackTokenCachedAt = Date.now();
|
|
348
358
|
return null;
|
|
349
359
|
}
|
|
350
360
|
|
|
351
361
|
/** Reset cached token (for testing). */
|
|
352
362
|
export function resetSlackTokenCache(): void {
|
|
353
363
|
_cachedSlackToken = undefined;
|
|
364
|
+
_slackTokenCachedAt = 0;
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
async function sendSlackReaction(
|
|
@@ -733,6 +744,13 @@ For each piece of knowledge, return a JSON array of objects:
|
|
|
733
744
|
- "project": which project this belongs to (from known projects list, or null if unclear)
|
|
734
745
|
- "scope": "private" (personal preference, agent-specific), "team" (shared knowledge), or "public" (documentation)
|
|
735
746
|
|
|
747
|
+
STRICT TASK CLASSIFICATION RULES — a "task" MUST have ALL three of:
|
|
748
|
+
1. A clear, completable action (not just an observation or idea)
|
|
749
|
+
2. An identifiable responsible party (explicitly named or unambiguously inferable from context)
|
|
750
|
+
3. A concrete deliverable or measurable end state
|
|
751
|
+
If ANY of these is missing, classify as "memory" instead of "task". When in doubt, use "memory".
|
|
752
|
+
Observations, learnings, insights, opinions, and general knowledge are ALWAYS "memory", never "task".
|
|
753
|
+
|
|
736
754
|
Only extract genuinely significant knowledge. Skip small talk, acknowledgments, routine exchanges.
|
|
737
755
|
Do NOT extract if similar knowledge was likely captured in a recent exchange. Prefer quality over quantity. Skip routine status updates and acknowledgments.
|
|
738
756
|
Return empty array [] if nothing is worth remembering.
|
|
@@ -892,9 +910,15 @@ export async function extractWithLLM(
|
|
|
892
910
|
}
|
|
893
911
|
|
|
894
912
|
const allTexts = extractMessageTexts(messages);
|
|
913
|
+
// Strip Palaia-injected recall context from user messages to prevent feedback loop
|
|
914
|
+
const cleanedTexts = allTexts.map(t =>
|
|
915
|
+
t.role === "user"
|
|
916
|
+
? { ...t, text: stripPalaiaInjectedContext(t.text) }
|
|
917
|
+
: t
|
|
918
|
+
);
|
|
895
919
|
// Only extract from recent exchanges — full history causes LLM timeouts
|
|
896
920
|
// and dilutes extraction quality
|
|
897
|
-
const recentTexts = trimToRecentExchanges(
|
|
921
|
+
const recentTexts = trimToRecentExchanges(cleanedTexts);
|
|
898
922
|
const exchangeText = recentTexts
|
|
899
923
|
.map((t) => `[${t.role}]: ${t.text}`)
|
|
900
924
|
.join("\n");
|
|
@@ -908,7 +932,25 @@ export async function extractWithLLM(
|
|
|
908
932
|
|
|
909
933
|
let tmpDir: string | null = null;
|
|
910
934
|
try {
|
|
911
|
-
|
|
935
|
+
// Use a fixed base directory for extraction temp dirs and clean up stale ones
|
|
936
|
+
const extractBaseDir = path.join(os.tmpdir(), "palaia-extractions");
|
|
937
|
+
await fs.mkdir(extractBaseDir, { recursive: true });
|
|
938
|
+
// Clean up stale extraction dirs (older than 5 minutes)
|
|
939
|
+
try {
|
|
940
|
+
const entries = await fs.readdir(extractBaseDir, { withFileTypes: true });
|
|
941
|
+
const now = Date.now();
|
|
942
|
+
for (const entry of entries) {
|
|
943
|
+
if (entry.isDirectory()) {
|
|
944
|
+
try {
|
|
945
|
+
const stat = await fs.stat(path.join(extractBaseDir, entry.name));
|
|
946
|
+
if (now - stat.mtimeMs > 5 * 60 * 1000) {
|
|
947
|
+
await fs.rm(path.join(extractBaseDir, entry.name), { recursive: true, force: true });
|
|
948
|
+
}
|
|
949
|
+
} catch { /* ignore individual cleanup errors */ }
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
} catch { /* ignore cleanup errors */ }
|
|
953
|
+
tmpDir = await fs.mkdtemp(path.join(extractBaseDir, "ext-"));
|
|
912
954
|
const sessionId = `palaia-extract-${Date.now()}`;
|
|
913
955
|
const sessionFile = path.join(tmpDir, "session.json");
|
|
914
956
|
|
|
@@ -1103,6 +1145,24 @@ export function extractSignificance(
|
|
|
1103
1145
|
return { tags, type: primaryType, summary };
|
|
1104
1146
|
}
|
|
1105
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Strip Palaia-injected recall context from message text.
|
|
1150
|
+
* The recall block is prepended to user messages by before_prompt_build via prependContext.
|
|
1151
|
+
* OpenClaw merges it into the user message, so agent_end sees it as user content.
|
|
1152
|
+
* Without stripping, auto-capture re-captures the injected memories → feedback loop.
|
|
1153
|
+
*
|
|
1154
|
+
* The block has a stable structure:
|
|
1155
|
+
* - Starts with "## Active Memory (Palaia)"
|
|
1156
|
+
* - Contains [t/m], [t/pr], [t/tk] prefixed entries
|
|
1157
|
+
* - Ends with "[palaia] auto-capture=on..." nudge line
|
|
1158
|
+
*/
|
|
1159
|
+
export function stripPalaiaInjectedContext(text: string): string {
|
|
1160
|
+
// Pattern: "## Active Memory (Palaia)" ... "[palaia] auto-capture=on..." + optional trailing newlines
|
|
1161
|
+
// The nudge line is always present and marks the end of the injected block
|
|
1162
|
+
const PALAIA_BLOCK_RE = /## Active Memory \(Palaia\)[\s\S]*?\[palaia\][^\n]*\n*/;
|
|
1163
|
+
return text.replace(PALAIA_BLOCK_RE, '').trim();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1106
1166
|
export function extractMessageTexts(messages: unknown[]): Array<{ role: string; text: string; provenance?: string }> {
|
|
1107
1167
|
const result: Array<{ role: string; text: string; provenance?: string }> = [];
|
|
1108
1168
|
|
|
@@ -1232,7 +1292,11 @@ function isSystemOnlyContent(text: string): boolean {
|
|
|
1232
1292
|
* - Hard-caps at 500 characters.
|
|
1233
1293
|
*/
|
|
1234
1294
|
export function buildRecallQuery(messages: unknown[]): string {
|
|
1235
|
-
const texts = extractMessageTexts(messages)
|
|
1295
|
+
const texts = extractMessageTexts(messages).map(t =>
|
|
1296
|
+
t.role === "user"
|
|
1297
|
+
? { ...t, text: stripPalaiaInjectedContext(t.text) }
|
|
1298
|
+
: t
|
|
1299
|
+
);
|
|
1236
1300
|
|
|
1237
1301
|
// Step 1: Filter out inter_session messages (sub-agent results, sessions_send)
|
|
1238
1302
|
const candidates = texts.filter(
|
|
@@ -1240,11 +1304,29 @@ export function buildRecallQuery(messages: unknown[]): string {
|
|
|
1240
1304
|
);
|
|
1241
1305
|
|
|
1242
1306
|
// Fallback: if no messages without provenance, use all user messages
|
|
1243
|
-
const
|
|
1307
|
+
const allUserMsgs = candidates.length > 0
|
|
1244
1308
|
? candidates
|
|
1245
1309
|
: texts.filter(t => t.role === "user");
|
|
1246
1310
|
|
|
1247
|
-
if (
|
|
1311
|
+
if (allUserMsgs.length === 0) return "";
|
|
1312
|
+
|
|
1313
|
+
// Early exit: only scan the last 3 user messages or 2000 chars, whichever comes first
|
|
1314
|
+
const MAX_SCAN_MSGS = 3;
|
|
1315
|
+
const MAX_SCAN_CHARS = 2000;
|
|
1316
|
+
let userMsgs: typeof allUserMsgs;
|
|
1317
|
+
if (allUserMsgs.length <= MAX_SCAN_MSGS) {
|
|
1318
|
+
userMsgs = allUserMsgs;
|
|
1319
|
+
} else {
|
|
1320
|
+
userMsgs = allUserMsgs.slice(-MAX_SCAN_MSGS);
|
|
1321
|
+
// Extend backwards if total chars < MAX_SCAN_CHARS and more messages available
|
|
1322
|
+
let totalChars = userMsgs.reduce((sum, m) => sum + m.text.length, 0);
|
|
1323
|
+
let startIdx = allUserMsgs.length - MAX_SCAN_MSGS;
|
|
1324
|
+
while (startIdx > 0 && totalChars < MAX_SCAN_CHARS) {
|
|
1325
|
+
startIdx--;
|
|
1326
|
+
totalChars += allUserMsgs[startIdx].text.length;
|
|
1327
|
+
userMsgs = allUserMsgs.slice(startIdx);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1248
1330
|
|
|
1249
1331
|
// Step 2: Strip envelopes from the last user message(s)
|
|
1250
1332
|
let lastText = stripSystemPrefix(stripChannelEnvelope(userMsgs[userMsgs.length - 1].text.trim()));
|
|
@@ -1313,10 +1395,22 @@ export function rerankByTypeWeight(
|
|
|
1313
1395
|
// Hook helpers
|
|
1314
1396
|
// ============================================================================
|
|
1315
1397
|
|
|
1316
|
-
|
|
1398
|
+
/**
|
|
1399
|
+
* Resolve per-agent workspace and agentId from hook context.
|
|
1400
|
+
* Fallback chain: ctx.workspaceDir → config.workspace → cwd
|
|
1401
|
+
* Agent chain: ctx.agentId → PALAIA_AGENT env var → undefined
|
|
1402
|
+
*/
|
|
1403
|
+
export function resolvePerAgentContext(ctx: any, config: PalaiaPluginConfig) {
|
|
1404
|
+
return {
|
|
1405
|
+
workspace: ctx?.workspaceDir || config.workspace,
|
|
1406
|
+
agentId: ctx?.agentId || process.env.PALAIA_AGENT || undefined,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function buildRunnerOpts(config: PalaiaPluginConfig, overrides?: { workspace?: string }): RunnerOpts {
|
|
1317
1411
|
return {
|
|
1318
1412
|
binaryPath: config.binaryPath,
|
|
1319
|
-
workspace: config.workspace,
|
|
1413
|
+
workspace: overrides?.workspace || config.workspace,
|
|
1320
1414
|
timeoutMs: config.timeoutMs,
|
|
1321
1415
|
};
|
|
1322
1416
|
}
|
|
@@ -1510,6 +1604,10 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1510
1604
|
// Prune stale entries to prevent memory leaks from crashed sessions (C-2)
|
|
1511
1605
|
pruneStaleEntries();
|
|
1512
1606
|
|
|
1607
|
+
// Per-agent workspace resolution (Issue #111)
|
|
1608
|
+
const resolved = resolvePerAgentContext(ctx, config);
|
|
1609
|
+
const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
|
|
1610
|
+
|
|
1513
1611
|
try {
|
|
1514
1612
|
const maxChars = config.maxInjectedChars || 4000;
|
|
1515
1613
|
const limit = Math.min(config.maxResults || 10, 20);
|
|
@@ -1525,15 +1623,22 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1525
1623
|
let serverQueried = false;
|
|
1526
1624
|
if (config.embeddingServer) {
|
|
1527
1625
|
try {
|
|
1528
|
-
const mgr = getEmbedServerManager(
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1626
|
+
const mgr = getEmbedServerManager(hookOpts);
|
|
1627
|
+
// If embed server workspace differs from resolved workspace, skip server and use CLI
|
|
1628
|
+
const serverWorkspace = hookOpts.workspace;
|
|
1629
|
+
const embedOpts = buildRunnerOpts(config);
|
|
1630
|
+
if (serverWorkspace !== embedOpts.workspace) {
|
|
1631
|
+
logger.info(`[palaia] Embed server workspace mismatch (agent=${resolved.workspace}), falling back to CLI`);
|
|
1632
|
+
} else {
|
|
1633
|
+
const resp = await mgr.query({
|
|
1634
|
+
text: userMessage,
|
|
1635
|
+
top_k: limit,
|
|
1636
|
+
include_cold: config.tier === "all",
|
|
1637
|
+
}, config.timeoutMs || 3000);
|
|
1638
|
+
if (resp?.result?.results && Array.isArray(resp.result.results)) {
|
|
1639
|
+
entries = resp.result.results;
|
|
1640
|
+
serverQueried = true;
|
|
1641
|
+
}
|
|
1537
1642
|
}
|
|
1538
1643
|
} catch (serverError) {
|
|
1539
1644
|
logger.warn(`[palaia] Embed server query failed, falling back to CLI: ${serverError}`);
|
|
@@ -1547,7 +1652,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1547
1652
|
if (config.tier === "all") {
|
|
1548
1653
|
queryArgs.push("--all");
|
|
1549
1654
|
}
|
|
1550
|
-
const result = await runJson<QueryResult>(queryArgs, { ...
|
|
1655
|
+
const result = await runJson<QueryResult>(queryArgs, { ...hookOpts, timeoutMs: 15000 });
|
|
1551
1656
|
if (result && Array.isArray(result.results)) {
|
|
1552
1657
|
entries = result.results;
|
|
1553
1658
|
}
|
|
@@ -1569,7 +1674,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1569
1674
|
} else {
|
|
1570
1675
|
listArgs.push("--tier", config.tier || "hot");
|
|
1571
1676
|
}
|
|
1572
|
-
const result = await runJson<QueryResult>(listArgs,
|
|
1677
|
+
const result = await runJson<QueryResult>(listArgs, hookOpts);
|
|
1573
1678
|
if (result && Array.isArray(result.results)) {
|
|
1574
1679
|
entries = result.results;
|
|
1575
1680
|
}
|
|
@@ -1615,7 +1720,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1615
1720
|
// Update recall counter for satisfaction/transparency nudges (Issue #87)
|
|
1616
1721
|
let nudgeContext = "";
|
|
1617
1722
|
try {
|
|
1618
|
-
const pluginState = await loadPluginState(
|
|
1723
|
+
const pluginState = await loadPluginState(resolved.workspace);
|
|
1619
1724
|
pluginState.successfulRecalls++;
|
|
1620
1725
|
if (!pluginState.firstRecallTimestamp) {
|
|
1621
1726
|
pluginState.firstRecallTimestamp = new Date().toISOString();
|
|
@@ -1624,7 +1729,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1624
1729
|
if (nudges.length > 0) {
|
|
1625
1730
|
nudgeContext = "\n\n## Agent Nudge (Palaia)\n\n" + nudges.join("\n\n");
|
|
1626
1731
|
}
|
|
1627
|
-
await savePluginState(pluginState,
|
|
1732
|
+
await savePluginState(pluginState, resolved.workspace);
|
|
1628
1733
|
} catch {
|
|
1629
1734
|
// Non-fatal
|
|
1630
1735
|
}
|
|
@@ -1686,14 +1791,16 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1686
1791
|
// Resolve session key for turn state
|
|
1687
1792
|
const sessionKey = resolveSessionKeyFromCtx(ctx);
|
|
1688
1793
|
|
|
1689
|
-
//
|
|
1794
|
+
// Per-agent workspace resolution (Issue #111)
|
|
1795
|
+
const resolved = resolvePerAgentContext(ctx, config);
|
|
1796
|
+
const hookOpts = buildRunnerOpts(config, { workspace: resolved.workspace });
|
|
1690
1797
|
|
|
1691
1798
|
if (!event.success || !event.messages || event.messages.length === 0) {
|
|
1692
1799
|
return;
|
|
1693
1800
|
}
|
|
1694
1801
|
|
|
1695
1802
|
try {
|
|
1696
|
-
const agentName =
|
|
1803
|
+
const agentName = resolved.agentId;
|
|
1697
1804
|
|
|
1698
1805
|
const allTexts = extractMessageTexts(event.messages);
|
|
1699
1806
|
|
|
@@ -1709,9 +1816,18 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1709
1816
|
collectedHints.push(...hints);
|
|
1710
1817
|
}
|
|
1711
1818
|
|
|
1819
|
+
// Strip Palaia-injected recall context from user messages to prevent feedback loop.
|
|
1820
|
+
// The recall block is prepended to user messages by before_prompt_build.
|
|
1821
|
+
// Without stripping, auto-capture would re-capture previously recalled memories.
|
|
1822
|
+
const cleanedTexts = allTexts.map(t =>
|
|
1823
|
+
t.role === "user"
|
|
1824
|
+
? { ...t, text: stripPalaiaInjectedContext(t.text) }
|
|
1825
|
+
: t
|
|
1826
|
+
);
|
|
1827
|
+
|
|
1712
1828
|
// Only extract from recent exchanges — full history causes LLM timeouts
|
|
1713
1829
|
// and dilutes extraction quality
|
|
1714
|
-
const recentTexts = trimToRecentExchanges(
|
|
1830
|
+
const recentTexts = trimToRecentExchanges(cleanedTexts);
|
|
1715
1831
|
|
|
1716
1832
|
// Build exchange text from recent window only
|
|
1717
1833
|
const exchangeParts: string[] = [];
|
|
@@ -1725,7 +1841,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1725
1841
|
return;
|
|
1726
1842
|
}
|
|
1727
1843
|
|
|
1728
|
-
const knownProjects = await loadProjects(
|
|
1844
|
+
const knownProjects = await loadProjects(hookOpts);
|
|
1729
1845
|
|
|
1730
1846
|
// Helper: build CLI args with metadata
|
|
1731
1847
|
const buildWriteArgs = (
|
|
@@ -1793,7 +1909,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1793
1909
|
validatedProject,
|
|
1794
1910
|
effectiveScope,
|
|
1795
1911
|
);
|
|
1796
|
-
await run(args, { ...
|
|
1912
|
+
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
1797
1913
|
logger.info(
|
|
1798
1914
|
`[palaia] LLM auto-captured: type=${r.type}, significance=${r.significance}, tags=${tags.join(",")}, project=${validatedProject || "none"}, scope=${effectiveScope || "team"}`
|
|
1799
1915
|
);
|
|
@@ -1877,7 +1993,7 @@ export function registerHooks(api: any, config: PalaiaPluginConfig): void {
|
|
|
1877
1993
|
hintForScope?.scope,
|
|
1878
1994
|
);
|
|
1879
1995
|
|
|
1880
|
-
await run(args, { ...
|
|
1996
|
+
await run(args, { ...hookOpts, timeoutMs: 10_000 });
|
|
1881
1997
|
logger.info(
|
|
1882
1998
|
`[palaia] Rule-based auto-captured: type=${captureData.type}, tags=${captureData.tags.join(",")}`
|
|
1883
1999
|
);
|
package/src/runner.ts
CHANGED
|
@@ -361,6 +361,11 @@ export class EmbedServerManager {
|
|
|
361
361
|
clearTimeout(this.pendingRequest.timer);
|
|
362
362
|
this.pendingRequest = null;
|
|
363
363
|
}
|
|
364
|
+
// Reject all queued requests
|
|
365
|
+
for (const queued of this.requestQueue) {
|
|
366
|
+
queued.reject(new Error(`Embed server exited with code ${code}`));
|
|
367
|
+
}
|
|
368
|
+
this.requestQueue = [];
|
|
364
369
|
});
|
|
365
370
|
|
|
366
371
|
// Register cleanup
|
|
@@ -456,6 +461,16 @@ export class EmbedServerManager {
|
|
|
456
461
|
await this.start();
|
|
457
462
|
}
|
|
458
463
|
|
|
464
|
+
/** Maximum number of queued requests before rejecting */
|
|
465
|
+
private maxQueueSize = 10;
|
|
466
|
+
/** Queue of pending requests waiting to be sent */
|
|
467
|
+
private requestQueue: Array<{
|
|
468
|
+
request: Record<string, unknown>;
|
|
469
|
+
timeoutMs: number;
|
|
470
|
+
resolve: (value: any) => void;
|
|
471
|
+
reject: (reason: any) => void;
|
|
472
|
+
}> = [];
|
|
473
|
+
|
|
459
474
|
private sendRequest(request: Record<string, unknown>, timeoutMs: number): Promise<any> {
|
|
460
475
|
return new Promise((resolve, reject) => {
|
|
461
476
|
if (!this.proc?.stdin?.writable) {
|
|
@@ -463,22 +478,46 @@ export class EmbedServerManager {
|
|
|
463
478
|
return;
|
|
464
479
|
}
|
|
465
480
|
|
|
466
|
-
//
|
|
481
|
+
// If a request is already in flight, queue this one
|
|
467
482
|
if (this.pendingRequest) {
|
|
468
|
-
|
|
483
|
+
if (this.requestQueue.length >= this.maxQueueSize) {
|
|
484
|
+
reject(new Error("Embed server queue full"));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this.requestQueue.push({ request, timeoutMs, resolve, reject });
|
|
469
488
|
return;
|
|
470
489
|
}
|
|
471
490
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
491
|
+
this._sendImmediate(request, timeoutMs, resolve, reject);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private _sendImmediate(
|
|
496
|
+
request: Record<string, unknown>,
|
|
497
|
+
timeoutMs: number,
|
|
498
|
+
resolve: (value: any) => void,
|
|
499
|
+
reject: (reason: any) => void,
|
|
500
|
+
): void {
|
|
501
|
+
const timer = setTimeout(() => {
|
|
502
|
+
this.pendingRequest = null;
|
|
503
|
+
reject(new Error(`Embed server request timed out after ${timeoutMs}ms`));
|
|
504
|
+
this._drainQueue();
|
|
505
|
+
}, timeoutMs);
|
|
476
506
|
|
|
477
|
-
|
|
507
|
+
this.pendingRequest = { resolve, reject, timer };
|
|
478
508
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
509
|
+
const line = JSON.stringify(request) + "\n";
|
|
510
|
+
this.proc!.stdin!.write(line);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
private _drainQueue(): void {
|
|
514
|
+
if (this.pendingRequest || this.requestQueue.length === 0) return;
|
|
515
|
+
const next = this.requestQueue.shift()!;
|
|
516
|
+
if (!this.proc?.stdin?.writable) {
|
|
517
|
+
next.reject(new Error("Embed server not running"));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this._sendImmediate(next.request, next.timeoutMs, next.resolve, next.reject);
|
|
482
521
|
}
|
|
483
522
|
|
|
484
523
|
private handleLine(line: string): void {
|
|
@@ -493,6 +532,8 @@ export class EmbedServerManager {
|
|
|
493
532
|
} else {
|
|
494
533
|
pending.resolve(msg);
|
|
495
534
|
}
|
|
535
|
+
// Process next queued request
|
|
536
|
+
this._drainQueue();
|
|
496
537
|
} catch {
|
|
497
538
|
// Ignore non-JSON lines
|
|
498
539
|
}
|