@shawnowen/comet-mcp 2.4.1 → 2.4.2
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/README.md +12 -1
- package/dist/binding-reaper.d.ts +46 -0
- package/dist/binding-reaper.js +73 -0
- package/dist/http-server.js +121 -0
- package/dist/index.js +310 -6
- package/dist/project-config.d.ts +46 -0
- package/dist/project-config.js +166 -0
- package/dist/tab-groups.d.ts +21 -1
- package/dist/tab-groups.js +184 -0
- package/dist/window-bindings.d.ts +48 -0
- package/dist/window-bindings.js +85 -0
- package/extension/background.js +38 -17
- package/extension/manifest.json +16 -1
- package/extension/perplexity-capability-manifest.json +1181 -0
- package/extension/perplexity-capability-manifest.schema.json +142 -0
- package/extension/session-logic.js +696 -25
- package/extension/session-manager.html +13 -1
- package/extension/sidepanel.css +21 -6
- package/extension/sidepanel.js +598 -68
- package/package.json +1 -1
- package/dist/discovery/capability-entry.d.ts +0 -215
- package/dist/discovery/capability-entry.js +0 -13
- package/dist/discovery/description-template.d.ts +0 -40
- package/dist/discovery/description-template.js +0 -61
- package/dist/discovery/golden-queries.fixture.d.ts +0 -22
- package/dist/discovery/golden-queries.fixture.js +0 -137
- package/dist/discovery/mcp-source.d.ts +0 -38
- package/dist/discovery/mcp-source.js +0 -70
- package/dist/discovery/metadata-completeness.d.ts +0 -48
- package/dist/discovery/metadata-completeness.js +0 -83
- package/dist/discovery/registry.d.ts +0 -35
- package/dist/discovery/registry.js +0 -35
- package/dist/discovery/safety.d.ts +0 -44
- package/dist/discovery/safety.js +0 -59
- package/dist/discovery/schema-validator.d.ts +0 -36
- package/dist/discovery/schema-validator.js +0 -257
- package/dist/discovery/source-error.d.ts +0 -47
- package/dist/discovery/source-error.js +0 -95
- package/dist/discovery/tool-meta.d.ts +0 -41
- package/dist/discovery/tool-meta.js +0 -229
- package/dist/discovery/virtual-tools.d.ts +0 -20
- package/dist/discovery/virtual-tools.js +0 -69
- package/dist/task-thread-aggregator.d.ts +0 -34
- package/dist/task-thread-aggregator.js +0 -480
- package/dist/task-thread-canonical.d.ts +0 -142
- package/dist/task-thread-canonical.js +0 -116
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ You: "Log into my GitHub and check my notifications"
|
|
|
62
62
|
Claude: [Comet handles the login flow and navigation]
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
## Tools (
|
|
65
|
+
## Tools (26)
|
|
66
66
|
|
|
67
67
|
### Direct Interaction (2) — No AI, pure CDP
|
|
68
68
|
|
|
@@ -112,6 +112,17 @@ Lifecycle tools are binding-aware: `start` attaches the run ID to the active or
|
|
|
112
112
|
| `comet_delegate` | High-level task dispatch with direct Codex binding creation/reuse, URL, and lifecycle setup |
|
|
113
113
|
| `comet_observe` | Passively observe browser state, binding owners, and stale/conflict/unbound windows without disrupting active agents |
|
|
114
114
|
|
|
115
|
+
### Parity and Workflow Tools (6)
|
|
116
|
+
|
|
117
|
+
| Tool | Description |
|
|
118
|
+
|------|-------------|
|
|
119
|
+
| `comet_pdf` | Generate PDF from current page or URL |
|
|
120
|
+
| `comet_scrape` | Extract structured data: text, tables, JSON-LD, lists, attributes, multi-element content |
|
|
121
|
+
| `comet_network` | Capture network traffic, block URL patterns, intercept and mock API responses |
|
|
122
|
+
| `comet_monitor` | Monitor current page or URL for content changes with baselines, diffs, screenshots, and notifications |
|
|
123
|
+
| `comet_automate` | Multi-step browser workflows with assertions and variables |
|
|
124
|
+
| `comet_domain` | Domain playbooks for QBO, Mercury, GitHub, Google, and SALT |
|
|
125
|
+
|
|
115
126
|
## Codex Window Bindings
|
|
116
127
|
|
|
117
128
|
Comet MCP routes multi-agent browser work through Codex window bindings. Each binding records the owning Codex session, repo/worktree, branch, `windowId`, `tabGroupId`, `targetId`, sidecar context key, lifecycle run IDs, and status.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window-binding TTL reaper (Spec 084).
|
|
3
|
+
*
|
|
4
|
+
* Schedules periodic reconciliation of persisted window bindings against the live
|
|
5
|
+
* CDP window set and prunes records whose window has been missing beyond a
|
|
6
|
+
* configurable TTL. Dependency-injected so it is deterministically unit-testable
|
|
7
|
+
* (fake live-window source, fake clock, fake archive) and free of CDP/timer setup.
|
|
8
|
+
*
|
|
9
|
+
* Wired into the long-lived bridge process (http-server.ts) after `server.listen`.
|
|
10
|
+
*/
|
|
11
|
+
import { type CodexWindowBinding, type ReapCycleResult, type ReapOptions } from "./window-bindings.js";
|
|
12
|
+
export interface ReaperConfig {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
ttlMs: number;
|
|
15
|
+
intervalMs: number;
|
|
16
|
+
}
|
|
17
|
+
/** Minimal store surface the reaper depends on (injectable for tests). */
|
|
18
|
+
export interface ReapableStore {
|
|
19
|
+
reapExpiredBindings(opts: ReapOptions): Promise<ReapCycleResult>;
|
|
20
|
+
}
|
|
21
|
+
export interface ReaperDeps {
|
|
22
|
+
/** Resolve the distinct live window ids. Return `null` when unknown (CDP down) → no-op cycle. */
|
|
23
|
+
getLiveWindowIds: () => Promise<number[] | null>;
|
|
24
|
+
/** Archive a binding (recovery snapshot + alert) before deletion. */
|
|
25
|
+
archive?: (binding: CodexWindowBinding) => Promise<void>;
|
|
26
|
+
/** Injectable clock. Defaults to Date.now. */
|
|
27
|
+
now?: () => number;
|
|
28
|
+
/** Structured logger. Defaults to console.log. */
|
|
29
|
+
log?: (msg: string) => void;
|
|
30
|
+
/** Binding store. Defaults to the shared `windowBindingStore` singleton. */
|
|
31
|
+
store?: ReapableStore;
|
|
32
|
+
}
|
|
33
|
+
/** Parse reaper configuration from the environment with safe defaults. */
|
|
34
|
+
export declare function readReaperConfig(env?: NodeJS.ProcessEnv): ReaperConfig;
|
|
35
|
+
/**
|
|
36
|
+
* Run a single reap cycle. Returns the cycle result, or `null` when the live
|
|
37
|
+
* window set could not be determined (in which case nothing is mutated).
|
|
38
|
+
*/
|
|
39
|
+
export declare function runReapCycle(cfg: ReaperConfig, deps: ReaperDeps): Promise<ReapCycleResult | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Start the reaper: one cycle on startup, then on the configured interval.
|
|
42
|
+
* Returns a `stop()` that clears the timer. When disabled, no cycle runs and no
|
|
43
|
+
* timer is scheduled, and `stop()` is a no-op.
|
|
44
|
+
*/
|
|
45
|
+
export declare function startBindingReaper(cfg: ReaperConfig, deps: ReaperDeps): () => void;
|
|
46
|
+
//# sourceMappingURL=binding-reaper.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window-binding TTL reaper (Spec 084).
|
|
3
|
+
*
|
|
4
|
+
* Schedules periodic reconciliation of persisted window bindings against the live
|
|
5
|
+
* CDP window set and prunes records whose window has been missing beyond a
|
|
6
|
+
* configurable TTL. Dependency-injected so it is deterministically unit-testable
|
|
7
|
+
* (fake live-window source, fake clock, fake archive) and free of CDP/timer setup.
|
|
8
|
+
*
|
|
9
|
+
* Wired into the long-lived bridge process (http-server.ts) after `server.listen`.
|
|
10
|
+
*/
|
|
11
|
+
import { windowBindingStore, } from "./window-bindings.js";
|
|
12
|
+
const DEFAULT_TTL_HOURS = 6;
|
|
13
|
+
const DEFAULT_INTERVAL_SEC = 1800;
|
|
14
|
+
function positiveNumber(raw, fallback) {
|
|
15
|
+
const n = Number(raw);
|
|
16
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
17
|
+
}
|
|
18
|
+
/** Parse reaper configuration from the environment with safe defaults. */
|
|
19
|
+
export function readReaperConfig(env = process.env) {
|
|
20
|
+
const enabled = (env.COMET_BINDING_REAP_ENABLED ?? "1") !== "0";
|
|
21
|
+
const ttlHours = positiveNumber(env.COMET_BINDING_REAP_TTL_HOURS, DEFAULT_TTL_HOURS);
|
|
22
|
+
const intervalSec = positiveNumber(env.COMET_BINDING_REAP_INTERVAL_SEC, DEFAULT_INTERVAL_SEC);
|
|
23
|
+
return {
|
|
24
|
+
enabled,
|
|
25
|
+
ttlMs: ttlHours * 3_600_000,
|
|
26
|
+
intervalMs: intervalSec * 1000,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Run a single reap cycle. Returns the cycle result, or `null` when the live
|
|
31
|
+
* window set could not be determined (in which case nothing is mutated).
|
|
32
|
+
*/
|
|
33
|
+
export async function runReapCycle(cfg, deps) {
|
|
34
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
35
|
+
const liveIds = await deps.getLiveWindowIds();
|
|
36
|
+
if (liveIds == null) {
|
|
37
|
+
log("[binding-reaper] live window set unknown — cycle skipped (no mutation)");
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const store = deps.store ?? windowBindingStore;
|
|
41
|
+
const result = await store.reapExpiredBindings({
|
|
42
|
+
liveWindowIds: liveIds,
|
|
43
|
+
ttlMs: cfg.ttlMs,
|
|
44
|
+
now: deps.now ? deps.now() : undefined,
|
|
45
|
+
archive: deps.archive,
|
|
46
|
+
});
|
|
47
|
+
log(`[binding-reaper] evaluated=${result.evaluated} newlyMissing=${result.newlyMissing} ` +
|
|
48
|
+
`retainedLive=${result.retainedLive} reaped=${result.reaped} skippedOwned=${result.skippedOwned}`);
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Start the reaper: one cycle on startup, then on the configured interval.
|
|
53
|
+
* Returns a `stop()` that clears the timer. When disabled, no cycle runs and no
|
|
54
|
+
* timer is scheduled, and `stop()` is a no-op.
|
|
55
|
+
*/
|
|
56
|
+
export function startBindingReaper(cfg, deps) {
|
|
57
|
+
const log = deps.log ?? ((m) => console.log(m));
|
|
58
|
+
if (!cfg.enabled) {
|
|
59
|
+
log("[binding-reaper] disabled (COMET_BINDING_REAP_ENABLED=0)");
|
|
60
|
+
return () => { };
|
|
61
|
+
}
|
|
62
|
+
log(`[binding-reaper] enabled — ttl=${Math.round(cfg.ttlMs / 3_600_000)}h interval=${Math.round(cfg.intervalMs / 1000)}s`);
|
|
63
|
+
// Startup cycle (fire-and-forget; errors are swallowed per-cycle).
|
|
64
|
+
void runReapCycle(cfg, deps).catch((err) => log(`[binding-reaper] startup cycle error: ${err instanceof Error ? err.message : err}`));
|
|
65
|
+
const timer = setInterval(() => {
|
|
66
|
+
void runReapCycle(cfg, deps).catch((err) => log(`[binding-reaper] cycle error: ${err instanceof Error ? err.message : err}`));
|
|
67
|
+
}, cfg.intervalMs);
|
|
68
|
+
// Never hold the process open solely for the reaper.
|
|
69
|
+
if (typeof timer.unref === "function")
|
|
70
|
+
timer.unref();
|
|
71
|
+
return () => clearInterval(timer);
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=binding-reaper.js.map
|
package/dist/http-server.js
CHANGED
|
@@ -12,10 +12,12 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { cometClient } from "./cdp-client.js";
|
|
13
13
|
import { cometAI } from "./comet-ai.js";
|
|
14
14
|
import { tabGroupsClient } from "./tab-groups.js";
|
|
15
|
+
import { startBindingReaper, readReaperConfig } from "./binding-reaper.js";
|
|
15
16
|
import { BoundSessionError, resolveHttpBoundSession } from "./bound-session.js";
|
|
16
17
|
import { createOrReuseDelegateBinding } from "./delegate-binding.js";
|
|
17
18
|
import { deriveCodexSessionIdentity, windowBindingStore, } from "./window-bindings.js";
|
|
18
19
|
import { readAgentRegistry, classifyAgentStatus, getHealth, getSnapshot, getStatus, getDetail, formatHealth, formatSnapshot, } from "./observer.js";
|
|
20
|
+
import { dispatchAlert } from "./alert-dispatcher.js";
|
|
19
21
|
const PORT = parseInt(process.env.COMET_HTTP_PORT || "3456", 10);
|
|
20
22
|
// Lifecycle + orchestration paths (mirrored from index.ts)
|
|
21
23
|
const CC_LIFECYCLE_URL = process.env.COMET_CC_LIFECYCLE_URL || "http://localhost:3001/command-center/api/comet/lifecycle";
|
|
@@ -134,6 +136,67 @@ async function getWindowIdForTarget(targetId) {
|
|
|
134
136
|
}
|
|
135
137
|
}
|
|
136
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the distinct live window ids for the window-binding reaper (Spec 084).
|
|
141
|
+
* Returns `null` when the live set cannot be determined (CDP unreachable, or page
|
|
142
|
+
* targets exist but none resolved) so the reaper performs a safe no-op instead of
|
|
143
|
+
* treating every binding as missing.
|
|
144
|
+
*/
|
|
145
|
+
async function getLiveWindowIds() {
|
|
146
|
+
try {
|
|
147
|
+
const resp = await fetch("http://127.0.0.1:9222/json", {
|
|
148
|
+
signal: AbortSignal.timeout(5000),
|
|
149
|
+
});
|
|
150
|
+
const targets = (await resp.json());
|
|
151
|
+
const pages = targets.filter((t) => t.type === "page" && typeof t.id === "string");
|
|
152
|
+
const ids = new Set();
|
|
153
|
+
for (const t of pages) {
|
|
154
|
+
try {
|
|
155
|
+
ids.add(await getWindowIdForTarget(t.id));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* per-target resolution failure — skip this target */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Page targets existed but none resolved → treat live set as unknown (no-op).
|
|
162
|
+
if (pages.length > 0 && ids.size === 0)
|
|
163
|
+
return null;
|
|
164
|
+
return [...ids];
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null; // CDP unreachable → unknown
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Archive a recovery snapshot of a binding before the reaper deletes it
|
|
172
|
+
* (Spec 084 FR-006). Writes a JSON record into the snapshots dir; throwing here
|
|
173
|
+
* causes the reaper to skip the delete (fail-safe).
|
|
174
|
+
*/
|
|
175
|
+
async function archiveBindingForReap(binding) {
|
|
176
|
+
const dir = join(homedir(), ".claude", "comet-browser", "snapshots");
|
|
177
|
+
mkdirSync(dir, { recursive: true });
|
|
178
|
+
const safeId = binding.bindingId.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
179
|
+
const file = join(dir, `reaped-binding-${safeId}-${Date.now()}.json`);
|
|
180
|
+
writeFileSync(file, JSON.stringify({ reapedAt: new Date().toISOString(), reason: "ttl-reaper", binding }, null, 2));
|
|
181
|
+
dispatchAlert({
|
|
182
|
+
type: "ORPHAN_REAPED",
|
|
183
|
+
message: `Window binding ${binding.bindingId} reaped by TTL reaper after recovery snapshot.`,
|
|
184
|
+
consumerId: binding.codexSessionId,
|
|
185
|
+
sessionKey: binding.sessionKey,
|
|
186
|
+
context: {
|
|
187
|
+
bindingId: binding.bindingId,
|
|
188
|
+
runIds: binding.runIds,
|
|
189
|
+
windowId: binding.windowId,
|
|
190
|
+
tabGroupId: binding.tabGroupId,
|
|
191
|
+
targetId: binding.targetId,
|
|
192
|
+
repoSlug: binding.repoSlug,
|
|
193
|
+
branchName: binding.branchName,
|
|
194
|
+
snapshotPath: file,
|
|
195
|
+
reaperReason: "ttl-reaper",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
console.log(`[binding-reaper] archived recovery snapshot -> ${file}`);
|
|
199
|
+
}
|
|
137
200
|
function writeBoundError(res, err) {
|
|
138
201
|
if (err instanceof BoundSessionError) {
|
|
139
202
|
errorJson(res, `${err.code}: ${err.message}. ${err.repairAction}`, err.status);
|
|
@@ -2190,6 +2253,13 @@ async function handleTabGroupsListTabs(res) {
|
|
|
2190
2253
|
if (result)
|
|
2191
2254
|
json(res, result);
|
|
2192
2255
|
}
|
|
2256
|
+
async function handleTabGroupsMetadataList(res) {
|
|
2257
|
+
const result = await withMutex(res, async () => {
|
|
2258
|
+
return await tabGroupsClient.listExtensionMetadata();
|
|
2259
|
+
});
|
|
2260
|
+
if (result)
|
|
2261
|
+
json(res, result);
|
|
2262
|
+
}
|
|
2193
2263
|
async function handleTabGroupsCreate(res, body) {
|
|
2194
2264
|
const result = await withMutex(res, async () => {
|
|
2195
2265
|
const tabIds = body.tabIds;
|
|
@@ -2227,6 +2297,26 @@ async function handleTabGroupsUpdate(res, body) {
|
|
|
2227
2297
|
json(res, result);
|
|
2228
2298
|
}
|
|
2229
2299
|
}
|
|
2300
|
+
async function handleTabGroupsMetadataUpdate(res, body) {
|
|
2301
|
+
const result = await withMutex(res, async () => {
|
|
2302
|
+
return await tabGroupsClient.updateExtensionMetadata({
|
|
2303
|
+
entityType: body.entityType,
|
|
2304
|
+
entityId: body.entityId,
|
|
2305
|
+
windowId: body.windowId,
|
|
2306
|
+
activeWindowId: body.activeWindowId,
|
|
2307
|
+
taskThreadId: body.taskThreadId,
|
|
2308
|
+
tabIndex: body.tabIndex,
|
|
2309
|
+
sourceFamily: body.sourceFamily,
|
|
2310
|
+
policyTier: body.policyTier,
|
|
2311
|
+
description: body.description,
|
|
2312
|
+
title: body.title,
|
|
2313
|
+
label: body.label,
|
|
2314
|
+
status: body.status,
|
|
2315
|
+
});
|
|
2316
|
+
});
|
|
2317
|
+
if (result)
|
|
2318
|
+
json(res, result);
|
|
2319
|
+
}
|
|
2230
2320
|
async function handleTabGroupsDelete(res, body) {
|
|
2231
2321
|
const result = await withMutex(res, async () => {
|
|
2232
2322
|
const groupId = body.groupId;
|
|
@@ -2329,6 +2419,9 @@ const server = createServer(async (req, res) => {
|
|
|
2329
2419
|
else if (path === "/api/tab-groups/tabs" && req.method === "GET") {
|
|
2330
2420
|
await handleTabGroupsListTabs(res);
|
|
2331
2421
|
}
|
|
2422
|
+
else if (path === "/api/tab-groups/metadata" && req.method === "GET") {
|
|
2423
|
+
await handleTabGroupsMetadataList(res);
|
|
2424
|
+
}
|
|
2332
2425
|
else if (path === "/api/tab-groups" && req.method === "POST") {
|
|
2333
2426
|
const body = await readBody(req);
|
|
2334
2427
|
await handleTabGroupsCreate(res, body);
|
|
@@ -2337,6 +2430,10 @@ const server = createServer(async (req, res) => {
|
|
|
2337
2430
|
const body = await readBody(req);
|
|
2338
2431
|
await handleTabGroupsUpdate(res, body);
|
|
2339
2432
|
}
|
|
2433
|
+
else if (path === "/api/tab-groups/metadata/update" && req.method === "POST") {
|
|
2434
|
+
const body = await readBody(req);
|
|
2435
|
+
await handleTabGroupsMetadataUpdate(res, body);
|
|
2436
|
+
}
|
|
2340
2437
|
else if (path === "/api/tab-groups/delete" && req.method === "POST") {
|
|
2341
2438
|
const body = await readBody(req);
|
|
2342
2439
|
await handleTabGroupsDelete(res, body);
|
|
@@ -2476,8 +2573,32 @@ server.listen(PORT, () => {
|
|
|
2476
2573
|
console.log(`\n --- Tab Groups ---`);
|
|
2477
2574
|
console.log(` GET /api/tab-groups - List all tab groups`);
|
|
2478
2575
|
console.log(` GET /api/tab-groups/tabs - List all tabs with group info`);
|
|
2576
|
+
console.log(` GET /api/tab-groups/metadata - List extension metadata`);
|
|
2479
2577
|
console.log(` POST /api/tab-groups - Create group {tabIds, title?, color?}`);
|
|
2480
2578
|
console.log(` POST /api/tab-groups/update - Update group {groupId, title?, color?, collapsed?}`);
|
|
2579
|
+
console.log(` POST /api/tab-groups/metadata/update - Update extension metadata`);
|
|
2481
2580
|
console.log(` POST /api/tab-groups/delete - Delete group {groupId}`);
|
|
2581
|
+
// --- Window-binding TTL reaper (Spec 084) ---
|
|
2582
|
+
// Periodically prune binding records whose Comet window has been gone past the
|
|
2583
|
+
// TTL, so the Session Manager / mirror stop accumulating "Display unknown" phantoms.
|
|
2584
|
+
const reaperConfig = readReaperConfig();
|
|
2585
|
+
const stopReaper = startBindingReaper(reaperConfig, {
|
|
2586
|
+
getLiveWindowIds,
|
|
2587
|
+
archive: archiveBindingForReap,
|
|
2588
|
+
log: (m) => console.log(m),
|
|
2589
|
+
});
|
|
2590
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
2591
|
+
process.once(sig, () => {
|
|
2592
|
+
try {
|
|
2593
|
+
stopReaper();
|
|
2594
|
+
}
|
|
2595
|
+
catch {
|
|
2596
|
+
/* ignore */
|
|
2597
|
+
}
|
|
2598
|
+
const exitCode = sig === "SIGINT" ? 130 : 143;
|
|
2599
|
+
server.close(() => process.exit(exitCode));
|
|
2600
|
+
server.closeAllConnections?.();
|
|
2601
|
+
});
|
|
2602
|
+
}
|
|
2482
2603
|
});
|
|
2483
2604
|
//# sourceMappingURL=http-server.js.map
|