gentle-pi 0.2.5 → 0.2.6
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 +23 -3
- package/extensions/skill-registry.ts +108 -19
- package/package.json +5 -3
- package/tests/skill-registry.test.ts +114 -0
package/README.md
CHANGED
|
@@ -178,16 +178,35 @@ It scans common roots such as:
|
|
|
178
178
|
|
|
179
179
|
```text
|
|
180
180
|
./skills
|
|
181
|
+
.opencode/skills
|
|
182
|
+
.claude/skills
|
|
183
|
+
.gemini/skills
|
|
184
|
+
.cursor/skills
|
|
185
|
+
.github/skills
|
|
186
|
+
.codex/skills
|
|
187
|
+
.qwen/skills
|
|
188
|
+
.kiro/skills
|
|
189
|
+
.openclaw/skills
|
|
181
190
|
.pi/skills
|
|
182
191
|
.agent/skills
|
|
183
192
|
.agents/skills
|
|
184
|
-
.
|
|
185
|
-
|
|
193
|
+
.atl/skills
|
|
194
|
+
~/.pi/agent/skills
|
|
195
|
+
~/.config/agents/skills
|
|
196
|
+
~/.agents/skills
|
|
197
|
+
~/.kimi/skills
|
|
186
198
|
~/.config/opencode/skills
|
|
199
|
+
~/.config/kilo/skills
|
|
187
200
|
~/.claude/skills
|
|
188
201
|
~/.gemini/skills
|
|
202
|
+
~/.gemini/antigravity/skills
|
|
189
203
|
~/.cursor/skills
|
|
190
204
|
~/.copilot/skills
|
|
205
|
+
~/.codex/skills
|
|
206
|
+
~/.codeium/windsurf/skills
|
|
207
|
+
~/.qwen/skills
|
|
208
|
+
~/.kiro/skills
|
|
209
|
+
~/.openclaw/skills
|
|
191
210
|
```
|
|
192
211
|
|
|
193
212
|
Behavior:
|
|
@@ -196,7 +215,7 @@ Behavior:
|
|
|
196
215
|
- the registry refreshes on session start;
|
|
197
216
|
- `/skill-registry:refresh` forces regeneration;
|
|
198
217
|
- a best-effort watcher refreshes when skill files change;
|
|
199
|
-
-
|
|
218
|
+
- `## Compact Rules` wins when present; otherwise the registry extracts compact rules from `## Hard Rules`, `## Critical Rules`, `## Critical Patterns`, `## Voice Rules`, and `## Decision Gates` using bullets, numbered lists, or simple tables.
|
|
200
219
|
|
|
201
220
|
Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony.
|
|
202
221
|
|
|
@@ -331,6 +350,7 @@ pi install .
|
|
|
331
350
|
Validate before publishing:
|
|
332
351
|
|
|
333
352
|
```bash
|
|
353
|
+
pnpm test
|
|
334
354
|
bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js
|
|
335
355
|
node --experimental-strip-types --check extensions/gentle-ai.ts
|
|
336
356
|
node --experimental-strip-types --check extensions/sdd-init.ts
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { basename, join, relative } from "node:path";
|
|
13
|
+
import { basename, join, normalize, relative, sep } from "node:path";
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
|
|
16
16
|
const REGISTRY_REL_PATH = ".atl/skill-registry.md";
|
|
@@ -20,7 +20,7 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
|
|
|
20
20
|
const EXCLUDE_PREFIXES = ["sdd-"];
|
|
21
21
|
const ATL_IGNORE_ENTRY = ".atl/";
|
|
22
22
|
const WATCH_DEBOUNCE_MS = 500;
|
|
23
|
-
const REGISTRY_SCHEMA_VERSION =
|
|
23
|
+
const REGISTRY_SCHEMA_VERSION = 4;
|
|
24
24
|
const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
|
|
25
25
|
const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
|
|
26
26
|
".pi/extensions/skill-registry.ts.disabled";
|
|
@@ -36,23 +36,39 @@ function userSkillDirs(): string[] {
|
|
|
36
36
|
const home = homedir();
|
|
37
37
|
return [
|
|
38
38
|
join(home, ".pi/agent/skills"),
|
|
39
|
+
join(home, ".config/agents/skills"),
|
|
39
40
|
join(home, ".agents/skills"),
|
|
41
|
+
join(home, ".kimi/skills"),
|
|
40
42
|
join(home, ".config/opencode/skills"),
|
|
43
|
+
join(home, ".config/kilo/skills"),
|
|
41
44
|
join(home, ".claude/skills"),
|
|
42
45
|
join(home, ".gemini/skills"),
|
|
46
|
+
join(home, ".gemini/antigravity/skills"),
|
|
43
47
|
join(home, ".cursor/skills"),
|
|
44
48
|
join(home, ".copilot/skills"),
|
|
49
|
+
join(home, ".codex/skills"),
|
|
50
|
+
join(home, ".codeium/windsurf/skills"),
|
|
51
|
+
join(home, ".qwen/skills"),
|
|
52
|
+
join(home, ".kiro/skills"),
|
|
53
|
+
join(home, ".openclaw/skills"),
|
|
45
54
|
];
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
function projectSkillDirs(cwd: string): string[] {
|
|
49
58
|
return [
|
|
50
59
|
join(cwd, "skills"),
|
|
60
|
+
join(cwd, ".opencode/skills"),
|
|
61
|
+
join(cwd, ".claude/skills"),
|
|
62
|
+
join(cwd, ".gemini/skills"),
|
|
63
|
+
join(cwd, ".cursor/skills"),
|
|
64
|
+
join(cwd, ".github/skills"),
|
|
65
|
+
join(cwd, ".codex/skills"),
|
|
66
|
+
join(cwd, ".qwen/skills"),
|
|
67
|
+
join(cwd, ".kiro/skills"),
|
|
68
|
+
join(cwd, ".openclaw/skills"),
|
|
51
69
|
join(cwd, ".pi/skills"),
|
|
52
70
|
join(cwd, ".agent/skills"),
|
|
53
71
|
join(cwd, ".agents/skills"),
|
|
54
|
-
join(cwd, ".claude/skills"),
|
|
55
|
-
join(cwd, ".gemini/skills"),
|
|
56
72
|
join(cwd, ".atl/skills"),
|
|
57
73
|
];
|
|
58
74
|
}
|
|
@@ -78,7 +94,7 @@ function findSkillFiles(root: string): string[] {
|
|
|
78
94
|
}
|
|
79
95
|
}
|
|
80
96
|
}
|
|
81
|
-
return out;
|
|
97
|
+
return out.sort();
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
|
|
@@ -102,24 +118,76 @@ function parseFrontmatter(source: string): { name?: string; description?: string
|
|
|
102
118
|
return { ...out, body };
|
|
103
119
|
}
|
|
104
120
|
|
|
121
|
+
const FALLBACK_RULE_HEADINGS = ["Hard Rules", "Critical Rules", "Critical Patterns", "Voice Rules", "Decision Gates"];
|
|
122
|
+
const MAX_EXTRACTED_RULE_COUNT = 15;
|
|
123
|
+
|
|
105
124
|
function extractCompactRulesSection(body: string): string[] {
|
|
106
|
-
const
|
|
125
|
+
const compactRules = extractRulesFromHeadings(body, ["Compact Rules"]);
|
|
126
|
+
if (compactRules.length > 0) return compactRules;
|
|
127
|
+
return extractRulesFromHeadings(body, FALLBACK_RULE_HEADINGS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractRulesFromHeadings(body: string, headings: string[]): string[] {
|
|
131
|
+
const wanted = new Set(headings.map(normalizeHeading));
|
|
107
132
|
let inSection = false;
|
|
108
133
|
const rules: string[] = [];
|
|
109
|
-
for (const raw of
|
|
134
|
+
for (const raw of body.split("\n")) {
|
|
110
135
|
const line = raw.trimEnd();
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
137
|
+
if (heading) {
|
|
138
|
+
inSection = wanted.has(normalizeHeading(heading[1]));
|
|
113
139
|
continue;
|
|
114
140
|
}
|
|
115
141
|
if (!inSection) continue;
|
|
116
|
-
if (/^##\s+/.test(line))
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
if (/^##\s+/.test(line)) {
|
|
143
|
+
inSection = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const rule = extractRuleLine(line);
|
|
147
|
+
if (rule) {
|
|
148
|
+
rules.push(rule);
|
|
149
|
+
if (rules.length >= MAX_EXTRACTED_RULE_COUNT) return rules;
|
|
150
|
+
}
|
|
119
151
|
}
|
|
120
152
|
return rules;
|
|
121
153
|
}
|
|
122
154
|
|
|
155
|
+
function extractRuleLine(line: string): string | undefined {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed) return undefined;
|
|
158
|
+
const bullet = trimmed.match(/^-\s+(.+)$/);
|
|
159
|
+
if (bullet) return bullet[1].trim();
|
|
160
|
+
const ordered = trimmed.match(/^\d+[.)]\s+(.+)$/);
|
|
161
|
+
if (ordered) return ordered[1].trim();
|
|
162
|
+
if (trimmed.startsWith("|") && trimmed.endsWith("|")) return extractRuleTableRow(trimmed);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractRuleTableRow(line: string): string | undefined {
|
|
167
|
+
const cells = line
|
|
168
|
+
.slice(1, -1)
|
|
169
|
+
.split("|")
|
|
170
|
+
.map((cell) => cell.trim());
|
|
171
|
+
if (cells.length < 2) return undefined;
|
|
172
|
+
if (isTableSeparator(cells) || isTableHeader(cells) || !cells[0] || !cells[1]) return undefined;
|
|
173
|
+
return `${cells[0]}: ${cells[1]}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isTableSeparator(cells: string[]): boolean {
|
|
177
|
+
return cells.every((cell) => cell.replace(/[\s:-]/g, "") === "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isTableHeader(cells: string[]): boolean {
|
|
181
|
+
if (cells.length < 2) return false;
|
|
182
|
+
const first = normalizeHeading(cells[0]);
|
|
183
|
+
const second = normalizeHeading(cells[1]);
|
|
184
|
+
return (first === "rule" && second === "requirement") || (first === "target" && second === "test pattern");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function normalizeHeading(heading: string): string {
|
|
188
|
+
return heading.trim().toLowerCase();
|
|
189
|
+
}
|
|
190
|
+
|
|
123
191
|
function deriveSkillName(file: string, frontmatterName: string | undefined): string {
|
|
124
192
|
if (frontmatterName) return frontmatterName;
|
|
125
193
|
return basename(join(file, ".."));
|
|
@@ -130,13 +198,19 @@ function isExcluded(name: string): boolean {
|
|
|
130
198
|
return EXCLUDE_PREFIXES.some((p) => name.startsWith(p));
|
|
131
199
|
}
|
|
132
200
|
|
|
201
|
+
function comparablePath(path: string): string {
|
|
202
|
+
const clean = normalize(path);
|
|
203
|
+
return clean.length > 1 ? clean.replace(/[\\/]+$/, "") : clean;
|
|
204
|
+
}
|
|
205
|
+
|
|
133
206
|
function uniqueExistingDirs(dirs: string[]): string[] {
|
|
134
207
|
const seen = new Set<string>();
|
|
135
208
|
const out: string[] = [];
|
|
136
209
|
for (const dir of dirs) {
|
|
137
|
-
|
|
138
|
-
seen.
|
|
139
|
-
|
|
210
|
+
const clean = comparablePath(dir);
|
|
211
|
+
if (seen.has(clean) || !existsSync(clean)) continue;
|
|
212
|
+
seen.add(clean);
|
|
213
|
+
out.push(clean);
|
|
140
214
|
}
|
|
141
215
|
return out;
|
|
142
216
|
}
|
|
@@ -155,7 +229,7 @@ function loadSkill(file: string): SkillEntry | undefined {
|
|
|
155
229
|
return {
|
|
156
230
|
name,
|
|
157
231
|
path: file,
|
|
158
|
-
description: fm.description ?? "",
|
|
232
|
+
description: extractTriggerDescription(fm.description ?? ""),
|
|
159
233
|
rules:
|
|
160
234
|
rules.length > 0
|
|
161
235
|
? rules
|
|
@@ -163,8 +237,14 @@ function loadSkill(file: string): SkillEntry | undefined {
|
|
|
163
237
|
};
|
|
164
238
|
}
|
|
165
239
|
|
|
240
|
+
function extractTriggerDescription(description: string): string {
|
|
241
|
+
const match = description.match(/\bTrigger:\s*(.+)$/i);
|
|
242
|
+
return match ? match[1].trim() : description;
|
|
243
|
+
}
|
|
244
|
+
|
|
166
245
|
function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
|
|
167
|
-
const
|
|
246
|
+
const cleanCwd = comparablePath(cwd);
|
|
247
|
+
const projectPrefix = cleanCwd.endsWith(sep) ? cleanCwd : `${cleanCwd}${sep}`;
|
|
168
248
|
const buckets = new Map<string, SkillEntry[]>();
|
|
169
249
|
for (const entry of entries) {
|
|
170
250
|
const list = buckets.get(entry.name) ?? [];
|
|
@@ -173,7 +253,7 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
|
|
|
173
253
|
}
|
|
174
254
|
const out: SkillEntry[] = [];
|
|
175
255
|
for (const [, list] of buckets) {
|
|
176
|
-
const projectScoped = list.find((e) => e.path.startsWith(projectPrefix));
|
|
256
|
+
const projectScoped = list.find((e) => comparablePath(e.path).startsWith(projectPrefix));
|
|
177
257
|
out.push(projectScoped ?? list[0]);
|
|
178
258
|
}
|
|
179
259
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -298,7 +378,7 @@ function quarantineLegacyProjectRegistry(cwd: string): boolean {
|
|
|
298
378
|
|
|
299
379
|
function regenerateRegistry(cwd: string, force: boolean): RegenResult {
|
|
300
380
|
const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
|
|
301
|
-
const files = existingDirs.flatMap(findSkillFiles)
|
|
381
|
+
const files = existingDirs.flatMap(findSkillFiles);
|
|
302
382
|
const cachePath = join(cwd, CACHE_REL_PATH);
|
|
303
383
|
const registryPath = join(cwd, REGISTRY_REL_PATH);
|
|
304
384
|
const fp = fingerprint(files);
|
|
@@ -357,6 +437,15 @@ function startSkillRegistryWatcher(cwd: string, notify: (message: string) => voi
|
|
|
357
437
|
}
|
|
358
438
|
}
|
|
359
439
|
|
|
440
|
+
export const __testing = {
|
|
441
|
+
projectSkillDirs,
|
|
442
|
+
userSkillDirs,
|
|
443
|
+
extractCompactRulesSection,
|
|
444
|
+
extractTriggerDescription,
|
|
445
|
+
uniqueExistingDirs,
|
|
446
|
+
dedupeBySkillName,
|
|
447
|
+
};
|
|
448
|
+
|
|
360
449
|
export default function (pi: ExtensionAPI) {
|
|
361
450
|
pi.on("session_start", async (_event, ctx) => {
|
|
362
451
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -28,11 +28,13 @@
|
|
|
28
28
|
"prompts/",
|
|
29
29
|
"skills/",
|
|
30
30
|
"scripts/",
|
|
31
|
+
"tests/",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
33
34
|
"scripts": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
35
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
36
|
+
"prepack": "pnpm test && node scripts/verify-package-files.mjs",
|
|
37
|
+
"prepublishOnly": "pnpm test && node scripts/verify-package-files.mjs"
|
|
36
38
|
},
|
|
37
39
|
"pi": {
|
|
38
40
|
"image": "https://cdn.jsdelivr.net/npm/gentle-pi/assets/gentle-logo-only.png",
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { __testing } from "../extensions/skill-registry.ts";
|
|
7
|
+
|
|
8
|
+
test("project skill dirs include supported workspace roots", () => {
|
|
9
|
+
const cwd = "/repo";
|
|
10
|
+
const dirs = __testing.projectSkillDirs(cwd);
|
|
11
|
+
for (const want of [
|
|
12
|
+
"skills",
|
|
13
|
+
".opencode/skills",
|
|
14
|
+
".claude/skills",
|
|
15
|
+
".gemini/skills",
|
|
16
|
+
".cursor/skills",
|
|
17
|
+
".github/skills",
|
|
18
|
+
".codex/skills",
|
|
19
|
+
".qwen/skills",
|
|
20
|
+
".kiro/skills",
|
|
21
|
+
".openclaw/skills",
|
|
22
|
+
".pi/skills",
|
|
23
|
+
".agent/skills",
|
|
24
|
+
".agents/skills",
|
|
25
|
+
".atl/skills",
|
|
26
|
+
]) {
|
|
27
|
+
assert.ok(dirs.includes(join(cwd, want)), `missing ${want}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Compact Rules are preferred over fallback sections", () => {
|
|
32
|
+
const rules = __testing.extractCompactRulesSection(`## Compact Rules
|
|
33
|
+
|
|
34
|
+
- Explicit compact rule.
|
|
35
|
+
|
|
36
|
+
## Hard Rules
|
|
37
|
+
|
|
38
|
+
- Hard rule should not be copied.
|
|
39
|
+
`);
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(rules, ["Explicit compact rule."]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("LLM-first and legacy sections extract bullets, ordered lists, and tables", () => {
|
|
45
|
+
const rules = __testing.extractCompactRulesSection(`## Hard Rules
|
|
46
|
+
|
|
47
|
+
- Prefer focused tests.
|
|
48
|
+
|
|
49
|
+
## Critical Rules
|
|
50
|
+
|
|
51
|
+
1. Link an approved issue.
|
|
52
|
+
2. Keep PRs within review budget.
|
|
53
|
+
|
|
54
|
+
## Voice Rules
|
|
55
|
+
|
|
56
|
+
| Rule | Requirement |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| Be warm | Sound like a teammate. |
|
|
59
|
+
|
|
60
|
+
## Decision Gates
|
|
61
|
+
|
|
62
|
+
| Target | Test pattern |
|
|
63
|
+
|---|---|
|
|
64
|
+
| File operations | Use t.TempDir(). |
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(rules, [
|
|
68
|
+
"Prefer focused tests.",
|
|
69
|
+
"Link an approved issue.",
|
|
70
|
+
"Keep PRs within review budget.",
|
|
71
|
+
"Be warm: Sound like a teammate.",
|
|
72
|
+
"File operations: Use t.TempDir().",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("description trigger text is extracted when present", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
__testing.extractTriggerDescription("Write comments. Trigger: PR feedback, issue replies."),
|
|
79
|
+
"PR feedback, issue replies.",
|
|
80
|
+
);
|
|
81
|
+
assert.equal(__testing.extractTriggerDescription("No explicit trigger."), "No explicit trigger.");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("fallback extraction is capped at 15 rules", () => {
|
|
85
|
+
const body = `## Hard Rules
|
|
86
|
+
|
|
87
|
+
${Array.from({ length: 16 }, (_, i) => `- Rule ${String(i + 1).padStart(2, "0")}.`).join("\n")}
|
|
88
|
+
`;
|
|
89
|
+
const rules = __testing.extractCompactRulesSection(body);
|
|
90
|
+
|
|
91
|
+
assert.equal(rules.length, 15);
|
|
92
|
+
assert.equal(rules.at(-1), "Rule 15.");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("project-scoped duplicate wins over user duplicate", () => {
|
|
96
|
+
const cwd = join(tmpdir(), `gentle-pi-registry-${Date.now()}`);
|
|
97
|
+
const projectPath = join(cwd, ".opencode/skills/dup/SKILL.md");
|
|
98
|
+
const userPath = join(cwd + "-home", ".config/opencode/skills/dup/SKILL.md");
|
|
99
|
+
const entries = [
|
|
100
|
+
{ name: "dup", path: userPath, description: "user", rules: ["User rule."] },
|
|
101
|
+
{ name: "dup", path: projectPath, description: "project", rules: ["Project rule."] },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const [chosen] = __testing.dedupeBySkillName(entries, cwd);
|
|
105
|
+
assert.equal(chosen.path, projectPath);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("uniqueExistingDirs normalizes duplicates and ignores missing roots", () => {
|
|
109
|
+
const root = join(tmpdir(), `gentle-pi-existing-${Date.now()}`);
|
|
110
|
+
const existing = join(root, "skills");
|
|
111
|
+
mkdirSync(existing, { recursive: true });
|
|
112
|
+
|
|
113
|
+
assert.deepEqual(__testing.uniqueExistingDirs([existing, join(root, "skills/"), join(root, "missing")]), [existing]);
|
|
114
|
+
});
|