@yemi33/minions 0.1.2373 → 0.1.2375
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/dashboard/js/settings.js +3 -8
- package/dashboard.js +0 -23
- package/docs/design-inbox-entries-schema.md +106 -0
- package/docs/pr-review-fix-loop.md +4 -2
- package/docs/shared-lifecycle-module-map.md +0 -2
- package/docs/team-memory.md +4 -4
- package/engine/db/migrations/019-inbox-entries.js +37 -0
- package/engine/features.js +2 -2
- package/engine/inbox-store.js +295 -0
- package/engine/shared.js +3 -141
- package/engine.js +0 -49
- package/package.json +1 -1
package/dashboard/js/settings.js
CHANGED
|
@@ -446,6 +446,7 @@ async function openSettings() {
|
|
|
446
446
|
'<div class="settings-pane-sub">Persistent CC worker pool and git worktree provisioning. Tune these only if you see worktree-create timeouts or slow CC cold-spawns.</div>' +
|
|
447
447
|
'<div class="settings-stack" style="margin-bottom:12px">' +
|
|
448
448
|
settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Copilot-only (Agent Client Protocol transport); Claude does not implement ACP, so this toggle has no effect when CC runtime is Claude. Default ON for copilot (cold-spawn ~20s on Windows); forced OFF for non-copilot CC runtimes regardless of this toggle.') +
|
|
449
|
+
settingsToggle('Agent Worker Pool', 'set-agentUseWorkerPool', e.agentUseWorkerPool !== false, 'Route fleet Copilot dispatches through persistent copilot --acp workers. Default ON; runtimes without ACP support ignore this setting.') +
|
|
449
450
|
// W-mq6f2fe0000557fa — opt-in: auto-kill orphan spawn-agent processes
|
|
450
451
|
// holding a stuck worktree's cwd. Gated by safe-reap criteria (cmdline
|
|
451
452
|
// matches spawn-agent.js + basename + age > agentTimeout * 2). Default
|
|
@@ -484,12 +485,6 @@ async function openSettings() {
|
|
|
484
485
|
'</select>' +
|
|
485
486
|
'</div>' +
|
|
486
487
|
settingsField('Copilot fallback model', 'set-copilotFallbackModel', e.copilotFallbackModel || '', 'e.g. gpt-5.4', 'Copilot has no --fallback-model flag. On a MODEL_UNAVAILABLE (overloaded/503) retry, the engine OVERRIDES --model with this value (Copilot only).') +
|
|
487
|
-
'</div>' +
|
|
488
|
-
'<div class="settings-stack" style="margin-top:12px">' +
|
|
489
|
-
settingsField('Copilot: disable agent MCP servers', 'set-copilotAgentDisabledMcpServers',
|
|
490
|
-
Array.isArray(e.copilotAgentDisabledMcpServers) ? e.copilotAgentDisabledMcpServers.join(', ') : (e.copilotAgentDisabledMcpServers || ''),
|
|
491
|
-
'e.g. playwright, maestro, loop',
|
|
492
|
-
'Comma-separated MCP server names (from ~/.copilot/mcp-config.json) the engine disables for autonomous Copilot agent dispatches by filtering them out of the agent-only COPILOT_HOME. Copilot loads your user MCP config on every spawn regardless of --add-dir; list the noisy/auth-popping servers (e.g. playwright, maestro) to keep them out of agents. Empty = inherit all.') +
|
|
493
488
|
'</div>';
|
|
494
489
|
|
|
495
490
|
const paneClaude =
|
|
@@ -588,7 +583,7 @@ async function openSettings() {
|
|
|
588
583
|
'</div>' +
|
|
589
584
|
'<h4>Agent Memory</h4>' +
|
|
590
585
|
'<div class="settings-stack">' +
|
|
591
|
-
settingsToggle('Shadow mode', 'set-memoryRetrievalShadowMode', e.memoryRetrievalShadowMode
|
|
586
|
+
settingsToggle('Shadow mode', 'set-memoryRetrievalShadowMode', !!e.memoryRetrievalShadowMode, 'Measure retrieval without changing prompts') +
|
|
592
587
|
settingsToggle('Capture task episodes', 'set-memoryEpisodicCapture', !!e.memoryEpisodicCapture, 'Store compact outcomes, never transcripts') +
|
|
593
588
|
'</div>' +
|
|
594
589
|
'<div class="settings-grid-2">' +
|
|
@@ -1128,6 +1123,7 @@ async function saveSettings() {
|
|
|
1128
1123
|
autoFixHumanComments: document.getElementById('set-autoFixHumanComments').checked,
|
|
1129
1124
|
autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
|
|
1130
1125
|
ccUseWorkerPool: !!document.getElementById('set-ccUseWorkerPool')?.checked,
|
|
1126
|
+
agentUseWorkerPool: !!document.getElementById('set-agentUseWorkerPool')?.checked,
|
|
1131
1127
|
autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
|
|
1132
1128
|
liveCheckoutAutoStash: !!document.getElementById('set-liveCheckoutAutoStash')?.checked,
|
|
1133
1129
|
liveCheckoutAutoReset: !!document.getElementById('set-liveCheckoutAutoReset')?.checked,
|
|
@@ -1188,7 +1184,6 @@ async function saveSettings() {
|
|
|
1188
1184
|
claudeBareMode: !!document.getElementById('set-claudeBareMode')?.checked,
|
|
1189
1185
|
claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
|
|
1190
1186
|
copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
|
|
1191
|
-
copilotAgentDisabledMcpServers: (document.getElementById('set-copilotAgentDisabledMcpServers')?.value ?? '').trim(),
|
|
1192
1187
|
copilotDisableBuiltinMcps: !!document.getElementById('set-copilotDisableBuiltinMcps')?.checked,
|
|
1193
1188
|
copilotStreamMode: document.getElementById('set-copilotStreamMode')?.value || 'on',
|
|
1194
1189
|
copilotReasoningSummaries: !!document.getElementById('set-copilotReasoningSummaries')?.checked,
|
package/dashboard.js
CHANGED
|
@@ -11374,17 +11374,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11374
11374
|
else if (valid.includes(e.copilotStreamMode)) _setEngineConfig('copilotStreamMode', e.copilotStreamMode);
|
|
11375
11375
|
else _clamped.push(`copilotStreamMode: "${e.copilotStreamMode}" not in [on, off] (kept previous value)`);
|
|
11376
11376
|
}
|
|
11377
|
-
// P-mcp-storm — MCP server names to disable for autonomous Copilot agents
|
|
11378
|
-
// (--disable-mcp-server). Accept an array OR a comma/whitespace string;
|
|
11379
|
-
// normalize to a deduped array of trimmed names. Empty clears (inherit all).
|
|
11380
|
-
if (e.copilotAgentDisabledMcpServers !== undefined) {
|
|
11381
|
-
const src = Array.isArray(e.copilotAgentDisabledMcpServers)
|
|
11382
|
-
? e.copilotAgentDisabledMcpServers
|
|
11383
|
-
: String(e.copilotAgentDisabledMcpServers || '').split(/[\s,]+/);
|
|
11384
|
-
const names = [...new Set(src.map(s => String(s == null ? '' : s).trim()).filter(Boolean))];
|
|
11385
|
-
if (names.length) _setEngineConfig('copilotAgentDisabledMcpServers', names);
|
|
11386
|
-
else _deleteEngineConfig('copilotAgentDisabledMcpServers');
|
|
11387
|
-
}
|
|
11388
11377
|
// W-mpmwxkrw000872ec — fontSize allowlist. Clamps invalid values
|
|
11389
11378
|
// (rather than silently failing) so the dashboard bootstrap never
|
|
11390
11379
|
// ends up with an unknown data-font-size attribute.
|
|
@@ -15099,18 +15088,6 @@ if (require.main === module) {
|
|
|
15099
15088
|
// engine reads, port binding) is captured rather than dying silently.
|
|
15100
15089
|
_installCrashHandlers();
|
|
15101
15090
|
|
|
15102
|
-
// Deliberately do NOT set process.env.COPILOT_HOME here. Command Center,
|
|
15103
|
-
// doc-chat, and the CC worker pool (`copilot --acp`) all spawn `copilot`
|
|
15104
|
-
// as this process's children, so they should inherit the operator's real
|
|
15105
|
-
// `~/.copilot` home (including their model preference in settings.json)
|
|
15106
|
-
// exactly like an interactive `copilot` CLI session would — that's the
|
|
15107
|
-
// documented CLI contract. An earlier revision forced these onto an
|
|
15108
|
-
// isolated, MCP-filtered home to dodge MCP-server auth popups, but that
|
|
15109
|
-
// broke CC's settings inheritance and reintroduced a staleness/reseed bug;
|
|
15110
|
-
// reverted (W-mre75l6x00024f49). Per-dispatch agent isolation is unaffected
|
|
15111
|
-
// — the engine process still seeds its own COPILOT_HOME at boot and
|
|
15112
|
-
// re-stamps it per spawn via `_applyAgentCopilotHome` (engine.js).
|
|
15113
|
-
|
|
15114
15091
|
// Kick off first npm check on startup, then re-check every 4 hours.
|
|
15115
15092
|
// Guarded here so that requiring dashboard as a module (unit tests, _withDashServer)
|
|
15116
15093
|
// does not spawn npm child processes that keep the event loop alive past test teardown
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# inbox_entries Table Schema
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
The `inbox_entries` table is the persistent store for operator and agent inbox messages within the Minions orchestration engine. It provides a time-ordered, unread-tracking message queue decoupled from the runtime work-item/dispatch lifecycle.
|
|
5
|
+
|
|
6
|
+
## Schema Specification
|
|
7
|
+
|
|
8
|
+
### Column Definitions
|
|
9
|
+
|
|
10
|
+
| Column | Type | Constraints | Purpose |
|
|
11
|
+
|--------|------|-----------|---------|
|
|
12
|
+
| `id` | TEXT | PRIMARY KEY | Unique message identifier; generated as `<recipient>-<timestamp>-<sender>-<seq>` format |
|
|
13
|
+
| `recipient_session_id` | TEXT | NOT NULL | Session ID of the inbox owner (operator or agent); routes messages to specific actor |
|
|
14
|
+
| `sender_id` | TEXT | NOT NULL | Identifier of the message originator; structured as `<agent-id>` or `'system'` or `'operator'` |
|
|
15
|
+
| `sender_name` | TEXT | NOT NULL | Human-readable sender name for UI display; persisted at write-time for offline/archived reference |
|
|
16
|
+
| `sender_type` | TEXT | NOT NULL | Enum: `'agent'`, `'system'`, `'operator'`; constrains allowed sender_id values |
|
|
17
|
+
| `interaction_id` | TEXT | NOT NULL | Group ID linking related messages (e.g., work-item dispatch feedback, PR review batch); enables threading and deduplication |
|
|
18
|
+
| `sequence` | INTEGER | NOT NULL, DEFAULT 0 | Per-interaction message counter; disambiguates bursts and enables ordering within same millisecond |
|
|
19
|
+
| `summary` | TEXT | NOT NULL | Title/subject line; used for list views and user scanning; always present even if content is truncated |
|
|
20
|
+
| `content` | TEXT | NOT NULL | Full message body; may contain Markdown, code blocks, or structured JSON sidecars (not yet parsed at table level) |
|
|
21
|
+
| `unread` | INTEGER | NOT NULL, DEFAULT 1 | Boolean flag (0/1); toggled by reader, cleared on archive/delete; enables fast unread-count queries |
|
|
22
|
+
| `sent_at` | INTEGER | NOT NULL | Unix timestamp (milliseconds) of message creation; primary sort key for time-ordered display |
|
|
23
|
+
| `read_at` | INTEGER | Nullable | Unix timestamp (milliseconds) when recipient first read the message; null until marked read |
|
|
24
|
+
| `notified_at` | INTEGER | Nullable | Unix timestamp (milliseconds) of last notification event sent to operator; null if no notification dispatched |
|
|
25
|
+
|
|
26
|
+
### Constraints & Relationships
|
|
27
|
+
|
|
28
|
+
- **PK constraint:** `id` is globally unique across all inboxes; collision risk is negligible given the composite structure (recipient + timestamp + sender + seq).
|
|
29
|
+
- **FK (soft):** `recipient_session_id` does NOT enforce a referential constraint to `sessions` table (sessions may be archived). Queries SHOULD handle missing recipients gracefully.
|
|
30
|
+
- **FK (soft):** `sender_id` is informational only; no validation that the sender exists in the `config.agents` or active sessions. Invalid senders are logged but do not block writes.
|
|
31
|
+
- **No composite index on (recipient_session_id, sent_at):** See Performance section.
|
|
32
|
+
|
|
33
|
+
## Architectural Rationale
|
|
34
|
+
|
|
35
|
+
### Design Decisions
|
|
36
|
+
|
|
37
|
+
1. **Separate inbox table vs. inline dispatch/work-item attachments.**
|
|
38
|
+
- Inboxes can outlive their source work items (e.g., a feedback message remains useful after the WI is done).
|
|
39
|
+
- Decoupling allows independent inbox lifecycle (archive, export, cleanup) without cascading work-item deletes.
|
|
40
|
+
- Supports rich async consolidation (team notes, KB articles) that don't belong to any single WI.
|
|
41
|
+
|
|
42
|
+
2. **`unread` flag instead of soft-delete or status enum.**
|
|
43
|
+
- Fast count queries for badge (`SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1`).
|
|
44
|
+
- Unread state is mutable; allows mark-as-read UX without data loss.
|
|
45
|
+
- Simplifies archive/export (no tombstone filtering needed).
|
|
46
|
+
|
|
47
|
+
3. **`sender_type` as an explicit column.**
|
|
48
|
+
- Allows schema validation (e.g., "agent" senders must have valid IDs; "system" is always valid).
|
|
49
|
+
- Enables filtered views ("show only agent messages" or "system alerts only") without string parsing.
|
|
50
|
+
|
|
51
|
+
4. **`interaction_id` for grouping.**
|
|
52
|
+
- Enables undo/redo on related messages (e.g., canceling all build-failure notifications from one dispatch).
|
|
53
|
+
- Supports consolidation: multiple small notifications can be collapsed into one summary message.
|
|
54
|
+
|
|
55
|
+
5. **Persistent `sender_name` at write-time.**
|
|
56
|
+
- Operator may rename agents or disable them; storing the name ensures historical accuracy.
|
|
57
|
+
- UI doesn't need to perform runtime lookups on archived inboxes.
|
|
58
|
+
|
|
59
|
+
6. **Unix timestamps (milliseconds) instead of ISO strings.**
|
|
60
|
+
- Enables range queries (`sent_at BETWEEN ? AND ?`) without parsing.
|
|
61
|
+
- Smaller column footprint; easier to sort.
|
|
62
|
+
- Downside: not human-readable; UI must convert to locale strings.
|
|
63
|
+
|
|
64
|
+
### Entity Relationships
|
|
65
|
+
|
|
66
|
+
- **inbox_entries ← dispatch WI feedback:** A failed dispatch may create multiple inbox entries (error summary, retry recommendation, logs snippet). Threaded via `interaction_id`.
|
|
67
|
+
- **inbox_entries ← PR reviews:** Humans and auto-review agents post comments; summarized as inbox alerts to project owners via `interaction_id` per-PR.
|
|
68
|
+
- **inbox_entries ← consolidation:** `engine/consolidation.js` reads inbox entries to extract `agent:` frontmatter and route findings into per-agent knowledge files.
|
|
69
|
+
|
|
70
|
+
## Performance Considerations
|
|
71
|
+
|
|
72
|
+
### Indexing Strategy
|
|
73
|
+
|
|
74
|
+
**Current indexes (recommended):**
|
|
75
|
+
- `idx_recipient_sent` on `(recipient_session_id, sent_at DESC)` — enables efficient "unread count" and "latest N messages" queries without table scans.
|
|
76
|
+
- `idx_interaction` on `(interaction_id)` — enables grouping/dedup queries (e.g., "was this interaction already notified?").
|
|
77
|
+
- PK `id` is implicit index.
|
|
78
|
+
|
|
79
|
+
**Query patterns:**
|
|
80
|
+
- **Unread count per session:** `SELECT COUNT(*) WHERE recipient_session_id = ? AND unread = 1` → must scan `idx_recipient_sent` to avoid full table.
|
|
81
|
+
- **Latest messages:** `SELECT * WHERE recipient_session_id = ? ORDER BY sent_at DESC LIMIT 20` → seeks on `idx_recipient_sent` DESC.
|
|
82
|
+
- **Thread view:** `SELECT * WHERE interaction_id = ? ORDER BY sent_at` → seeks on `idx_interaction`, then sorts in application or via secondary index.
|
|
83
|
+
- **Cleanup:** `DELETE WHERE sent_at < ? AND unread = 0` → full table scan (acceptable; cleanup is infrequent, low-priority background task).
|
|
84
|
+
|
|
85
|
+
**Missing indexes (future optimization):**
|
|
86
|
+
- `idx_sender_id` — if queries like "messages from this agent" are common, consider adding.
|
|
87
|
+
- `idx_unread` — if badge ("N unread") is a hot query, consider combining with recipient (`idx_recipient_unread` on `(recipient_session_id, unread)`).
|
|
88
|
+
|
|
89
|
+
### Size Estimates & Cleanup
|
|
90
|
+
|
|
91
|
+
- **Row size:** ~500–2000 bytes (summary + content; content can be verbose with logs/code snippets).
|
|
92
|
+
- **Growth:** ~5–50 entries/day (depends on agent activity and verbose flagging).
|
|
93
|
+
- **Retention:** No automatic cleanup; operators may archive/export and manually delete aged entries. Engine does NOT auto-prune.
|
|
94
|
+
- **Recommended**: Implement a dashboard setting to soft-delete entries older than N days (default 30) with an undo/export option.
|
|
95
|
+
|
|
96
|
+
## Migration & Backward Compatibility
|
|
97
|
+
|
|
98
|
+
- **Migration 018 or later** creates `inbox_entries` as part of SQL state cutover. Legacy `inbox/` Markdown files are NOT imported; operators must re-send or manually recreate critical messages.
|
|
99
|
+
- **No schema versioning needed** for now; table is immutable-append-only (reads/writes only).
|
|
100
|
+
|
|
101
|
+
## Related Code
|
|
102
|
+
|
|
103
|
+
- **Writes:** `engine/inbox-store.js` (primary mutator; uses `getDb()` and `emitStateEvent`).
|
|
104
|
+
- **Reads:** `engine/queries.js#getInboxForSession()`, dashboard `/api/inbox`, `engine/consolidation.js`.
|
|
105
|
+
- **UI:** `dashboard/pages/inbox.html`, `dashboard/js/inbox.js`.
|
|
106
|
+
|
|
@@ -11,6 +11,7 @@ How the engine manages the lifecycle of a PR from creation through review, fix,
|
|
|
11
11
|
|
|
12
12
|
- `discoverFromPrs()` (engine.js) runs each tick (~10s)
|
|
13
13
|
- Gates: `status === 'active'` + `reviewStatus === 'pending'` + not reviewed since last push + not dispatched + not on cooldown
|
|
14
|
+
- "Not reviewed since last push" is `!isPrReviewCurrent(pr)` — compares `pr.reviewedHeadSha` (the SHA the completed review verdict actually evaluated) against the live head, falling back to the legacy `lastPushedAt <= lastReviewedAt` timestamp comparison for PR records that predate `reviewedHeadSha`. The SHA-based check replaces a pure timestamp comparison, which could false-positive as "already reviewed" when a commit landed mid-review (`lastReviewedAt` is stamped at completion, i.e. *after* the mid-review push) — root cause of github:opg-microsoft/minions#821. Re-review (`reviewStatus === 'waiting'`) uses the mirrored `isPrFixedAfterReview(pr)` gate for the same reason. Both dispatch sites capture `reviewHeadSha: getPrCauseHead(pr)` into dispatch `meta` so the eventual verdict can stamp the exact SHA it evaluated.
|
|
14
15
|
- Pre-dispatch: `checkLiveReviewStatus()` hits GitHub/ADO API to catch stale cached status
|
|
15
16
|
- Routes to reviewer via `resolveAgent('review')`, dispatches with `review.md` playbook
|
|
16
17
|
|
|
@@ -144,8 +145,9 @@ A "branch unchanged" no-op report for a `BUILD_FAILURE`-cause fix used to be con
|
|
|
144
145
|
| `buildErrorLog` | Poller | Actual CI error output for fix agents |
|
|
145
146
|
| `_buildFixPushedAt` | Discovery (on dispatch) | Grace period timestamp |
|
|
146
147
|
| `_buildFailNotified` | Discovery | Dedup for inbox alert |
|
|
147
|
-
| `lastPushedAt` | Poller (new commit) | Tracks latest push for re-review logic |
|
|
148
|
-
| `lastReviewedAt` | `updatePrAfterReview()` |
|
|
148
|
+
| `lastPushedAt` | Poller (new commit) | Tracks latest push for re-review logic (legacy fallback signal) |
|
|
149
|
+
| `lastReviewedAt` | `updatePrAfterReview()` | Legacy fallback for freshness gating on PR records that predate `reviewedHeadSha` |
|
|
150
|
+
| `reviewedHeadSha` | `updatePrAfterReview()` | SHA the completed review verdict actually evaluated (captured at dispatch time via `meta.reviewHeadSha`, or falls back to the live head for legacy in-flight dispatches); primary review-freshness signal (`isPrReviewCurrent`/`isPrFixedAfterReview`) |
|
|
149
151
|
| `minionsReview` | Post-completion hooks | `{ reviewer, reviewedAt, note, fixedAt }` |
|
|
150
152
|
| `humanFeedback` | `pollPrHumanComments()` | `{ pendingFix, feedbackContent, lastProcessedCommentDate }` |
|
|
151
153
|
| `_noOpFixes[cause]` | `recordPrNoOpFixAttempt()` | Per-cause record `{ count, paused, fingerprint, beforeHead, afterHead }` driving the issue #2969 pause loop |
|
|
@@ -321,8 +321,6 @@ means zero such files were found), **Proposed target module**.
|
|
|
321
321
|
| `resolveCcModel` | 3935-3950 | Resolve the model for the Command Center / doc-chat. Priority: 1. `engine.ccModel` — CC-only override 2. `engine.defaultModel` — fleet default (CC inherits this when ccModel unset) 3. `undefined` — let the runtime adapte | yes | `engine/llm.js`, `engine/pre-dispatch-eval.js`, `test/unit/auto-recovery.test.js`, `test/unit.test.js`, `dashboard.js` | engine/shared.js (keep) |
|
|
322
322
|
| `resolveAgentMaxBudget` | 3951-3973 | Resolve the per-spawn USD budget cap. Priority: 1. `agent.maxBudgetUsd` — per-agent override 2. `engine.maxBudgetUsd` — fleet default 3. `undefined` — no cap Uses nullish coalescing so a literal `0` is honored as a valid | yes | `test/unit.test.js`, `engine.js` | engine/shared.js (keep) |
|
|
323
323
|
| `resolveAgentBareMode` | 3974-3991 | Resolve whether this agent should run in Claude `--bare` mode. Priority: 1. `agent.bareMode` — per-agent override (boolean) 2. `engine.claudeBareMode` — fleet default 3. `false` — hardcoded fallback Strict undefined/null | yes | `test/unit.test.js`, `engine.js` | engine/shared.js (keep) |
|
|
324
|
-
| `_normalizeMcpServerList` | 3992-4005 | P-mcp-storm — Resolve the list of MCP server names to disable for spawned // Copilot agents. The engine FILTERS these out of the agent's seeded // COPILOT_HOME mcp-config (engine.js `_applyAgentCopilotHome`) — a server a | no | _internal-only (no callers found outside shared.js)_ | engine/shared.js (keep) |
|
|
325
|
-
| `resolveCopilotAgentDisabledMcpServers` | 4006-4030 | Resolve copilot agent disabled mcp servers. | yes | `test/unit/agent-copilot-home.test.js` | engine/shared.js (keep) |
|
|
326
324
|
| `applyLegacyCcModelMigration` | 4063-4082 | If `config.engine.ccModel` is set but `config.engine.defaultModel` is unset, mutate the in-memory `config.engine` so `defaultModel` mirrors `ccModel` and log a one-time deprecation notice. Does NOT write to disk. Returns | yes | `engine/cli.js`, `test/unit.test.js` | engine/shared.js (keep) |
|
|
327
325
|
| `_resetLegacyCcModelMigrationFlag` | 4083-4113 | Test helper: reset the dedup flag so repeated tests can re-trigger the log. | yes | _internal-only (no callers found outside shared.js)_ | engine/shared.js (keep) |
|
|
328
326
|
| `runtimeConfigWarnings` | 4114-4194 | Inspect runtime fleet config and return warning entries for misconfiguration. Warnings emitted: - Unknown CLI: any `cli` value (per-agent, ccCli, defaultCli) not in `registeredRuntimes`. Each unknown value produces one e | yes | `engine/preflight.js` | engine/process-utils.js (new) — process spawn/pid/tmp-dir lifecycle helpers |
|
package/docs/team-memory.md
CHANGED
|
@@ -12,7 +12,7 @@ Multiple agents work the same repository in parallel and across days. Without sh
|
|
|
12
12
|
- Agents re-litigate decisions the team already made (drift, regressions).
|
|
13
13
|
- Human-flagged warnings (e.g., "do not self-approve PRs") get ignored because they live outside the prompt.
|
|
14
14
|
|
|
15
|
-
Team memory closes the loop. The engine always injects pinned context. By default
|
|
15
|
+
Team memory closes the loop. The engine always injects pinned context. By default, structured retrieval replaces broad team and personal notes with a bounded task-relevant memory pack. Playbooks still instruct agents to consult `knowledge/` and `notes/inbox/` on disk before going wider.
|
|
16
16
|
|
|
17
17
|
## Structured memory and retrieval
|
|
18
18
|
|
|
@@ -20,11 +20,11 @@ Migration `017-agent-memory.js` backfills the existing Markdown sources into `me
|
|
|
20
20
|
|
|
21
21
|
`engine/memory-retrieval.js` searches active records in global, current-project, and assigned-agent scopes. It reranks lexical matches by scope, trust, path match, recency, importance, and confidence, then packs the best records into a strict byte budget. Agent- or external-authored procedural records are never recalled as instructions. Every injected pack remains inside an `<UNTRUSTED-INPUT>` fence.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Structured retrieval is controlled by the default-on `memoryRetrieval` feature flag:
|
|
24
24
|
|
|
25
25
|
- **Flag off:** legacy prompt behavior.
|
|
26
|
-
- **Flag on +
|
|
27
|
-
- **Flag on +
|
|
26
|
+
- **Flag on + shadow mode off (default):** inject `Relevant Memory` when retrieval finds evidence; otherwise fall back to legacy notes and personal memory.
|
|
27
|
+
- **Flag on + `memoryRetrievalShadowMode: true`:** compute retrieval and telemetry, but keep legacy prompts.
|
|
28
28
|
|
|
29
29
|
Settings expose top-k, candidate count, byte budget, shadow mode, and episodic capture. The Inbox page's **Memory search** control shows provenance and status and supports explicit pin, retract, and restore actions.
|
|
30
30
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// engine/db/migrations/019-inbox-entries.js
|
|
2
|
+
//
|
|
3
|
+
// Creates the `inbox_entries` table: persistent store for operator and agent
|
|
4
|
+
// inbox messages, decoupled from the runtime work-item/dispatch lifecycle.
|
|
5
|
+
//
|
|
6
|
+
// Schema: columns, types, constraints, and indexing strategy documented in
|
|
7
|
+
// docs/design-inbox-entries-schema.md
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
version: 19,
|
|
11
|
+
description: 'inbox_entries: operator/agent message queue with full schema + indexes',
|
|
12
|
+
up(db) {
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE inbox_entries (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
recipient_session_id TEXT NOT NULL,
|
|
17
|
+
sender_id TEXT NOT NULL,
|
|
18
|
+
sender_name TEXT NOT NULL,
|
|
19
|
+
sender_type TEXT NOT NULL,
|
|
20
|
+
interaction_id TEXT NOT NULL,
|
|
21
|
+
sequence INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
summary TEXT NOT NULL,
|
|
23
|
+
content TEXT NOT NULL,
|
|
24
|
+
unread INTEGER NOT NULL DEFAULT 1,
|
|
25
|
+
sent_at INTEGER NOT NULL,
|
|
26
|
+
read_at INTEGER,
|
|
27
|
+
notified_at INTEGER
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE INDEX idx_inbox_recipient_sent
|
|
31
|
+
ON inbox_entries(recipient_session_id, sent_at DESC);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX idx_inbox_interaction
|
|
34
|
+
ON inbox_entries(interaction_id);
|
|
35
|
+
`);
|
|
36
|
+
},
|
|
37
|
+
};
|
package/engine/features.js
CHANGED
|
@@ -96,8 +96,8 @@ const FEATURES = {
|
|
|
96
96
|
expires: '2026-12-01',
|
|
97
97
|
},
|
|
98
98
|
'memoryRetrieval': {
|
|
99
|
-
description: 'Select bounded task-relevant long-term memory with SQLite FTS5 instead of injecting
|
|
100
|
-
default:
|
|
99
|
+
description: 'Select bounded task-relevant long-term memory with SQLite FTS5 instead of injecting broad team and personal notes.',
|
|
100
|
+
default: true,
|
|
101
101
|
addedIn: '0.1.2233',
|
|
102
102
|
expires: '2027-01-31',
|
|
103
103
|
},
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// engine/inbox-store.js — SQL-backed inbox_entries table handlers.
|
|
2
|
+
//
|
|
3
|
+
// Provides read/write helpers for the operator and agent inbox message queue.
|
|
4
|
+
// Inboxes are decoupled from the runtime work-item/dispatch lifecycle and
|
|
5
|
+
// support threading via interaction_id, unread tracking, and time-ordered display.
|
|
6
|
+
//
|
|
7
|
+
// Schema: docs/design-inbox-entries-schema.md
|
|
8
|
+
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
const { getDb, withTransaction } = require('./db');
|
|
11
|
+
|
|
12
|
+
// Per CLAUDE.md's "State storage" mutator contract, every mutator wrapper
|
|
13
|
+
// must call emitStateEvent() after a successful write so the dashboard's
|
|
14
|
+
// MAX(events.id) cache-version advances — otherwise the dashboard keeps
|
|
15
|
+
// serving stale cached inbox state until an unrelated event happens to
|
|
16
|
+
// bump the counter. Best-effort: never let event emission fail a write.
|
|
17
|
+
function _emitInboxEvent(payload) {
|
|
18
|
+
try {
|
|
19
|
+
require('./db-events').emitStateEvent('inbox', payload);
|
|
20
|
+
} catch { /* best-effort */ }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// In-process monotonic counter. Millisecond timestamps are too coarse to
|
|
24
|
+
// disambiguate bursts of createInboxEntry() calls for the same
|
|
25
|
+
// recipient+sender pair (e.g. rapid consolidation/notification writes), and
|
|
26
|
+
// the caller-supplied `sequence` field is not guaranteed to be unique either
|
|
27
|
+
// (it defaults to 0 and callers are not required to increment it). The
|
|
28
|
+
// counter guarantees every id generated within this process is distinct even
|
|
29
|
+
// when Date.now() does not advance between calls.
|
|
30
|
+
let _inboxIdCounter = 0;
|
|
31
|
+
|
|
32
|
+
// Generate a likely-unique inbox entry ID:
|
|
33
|
+
// <recipient>-<timestamp>-<sender>-<seq>-<counter>-<random>
|
|
34
|
+
// `sequence` is preserved for callers that pass a meaningful value; the
|
|
35
|
+
// monotonic counter + random suffix are what actually make the id
|
|
36
|
+
// collision-proof, independent of clock resolution or caller behavior.
|
|
37
|
+
function _generateInboxId(recipientSessionId, senderId, sequence) {
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
_inboxIdCounter = (_inboxIdCounter + 1) % 0x1000000;
|
|
40
|
+
const rand = crypto.randomBytes(4).toString('hex');
|
|
41
|
+
return `${recipientSessionId}-${now}-${senderId}-${sequence}-${_inboxIdCounter.toString(36)}-${rand}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read all inbox entries for a given recipient session, ordered by time descending.
|
|
45
|
+
function _readInboxEntriesFromSql(db, { recipientSessionId } = {}) {
|
|
46
|
+
let query = 'SELECT * FROM inbox_entries';
|
|
47
|
+
const params = [];
|
|
48
|
+
if (recipientSessionId) {
|
|
49
|
+
query += ' WHERE recipient_session_id = ?';
|
|
50
|
+
params.push(recipientSessionId);
|
|
51
|
+
}
|
|
52
|
+
query += ' ORDER BY sent_at DESC';
|
|
53
|
+
return db.prepare(query).all(...params);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Read unread count for a recipient.
|
|
57
|
+
function getInboxUnreadCount(recipientSessionId) {
|
|
58
|
+
let db;
|
|
59
|
+
try { db = getDb(); }
|
|
60
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
61
|
+
const row = db.prepare('SELECT COUNT(*) AS count FROM inbox_entries WHERE recipient_session_id = ? AND unread = 1')
|
|
62
|
+
.get(recipientSessionId);
|
|
63
|
+
return row ? row.count : 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read all inbox entries for a recipient session.
|
|
67
|
+
function getInboxForSession(recipientSessionId, { limit = null, offset = 0 } = {}) {
|
|
68
|
+
let db;
|
|
69
|
+
try { db = getDb(); }
|
|
70
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
71
|
+
let query = 'SELECT * FROM inbox_entries WHERE recipient_session_id = ? ORDER BY sent_at DESC';
|
|
72
|
+
const params = [recipientSessionId];
|
|
73
|
+
if (limit) {
|
|
74
|
+
query += ' LIMIT ? OFFSET ?';
|
|
75
|
+
params.push(limit, offset);
|
|
76
|
+
}
|
|
77
|
+
return db.prepare(query).all(...params);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Read a single inbox entry by ID.
|
|
81
|
+
function getInboxEntryById(id) {
|
|
82
|
+
let db;
|
|
83
|
+
try { db = getDb(); }
|
|
84
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
85
|
+
return db.prepare('SELECT * FROM inbox_entries WHERE id = ?').get(id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Create a new inbox entry. Returns the entry object.
|
|
89
|
+
function createInboxEntry({
|
|
90
|
+
recipientSessionId,
|
|
91
|
+
senderId,
|
|
92
|
+
senderName,
|
|
93
|
+
senderType,
|
|
94
|
+
interactionId,
|
|
95
|
+
summary,
|
|
96
|
+
content,
|
|
97
|
+
sequence = 0,
|
|
98
|
+
}) {
|
|
99
|
+
if (!recipientSessionId || !senderId || !senderName || !senderType || !interactionId || !summary || !content) {
|
|
100
|
+
throw new Error('inbox-store: missing required fields for createInboxEntry');
|
|
101
|
+
}
|
|
102
|
+
if (!['agent', 'system', 'operator'].includes(senderType)) {
|
|
103
|
+
throw new Error(`inbox-store: invalid senderType "${senderType}"`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let db;
|
|
107
|
+
try { db = getDb(); }
|
|
108
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
109
|
+
|
|
110
|
+
const MAX_ID_COLLISION_RETRIES = 5;
|
|
111
|
+
|
|
112
|
+
return withTransaction(db, () => {
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
const insert = db.prepare(`
|
|
115
|
+
INSERT INTO inbox_entries
|
|
116
|
+
(id, recipient_session_id, sender_id, sender_name, sender_type, interaction_id,
|
|
117
|
+
sequence, summary, content, unread, sent_at, read_at, notified_at)
|
|
118
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, NULL, NULL)
|
|
119
|
+
`);
|
|
120
|
+
|
|
121
|
+
// _generateInboxId() is collision-proof in practice (monotonic
|
|
122
|
+
// per-process counter + random suffix), but retry-on-conflict is kept as
|
|
123
|
+
// defense-in-depth against an id collision from any other source (e.g. a
|
|
124
|
+
// pre-existing row imported/replayed from another process) rather than
|
|
125
|
+
// letting a rare UNIQUE-constraint failure crash the caller.
|
|
126
|
+
let id;
|
|
127
|
+
for (let attempt = 0; attempt < MAX_ID_COLLISION_RETRIES; attempt++) {
|
|
128
|
+
id = _generateInboxId(recipientSessionId, senderId, sequence);
|
|
129
|
+
try {
|
|
130
|
+
insert.run(id, recipientSessionId, senderId, senderName, senderType, interactionId, sequence, summary, content, now);
|
|
131
|
+
break;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
const isUniqueViolation = e && (e.code === 'ERR_SQLITE_ERROR' || e.errcode === 1555 || /UNIQUE constraint failed/.test(e.message || ''));
|
|
134
|
+
if (!isUniqueViolation || attempt === MAX_ID_COLLISION_RETRIES - 1) throw e;
|
|
135
|
+
// Regenerate and retry — the monotonic counter guarantees the next
|
|
136
|
+
// attempt produces a different id.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_emitInboxEvent({ id, recipientSessionId, action: 'create' });
|
|
141
|
+
return getInboxEntryById(id);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Mark one or more inbox entries as read.
|
|
146
|
+
function markInboxEntriesAsRead(entryIds) {
|
|
147
|
+
if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
|
|
148
|
+
|
|
149
|
+
let db;
|
|
150
|
+
try { db = getDb(); }
|
|
151
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
152
|
+
|
|
153
|
+
return withTransaction(db, () => {
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
const placeholders = entryIds.map(() => '?').join(',');
|
|
156
|
+
const query = `UPDATE inbox_entries SET unread = 0, read_at = ? WHERE id IN (${placeholders})`;
|
|
157
|
+
const stmt = db.prepare(query);
|
|
158
|
+
const result = stmt.run(now, ...entryIds);
|
|
159
|
+
if (result.changes > 0) _emitInboxEvent({ entryIds, action: 'mark_read' });
|
|
160
|
+
return { wrote: result.changes > 0 };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Mark one or more inbox entries as unread.
|
|
165
|
+
function markInboxEntriesAsUnread(entryIds) {
|
|
166
|
+
if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
|
|
167
|
+
|
|
168
|
+
let db;
|
|
169
|
+
try { db = getDb(); }
|
|
170
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
171
|
+
|
|
172
|
+
return withTransaction(db, () => {
|
|
173
|
+
const placeholders = entryIds.map(() => '?').join(',');
|
|
174
|
+
const query = `UPDATE inbox_entries SET unread = 1, read_at = NULL WHERE id IN (${placeholders})`;
|
|
175
|
+
const stmt = db.prepare(query);
|
|
176
|
+
const result = stmt.run(...entryIds);
|
|
177
|
+
if (result.changes > 0) _emitInboxEvent({ entryIds, action: 'mark_unread' });
|
|
178
|
+
return { wrote: result.changes > 0 };
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Mark all inbox entries for a recipient as read.
|
|
183
|
+
function markAllInboxAsRead(recipientSessionId) {
|
|
184
|
+
let db;
|
|
185
|
+
try { db = getDb(); }
|
|
186
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
187
|
+
|
|
188
|
+
return withTransaction(db, () => {
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const result = db.prepare(
|
|
191
|
+
'UPDATE inbox_entries SET unread = 0, read_at = ? WHERE recipient_session_id = ? AND unread = 1'
|
|
192
|
+
).run(now, recipientSessionId);
|
|
193
|
+
if (result.changes > 0) _emitInboxEvent({ recipientSessionId, action: 'mark_all_read' });
|
|
194
|
+
return { wrote: result.changes > 0 };
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Delete an inbox entry by ID.
|
|
199
|
+
function deleteInboxEntry(id) {
|
|
200
|
+
let db;
|
|
201
|
+
try { db = getDb(); }
|
|
202
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
203
|
+
|
|
204
|
+
return withTransaction(db, () => {
|
|
205
|
+
const result = db.prepare('DELETE FROM inbox_entries WHERE id = ?').run(id);
|
|
206
|
+
if (result.changes > 0) _emitInboxEvent({ id, action: 'delete' });
|
|
207
|
+
return { wrote: result.changes > 0 };
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Delete multiple inbox entries by ID.
|
|
212
|
+
function deleteInboxEntries(entryIds) {
|
|
213
|
+
if (!Array.isArray(entryIds) || !entryIds.length) return { wrote: false };
|
|
214
|
+
|
|
215
|
+
let db;
|
|
216
|
+
try { db = getDb(); }
|
|
217
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
218
|
+
|
|
219
|
+
return withTransaction(db, () => {
|
|
220
|
+
const placeholders = entryIds.map(() => '?').join(',');
|
|
221
|
+
const query = `DELETE FROM inbox_entries WHERE id IN (${placeholders})`;
|
|
222
|
+
const result = db.prepare(query).run(...entryIds);
|
|
223
|
+
if (result.changes > 0) _emitInboxEvent({ entryIds, action: 'delete_batch' });
|
|
224
|
+
return { wrote: result.changes > 0 };
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Delete all unread entries older than a given timestamp (in milliseconds).
|
|
229
|
+
// Used for cleanup/archival.
|
|
230
|
+
function deleteInboxEntriesOlderThan(timestampMs, { unreadOnly = false } = {}) {
|
|
231
|
+
let db;
|
|
232
|
+
try { db = getDb(); }
|
|
233
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
234
|
+
|
|
235
|
+
return withTransaction(db, () => {
|
|
236
|
+
let query = 'DELETE FROM inbox_entries WHERE sent_at < ?';
|
|
237
|
+
const params = [timestampMs];
|
|
238
|
+
if (unreadOnly) {
|
|
239
|
+
query += ' AND unread = 1';
|
|
240
|
+
}
|
|
241
|
+
const result = db.prepare(query).run(...params);
|
|
242
|
+
if (result.changes > 0) _emitInboxEvent({ timestampMs, unreadOnly, action: 'delete_older_than' });
|
|
243
|
+
return { wrote: result.changes > 0, deletedCount: result.changes };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Retrieve inbox entries grouped by interaction_id.
|
|
248
|
+
function getInboxByInteraction(interactionId) {
|
|
249
|
+
let db;
|
|
250
|
+
try { db = getDb(); }
|
|
251
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
252
|
+
return db.prepare('SELECT * FROM inbox_entries WHERE interaction_id = ? ORDER BY sent_at ASC')
|
|
253
|
+
.all(interactionId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Retrieve all unread entries for all recipients.
|
|
257
|
+
function getAllUnreadInboxEntries() {
|
|
258
|
+
let db;
|
|
259
|
+
try { db = getDb(); }
|
|
260
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
261
|
+
return db.prepare('SELECT * FROM inbox_entries WHERE unread = 1 ORDER BY sent_at DESC').all();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Update the notified_at timestamp for an entry (marks when a notification was sent).
|
|
265
|
+
function markInboxEntryNotified(id) {
|
|
266
|
+
let db;
|
|
267
|
+
try { db = getDb(); }
|
|
268
|
+
catch (e) { throw new Error(`inbox-store: SQLite unavailable (${e.message})`); }
|
|
269
|
+
|
|
270
|
+
return withTransaction(db, () => {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
const result = db.prepare('UPDATE inbox_entries SET notified_at = ? WHERE id = ?').run(now, id);
|
|
273
|
+
if (result.changes > 0) _emitInboxEvent({ id, action: 'mark_notified' });
|
|
274
|
+
return { wrote: result.changes > 0 };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
// Read operations
|
|
280
|
+
getInboxUnreadCount,
|
|
281
|
+
getInboxForSession,
|
|
282
|
+
getInboxEntryById,
|
|
283
|
+
getInboxByInteraction,
|
|
284
|
+
getAllUnreadInboxEntries,
|
|
285
|
+
|
|
286
|
+
// Write operations
|
|
287
|
+
createInboxEntry,
|
|
288
|
+
markInboxEntriesAsRead,
|
|
289
|
+
markInboxEntriesAsUnread,
|
|
290
|
+
markAllInboxAsRead,
|
|
291
|
+
markInboxEntryNotified,
|
|
292
|
+
deleteInboxEntry,
|
|
293
|
+
deleteInboxEntries,
|
|
294
|
+
deleteInboxEntriesOlderThan,
|
|
295
|
+
};
|
package/engine/shared.js
CHANGED
|
@@ -3485,25 +3485,8 @@ const ENGINE_DEFAULTS = {
|
|
|
3485
3485
|
claudePreApproveWorkspaceMcps: true,
|
|
3486
3486
|
copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
|
|
3487
3487
|
copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
|
|
3488
|
-
ccUseWorkerPool:
|
|
3488
|
+
ccUseWorkerPool: true, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. On by default (PR #2492 cold-spawn measurements; matches engine/features.js's `ccUseWorkerPool` registry default) — opt out via `engine.ccUseWorkerPool: false`. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
|
|
3489
3489
|
agentUseWorkerPool: false, // P-9b3d5f61: mirrors ccUseWorkerPool's shape but for the fleet agent-dispatch path (engine/agent-worker-pool.js). Opt-in, default OFF — see resolveAgentUseWorkerPool in engine/shared.js for the full priority chain and rationale.
|
|
3490
|
-
// PR #321 — "kill auth-popup storm". Copilot spawns and authenticates EVERY
|
|
3491
|
-
// MCP server in `$COPILOT_HOME/mcp-config.json` on every process start, even
|
|
3492
|
-
// when the corresponding tool is hidden from the model via
|
|
3493
|
-
// `--disable-mcp-server`. If left unfiltered, every auth-requiring user MCP
|
|
3494
|
-
// (azure-kusto/azmcp, DevBox, workiq, loop, teams, …) boots and pops a
|
|
3495
|
-
// Microsoft sign-in window on EVERY agent spawn — at minions' continuous
|
|
3496
|
-
// spawn cadence that is an infinite popup storm.
|
|
3497
|
-
//
|
|
3498
|
-
// The engine therefore points agents at a private COPILOT_HOME
|
|
3499
|
-
// (`shared.resolveAgentCopilotHome`) whose mcp-config is a copy of the operator's
|
|
3500
|
-
// `~/.copilot/mcp-config.json` MINUS the names in this list (see engine.js
|
|
3501
|
-
// `_applyAgentCopilotHome`). Default [] = inherit ALL of the operator's MCP
|
|
3502
|
-
// servers for agents (non-breaking). Add a name to disable it for agents only;
|
|
3503
|
-
// the operator's interactive `~/.copilot` is never touched. Per-agent override
|
|
3504
|
-
// `agent.copilotAgentDisabledMcpServers`. Resolved through
|
|
3505
|
-
// `shared.resolveCopilotAgentDisabledMcpServers(agent, engine)`.
|
|
3506
|
-
copilotAgentDisabledMcpServers: [],
|
|
3507
3490
|
maxBudgetUsd: undefined, // fleet USD ceiling for --max-budget-usd (per-agent override: agents.<id>.maxBudgetUsd). Honors 0 via ?? so a literal cap of $0 works
|
|
3508
3491
|
disableModelDiscovery: false, // skip runtime.listModels() REST calls fleet-wide (settings UI falls back to free-text)
|
|
3509
3492
|
// Phase 8 (qa-runs.json + qa-sessions.json → SQL). When true, every QA
|
|
@@ -3605,7 +3588,7 @@ const ENGINE_DEFAULTS = {
|
|
|
3605
3588
|
agentMemorySummaryEnabled: false, // opt-in: when true, eviction batches go through an LLM-compressed summary before being dropped. Default off to mirror the conservative gating on the existing reconcile pass (LLM cost + test stability). Operators flip via engine.agentMemorySummaryEnabled.
|
|
3606
3589
|
agentMemorySummaryThreshold: 30, // batch window: when summary is enabled and a prune evicts entries, fold at least this many oldest sections into one summary. Means "summary every ~30 entries" in steady state (the original PRD intent).
|
|
3607
3590
|
agentMemorySummaryDays: 30, // age trigger: when the oldest section is older than this and >= agentMemorySummaryThreshold entries exist, summarize the oldest window even if the file is under the entry cap.
|
|
3608
|
-
memoryRetrievalShadowMode:
|
|
3591
|
+
memoryRetrievalShadowMode: false, // inject bounded FTS5-selected memory by default; true computes telemetry while preserving legacy prompt injection
|
|
3609
3592
|
memoryRetrievalTopK: 8, // maximum structured memory records injected into one dispatch
|
|
3610
3593
|
memoryRetrievalMaxBytes: 8 * 1024, // hard byte budget for the Relevant Memory prompt appendix
|
|
3611
3594
|
memoryRetrievalCandidateLimit: 50, // bounded FTS5 candidate pool before deterministic re-ranking
|
|
@@ -3863,7 +3846,7 @@ function resolveCcUseWorkerPool(engine) {
|
|
|
3863
3846
|
if (engine && (engine.ccUseWorkerPool === true || engine.ccUseWorkerPool === false)) {
|
|
3864
3847
|
return engine.ccUseWorkerPool;
|
|
3865
3848
|
}
|
|
3866
|
-
return
|
|
3849
|
+
return ENGINE_DEFAULTS.ccUseWorkerPool;
|
|
3867
3850
|
}
|
|
3868
3851
|
|
|
3869
3852
|
/**
|
|
@@ -3988,39 +3971,6 @@ function resolveAgentBareMode(agent, engine) {
|
|
|
3988
3971
|
return false;
|
|
3989
3972
|
}
|
|
3990
3973
|
|
|
3991
|
-
// P-mcp-storm — Resolve the list of MCP server names to disable for spawned
|
|
3992
|
-
// Copilot agents. The engine FILTERS these out of the agent's seeded
|
|
3993
|
-
// COPILOT_HOME mcp-config (engine.js `_applyAgentCopilotHome`) — a server absent
|
|
3994
|
-
// from the config can't be spawned, which is the only effective disable
|
|
3995
|
-
// (`--disable-mcp-server` does not stop the process). Chain: per-agent
|
|
3996
|
-
// `agent.copilotAgentDisabledMcpServers` → `engine.copilotAgentDisabledMcpServers`
|
|
3997
|
-
// → `[]` (inherit all). Tolerant of either an array OR a comma/whitespace-
|
|
3998
|
-
// separated string (Settings UI / `MINIONS_*` env deliver strings). Always
|
|
3999
|
-
// returns a deduped array of trimmed, non-empty strings. See ENGINE_DEFAULTS
|
|
4000
|
-
// for the full rationale.
|
|
4001
|
-
function _normalizeMcpServerList(v) {
|
|
4002
|
-
if (v == null) return null;
|
|
4003
|
-
const raw = Array.isArray(v) ? v : String(v).split(/[\s,]+/);
|
|
4004
|
-
const out = [];
|
|
4005
|
-
const seen = new Set();
|
|
4006
|
-
for (const item of raw) {
|
|
4007
|
-
const name = String(item == null ? '' : item).trim();
|
|
4008
|
-
if (!name || seen.has(name)) continue;
|
|
4009
|
-
seen.add(name);
|
|
4010
|
-
out.push(name);
|
|
4011
|
-
}
|
|
4012
|
-
return out;
|
|
4013
|
-
}
|
|
4014
|
-
|
|
4015
|
-
function resolveCopilotAgentDisabledMcpServers(agent, engine) {
|
|
4016
|
-
const a = agent ? _normalizeMcpServerList(agent.copilotAgentDisabledMcpServers) : null;
|
|
4017
|
-
if (a) return a;
|
|
4018
|
-
const e = engine ? _normalizeMcpServerList(engine.copilotAgentDisabledMcpServers) : null;
|
|
4019
|
-
if (e) return e;
|
|
4020
|
-
return [];
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
3974
|
// ─── Legacy ccModel → defaultModel Migration ─────────────────────────────────
|
|
4025
3975
|
//
|
|
4026
3976
|
// Pre-P-3b8e5f1d, `engine.ccModel` was the single fleet-wide model knob (it
|
|
@@ -6051,91 +6001,6 @@ function resolveAgentTempBaseDir(project, engine, minionsDir) {
|
|
|
6051
6001
|
return path.join(anchor, '.agent-temp');
|
|
6052
6002
|
}
|
|
6053
6003
|
|
|
6054
|
-
/**
|
|
6055
|
-
* Resolve the stable, repo-external COPILOT_HOME used to isolate spawned
|
|
6056
|
-
* Copilot agents from the operator's interactive `~/.copilot` MCP servers
|
|
6057
|
-
* (W-copilot-auth-popup-storm / PR #321 — "give copilot agents an MCP-free
|
|
6058
|
-
* COPILOT_HOME (kill auth-popup storm)"). Copilot spawns and authenticates
|
|
6059
|
-
* EVERY server in `$COPILOT_HOME/mcp-config.json` on every process start;
|
|
6060
|
-
* `--disable-mcp-server <name>` only hides that server's tools from the model,
|
|
6061
|
-
* it does NOT stop the server process from launching (verified live: agents
|
|
6062
|
-
* spawned azmcp.exe/workiq.exe despite those servers being in
|
|
6063
|
-
* `copilotAgentDisabledMcpServers`). The only effective lever is to point
|
|
6064
|
-
* agents at a COPILOT_HOME whose mcp-config has NO disabled-list servers — see
|
|
6065
|
-
* `ensureAgentCopilotHome` below, which the engine seeds at boot and re-stamps
|
|
6066
|
-
* per dispatch (`_applyAgentCopilotHome` in engine.js).
|
|
6067
|
-
*
|
|
6068
|
-
* `COPILOT_HOME` overrides `$HOME/.copilot`; copilot auth is unaffected (it
|
|
6069
|
-
* uses GH_TOKEN / the OS credential store, not files under the home), and the
|
|
6070
|
-
* operator's real `~/.copilot` — i.e. their interactive copilot — is
|
|
6071
|
-
* completely untouched.
|
|
6072
|
-
*
|
|
6073
|
-
* Placed OUTSIDE the repo so concurrent git branch switches in the working
|
|
6074
|
-
* tree can never delete or dirty it, on the MINIONS_DIR volume (copilot's
|
|
6075
|
-
* session-store can grow to GBs, so keep it off a possibly-full system
|
|
6076
|
-
* drive). Stable path so copilot session resume (`--resume`) stays
|
|
6077
|
-
* consistent across spawns. Falls back to the user home when no minionsDir
|
|
6078
|
-
* is given.
|
|
6079
|
-
*
|
|
6080
|
-
* Base selection is cross-platform (W-copilot-home-posix-root): on Windows
|
|
6081
|
-
* the MINIONS_DIR drive root (`C:\`, `D:\`, or a UNC share root) is
|
|
6082
|
-
* user-writable, so we anchor there. On POSIX the filesystem root (`/`) is
|
|
6083
|
-
* NOT user-writable — a home placed there can never be created (`mkdir`
|
|
6084
|
-
* EACCES), `ensureAgentCopilotHome` fail-opens and returns the uncreatable
|
|
6085
|
-
* path anyway, and every spawned `copilot --acp` / copilot agent then
|
|
6086
|
-
* inherits a `COPILOT_HOME` it can't use and exits code 1 silently. So when
|
|
6087
|
-
* the drive root is the bare POSIX root we fall back to the user home —
|
|
6088
|
-
* repo-external, writable, and on the user's own volume.
|
|
6089
|
-
*/
|
|
6090
|
-
function resolveAgentCopilotHome(minionsDir) {
|
|
6091
|
-
let base;
|
|
6092
|
-
if (minionsDir) {
|
|
6093
|
-
const root = path.parse(path.resolve(String(minionsDir))).root;
|
|
6094
|
-
// The POSIX filesystem root ('/') is not user-writable; Windows drive/UNC
|
|
6095
|
-
// roots are. Anchor at the user home in the unwritable-root case.
|
|
6096
|
-
base = (root === '/' || root === path.sep) ? os.homedir() : root;
|
|
6097
|
-
} else {
|
|
6098
|
-
base = os.homedir();
|
|
6099
|
-
}
|
|
6100
|
-
return path.join(base, '.minions-agent-copilot-home');
|
|
6101
|
-
}
|
|
6102
|
-
|
|
6103
|
-
// Seed the agent COPILOT_HOME's mcp-config (copy of `~/.copilot/mcp-config.json`
|
|
6104
|
-
// MINUS `engine.copilotAgentDisabledMcpServers`) and return the home path. This
|
|
6105
|
-
// is the source of truth for "what MCP servers may an agent-dispatch copilot
|
|
6106
|
-
// load." It is applied to the engine-process copilot spawn paths ONLY — agent
|
|
6107
|
-
// dispatches (`_applyAgentCopilotHome`) and the engine process's own internal
|
|
6108
|
-
// `llm.callLLM` calls — by setting `process.env.COPILOT_HOME` at engine boot
|
|
6109
|
-
// and re-stamping it per dispatch, so those copilot CHILDREN inherit the
|
|
6110
|
-
// filtered config. copilot SPAWNS every server in its config and
|
|
6111
|
-
// authenticates it (`--disable-mcp-server` only hides tools), so any
|
|
6112
|
-
// auth-requiring server pops a Microsoft window per spawn — filtering the
|
|
6113
|
-
// config is the only effective control. Idempotent: writes only when the
|
|
6114
|
-
// content changes (safe under concurrent callers). The operator's real
|
|
6115
|
-
// `~/.copilot` (interactive copilot, and now also Command Center / doc-chat /
|
|
6116
|
-
// the CC worker pool, per W-mre75l6x00024f49) is never touched. Fail-open.
|
|
6117
|
-
function ensureAgentCopilotHome(minionsDir, engine) {
|
|
6118
|
-
const home = resolveAgentCopilotHome(minionsDir);
|
|
6119
|
-
try {
|
|
6120
|
-
fs.mkdirSync(home, { recursive: true });
|
|
6121
|
-
const disabled = new Set(resolveCopilotAgentDisabledMcpServers(null, engine));
|
|
6122
|
-
let servers = {};
|
|
6123
|
-
try {
|
|
6124
|
-
const userCfgPath = path.join(os.homedir(), '.copilot', 'mcp-config.json');
|
|
6125
|
-
const userCfg = JSON.parse(fs.readFileSync(userCfgPath, 'utf8').replace(/^/, ''));
|
|
6126
|
-
for (const [name, def] of Object.entries(userCfg.mcpServers || {})) {
|
|
6127
|
-
if (!disabled.has(name)) servers[name] = def;
|
|
6128
|
-
}
|
|
6129
|
-
} catch { servers = {}; /* no/unreadable user config → no MCPs */ }
|
|
6130
|
-
const desired = JSON.stringify({ mcpServers: servers }, null, 2);
|
|
6131
|
-
const cfgFile = path.join(home, 'mcp-config.json');
|
|
6132
|
-
let current = null;
|
|
6133
|
-
try { current = fs.readFileSync(cfgFile, 'utf8'); } catch {}
|
|
6134
|
-
if (current !== desired) fs.writeFileSync(cfgFile, desired);
|
|
6135
|
-
} catch { /* fail-open: leave whatever home/config exists */ }
|
|
6136
|
-
return home;
|
|
6137
|
-
}
|
|
6138
|
-
|
|
6139
6004
|
// ── Spawn cwd vs worktree placement (W-mp73x32w000l143d) ──────────────────────
|
|
6140
6005
|
// Work types that don't need a git worktree — they read repo state but don't
|
|
6141
6006
|
// produce code changes. Centralized here so engine.js spawnAgent and any
|
|
@@ -9056,7 +8921,6 @@ module.exports = {
|
|
|
9056
8921
|
resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
|
|
9057
8922
|
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
|
|
9058
8923
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
9059
|
-
resolveCopilotAgentDisabledMcpServers,
|
|
9060
8924
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
9061
8925
|
runtimeConfigWarnings,
|
|
9062
8926
|
projectWorkSourceWarnings,
|
|
@@ -9162,9 +9026,7 @@ module.exports = {
|
|
|
9162
9026
|
parseWorktreePorcelain,
|
|
9163
9027
|
assertWorktreeOutsideProject,
|
|
9164
9028
|
resolveProjectRootDir,
|
|
9165
|
-
resolveAgentCopilotHome,
|
|
9166
9029
|
resolveAgentTempBaseDir,
|
|
9167
|
-
ensureAgentCopilotHome,
|
|
9168
9030
|
resolveSpawnPaths,
|
|
9169
9031
|
validateWorkItemWorkdir,
|
|
9170
9032
|
applyWorkdir,
|
package/engine.js
CHANGED
|
@@ -2100,32 +2100,6 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
|
|
|
2100
2100
|
}
|
|
2101
2101
|
}
|
|
2102
2102
|
|
|
2103
|
-
// Seed a spawned agent's COPILOT_HOME mcp-config as a copy of the operator's
|
|
2104
|
-
// `~/.copilot/mcp-config.json` MINUS the servers in
|
|
2105
|
-
// `engine.copilotAgentDisabledMcpServers`, then point COPILOT_HOME at it.
|
|
2106
|
-
//
|
|
2107
|
-
// Why config-filtering and not `--disable-mcp-server`: copilot SPAWNS every
|
|
2108
|
-
// server in its config on startup and authenticates it; `--disable-mcp-server
|
|
2109
|
-
// <name>` only hides that server's TOOLS from the model, it does NOT stop the
|
|
2110
|
-
// server process (PR #321 — verified live: agents spawned azmcp.exe/workiq.exe
|
|
2111
|
-
// despite those servers being in `copilotAgentDisabledMcpServers`).
|
|
2112
|
-
//
|
|
2113
|
-
// Default disabled list `[]` => agents inherit ALL of the operator's MCP servers
|
|
2114
|
-
// (non-breaking; matches interactive copilot). Add a name to disable it for
|
|
2115
|
-
// agents. COPILOT_HOME is copilot-specific; claude/codex ignore it, so this is
|
|
2116
|
-
// set unconditionally (no runtime.name branching, per the adapter contract).
|
|
2117
|
-
// Copilot auth is unaffected (GH_TOKEN / OS credential store); the operator's
|
|
2118
|
-
// real ~/.copilot is untouched. Best-effort/fail-open: any failure leaves the
|
|
2119
|
-
// inherited COPILOT_HOME so a hiccup never blocks a dispatch.
|
|
2120
|
-
function _applyAgentCopilotHome(childEnv) {
|
|
2121
|
-
// Re-seed (picks up Settings changes to the disabled list) + stamp the env.
|
|
2122
|
-
// process.env.COPILOT_HOME is also set at engine boot (see below) so the
|
|
2123
|
-
// child would inherit it anyway; this is belt-and-suspenders + keeps the
|
|
2124
|
-
// seed fresh per dispatch. See shared.ensureAgentCopilotHome.
|
|
2125
|
-
try { childEnv.COPILOT_HOME = shared.ensureAgentCopilotHome(MINIONS_DIR, getConfig()?.engine); }
|
|
2126
|
-
catch { /* leave inherited COPILOT_HOME untouched */ }
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
2103
|
// Point a spawned agent's TEMP/TMP/TMPDIR at a scratch dir on the worktree
|
|
2130
2104
|
// volume instead of the (often full) system drive — see
|
|
2131
2105
|
// shared.resolveAgentTempBaseDir. copilot/node/git + JVM-based MCP servers write
|
|
@@ -4598,16 +4572,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4598
4572
|
// random localhost port (blank window). Belt-and-suspenders with dashboard.js's
|
|
4599
4573
|
// stdout.isTTY gate.
|
|
4600
4574
|
childEnv.MINIONS_NO_AUTO_OPEN = '1';
|
|
4601
|
-
// MCP-free copilot config so agents never pop a per-spawn Microsoft auth
|
|
4602
|
-
// window — see _applyAgentCopilotHome / shared.resolveAgentCopilotHome.
|
|
4603
|
-
// P-1d8f0b93 — SKIPPED for pooled dispatches: the pooled ACP worker is a
|
|
4604
|
-
// long-lived process shared across many dispatches, so it always uses the
|
|
4605
|
-
// operator's REAL COPILOT_HOME (this is the isolation-removal half of the
|
|
4606
|
-
// pooling feature — MCP-auth-popup risk becomes bounded by pool size
|
|
4607
|
-
// instead of eliminated per-dispatch; documented tradeoff in the plan).
|
|
4608
|
-
if (!useAgentPool) {
|
|
4609
|
-
_applyAgentCopilotHome(childEnv);
|
|
4610
|
-
}
|
|
4611
4575
|
// Keep agent scratch off a (possibly full) system drive — anchors TEMP/TMP to
|
|
4612
4576
|
// the worktree volume. See _applyAgentTempEnv / shared.resolveAgentTempBaseDir.
|
|
4613
4577
|
_applyAgentTempEnv(childEnv, project);
|
|
@@ -5059,8 +5023,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5059
5023
|
// W-mqef-dashboard-tty — same browser-popup suppression on steering resume
|
|
5060
5024
|
// (see the initial spawn site). Agents must never auto-open a browser.
|
|
5061
5025
|
childEnv.MINIONS_NO_AUTO_OPEN = '1';
|
|
5062
|
-
// Same MCP-free copilot home on steering resume (see the initial spawn site).
|
|
5063
|
-
_applyAgentCopilotHome(childEnv);
|
|
5064
5026
|
// Same agent-scratch redirect on steering resume.
|
|
5065
5027
|
_applyAgentTempEnv(childEnv, project);
|
|
5066
5028
|
if (getRepoHost(project) === 'ado') {
|
|
@@ -11685,17 +11647,6 @@ function ensureGitHooksInstalled() {
|
|
|
11685
11647
|
|
|
11686
11648
|
if (require.main === module) {
|
|
11687
11649
|
try { ensureGitHooksInstalled(); } catch { /* best-effort */ }
|
|
11688
|
-
// Every copilot this process spawns — agent dispatches AND internal
|
|
11689
|
-
// `llm.callLLM` calls (consolidation, classification, meetings, …) — inherits
|
|
11690
|
-
// this process's COPILOT_HOME, so point it at the seeded, MCP-filtered home.
|
|
11691
|
-
// Without this, those `direct:true` copilot calls load the operator's full
|
|
11692
|
-
// `~/.copilot` stack and pop a Microsoft auth window per call. This isolation
|
|
11693
|
-
// is engine-process-only: the dashboard process deliberately does NOT set
|
|
11694
|
-
// COPILOT_HOME at its own boot, so Command Center / doc-chat / the CC worker
|
|
11695
|
-
// pool inherit the operator's real `~/.copilot` home instead (reverted in
|
|
11696
|
-
// W-mre75l6x00024f49; see dashboard.js boot for rationale).
|
|
11697
|
-
// See shared.ensureAgentCopilotHome. Fail-open.
|
|
11698
|
-
try { process.env.COPILOT_HOME = shared.ensureAgentCopilotHome(MINIONS_DIR, getConfig()?.engine); } catch {}
|
|
11699
11650
|
const { handleCommand } = require('./engine/cli');
|
|
11700
11651
|
const [cmd, ...args] = process.argv.slice(2);
|
|
11701
11652
|
handleCommand(cmd, args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2375",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|