kushi-agents 3.4.2 → 3.12.1
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 +33 -0
- package/package.json +15 -3
- package/plugin/agents/kushi.agent.md +155 -147
- package/plugin/instructions/ado-bootstrap-discovery.instructions.md +111 -0
- package/plugin/instructions/ado-engagement-tree.instructions.md +73 -0
- package/plugin/instructions/answer-from-evidence.instructions.md +1 -1
- package/plugin/instructions/auth-and-retry.instructions.md +51 -16
- package/plugin/instructions/azure-auth-patterns.instructions.md +13 -6
- package/plugin/instructions/bootstrap-status-format.instructions.md +113 -0
- package/plugin/instructions/capture-learnings.instructions.md +95 -0
- package/plugin/instructions/cleanup-on-resolution.instructions.md +69 -0
- package/plugin/instructions/crm-bootstrap-discovery.instructions.md +79 -0
- package/plugin/instructions/crm-internal-vs-confirmed.instructions.md +79 -0
- package/plugin/instructions/evidence-confidence-ladder.instructions.md +66 -0
- package/plugin/instructions/evidence-layout-canonical.instructions.md +115 -0
- package/plugin/instructions/evidence-thoroughness.instructions.md +82 -12
- package/plugin/instructions/full-view-gate.instructions.md +91 -0
- package/plugin/instructions/m365-id-registry.instructions.md +134 -0
- package/plugin/instructions/meetings-verbatim-required.instructions.md +176 -0
- package/plugin/instructions/run-reports.instructions.md +129 -0
- package/plugin/instructions/scope-boundaries.instructions.md +218 -0
- package/plugin/instructions/snapshot-vs-stream.instructions.md +2 -0
- package/plugin/instructions/update-ledger.instructions.md +132 -0
- package/plugin/instructions/verbatim-by-default.instructions.md +73 -0
- package/plugin/instructions/workiq-first.instructions.md +15 -31
- package/plugin/instructions/workiq-only.instructions.md +193 -0
- package/plugin/learnings/README.md +50 -0
- package/plugin/learnings/ado.md +45 -0
- package/plugin/learnings/crm.md +96 -0
- package/plugin/learnings/cross-cutting.md +36 -0
- package/plugin/learnings/email.md +33 -0
- package/plugin/learnings/meetings.md +30 -0
- package/plugin/learnings/misc.md +46 -0
- package/plugin/learnings/onenote.md +215 -0
- package/plugin/learnings/sharepoint.md +5 -0
- package/plugin/learnings/teams.md +5 -0
- package/plugin/plugin.json +22 -2
- package/plugin/prompts/apply-ado.prompt.md +14 -0
- package/plugin/prompts/propose-ado.prompt.md +12 -0
- package/plugin/reference-packs/fde/crm-field-manifest.md +165 -0
- package/plugin/skills/apply-ado-update/SKILL.md +125 -0
- package/plugin/skills/ask-project/SKILL.md +2 -0
- package/plugin/skills/bootstrap-project/SKILL.md +81 -3
- package/plugin/skills/propose-ado-update/SKILL.md +108 -0
- package/plugin/skills/pull-ado/SKILL.md +173 -23
- package/plugin/skills/pull-crm/SKILL.md +168 -15
- package/plugin/skills/pull-email/SKILL.md +139 -22
- package/plugin/skills/pull-meetings/SKILL.md +109 -25
- package/plugin/skills/pull-misc/README.md +84 -0
- package/plugin/skills/pull-misc/SKILL.md +257 -0
- package/plugin/skills/pull-misc/runner.mjs +280 -0
- package/plugin/skills/pull-onenote/README.md +90 -0
- package/plugin/skills/pull-onenote/SKILL.md +400 -51
- package/plugin/skills/pull-onenote/runner.mjs +356 -0
- package/plugin/skills/pull-onenote/scripts/recapture-section-url.mjs +295 -0
- package/plugin/skills/pull-onenote/write-snapshot.mjs +271 -0
- package/plugin/skills/pull-sharepoint/SKILL.md +44 -12
- package/plugin/skills/pull-teams/SKILL.md +40 -11
- package/plugin/skills/refresh-project/SKILL.md +33 -2
- package/plugin/skills/self-check/run.ps1 +186 -4
- package/plugin/templates/ado-update/discussion-comment.template.md +26 -0
- package/plugin/templates/ado-update/integrations-ado-writes.example.yml +49 -0
- package/plugin/templates/ado-update/proposed.template.md +78 -0
- package/plugin/templates/init/external-links.template.txt +30 -0
- package/plugin/templates/init/project-integrations.template.yml +57 -2
- package/plugin/templates/snapshot/meeting-verbatim.template.md +110 -0
- package/plugin/templates/snapshot/meetings-series-index.template.md +3 -1
- package/plugin/templates/snapshot/onenote-page.template.md +92 -23
- package/plugin/templates/weekly/meetings-stream.template.md +11 -6
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: "pull-misc"
|
|
3
|
+
version: "2.0.1"
|
|
4
|
+
description: "Pull miscellaneous evidence from a user-curated link list (<project>/external-links.txt). Handles links that don't fit the per-source pull-* skills: Loop pages, public web pages, learn.microsoft.com / docs sites, GitHub repos, local files, anything else linkable. Routes by type: delegated types (onenote, sharepoint, ado) are skipped because dedicated pull-* skills handle them. Per-link retry registry tracks last_status across runs."
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Skill: pull-misc
|
|
8
|
+
|
|
9
|
+
> **v3.9.0 contracts** — This skill operates under six HARD-rule doctrines:
|
|
10
|
+
> - `verbatim-by-default.instructions.md` — full bodies by default; no preview-grade pulls accepted.
|
|
11
|
+
> - `m365-id-registry.instructions.md` — discover-once / consume-deterministically (per-link retry registry under `misc_links[]`).
|
|
12
|
+
> - `capture-learnings.instructions.md` — every fix/discovery is logged to `plugin/learnings/misc.md`.
|
|
13
|
+
> - `cleanup-on-resolution.instructions.md` — when a placeholder URL resolves, all stale unavailable-markers are upgraded in the same turn.
|
|
14
|
+
> - `run-reports.instructions.md` — every refresh writes a per-user report under `Evidence/<alias>/refresh-reports/`.
|
|
15
|
+
> - `thoroughness-detector.instructions.md` — the per-link retry registry IS this skill's thoroughness contract.
|
|
16
|
+
|
|
17
|
+
> **Canonical evidence layout** (HARD, kushi v3.12.1+): all artifacts produced by this skill MUST be written under `<project>/Evidence/<alias>/<source>/{snapshot,stream,...}/` — sibling folders under `<project>/` (e.g. `<project>/<source>-context/`, `<project>/<source>/`, `<project>/_Weekly Summaries/`) are FORBIDDEN. See `evidence-layout-canonical.instructions.md`.
|
|
18
|
+
|
|
19
|
+
Pulls **misc** evidence in two shapes per `snapshot-vs-stream.instructions.md`:
|
|
20
|
+
|
|
21
|
+
- **snapshot/** — full content per link — one file per link with last-fetched + verbatim body
|
|
22
|
+
- **stream/** — change events (HTTP `last-modified` header drift, git commit deltas where applicable, Loop edit signals via WorkIQ when available)
|
|
23
|
+
|
|
24
|
+
## Tools (in order)
|
|
25
|
+
|
|
26
|
+
1. **Playwright (browser-scrape, persisted profile)** — for `loop` links. Reuses `~/.copilot/playwright-profile/onenote/` because it's the same M365 cookie scope. Implementation: `plugin/skills/pull-misc/runner.mjs` branch `loop`.
|
|
27
|
+
2. **HTTP fetch + Readability** — for `web` / `confluence` (anonymous) / `learn` / `docs` / `pdf` links. Implementation: `plugin/skills/pull-misc/runner.mjs` branch `web`.
|
|
28
|
+
3. **Local file read** — for `file` links pointing at project-relative paths. Implementation: `plugin/skills/pull-misc/runner.mjs` branch `file`.
|
|
29
|
+
4. **WorkIQ** — used only for stream/edit-event signals on `loop` links (Loop edit events surface in the M365 search index, same as OneNote stream events). NOT used for body retrieval — Loop bodies require the browser path.
|
|
30
|
+
5. **Delegation** — `onenote`, `sharepoint`, `ado` are NOT pulled here; the runner records `captured_via: delegated` so the registry stays complete and the user can see which links flow through which skill. The actual capture is done by `pull-onenote`, `pull-sharepoint`, `pull-ado` respectively.
|
|
31
|
+
|
|
32
|
+
## The link list (`<project>/external-links.txt`)
|
|
33
|
+
|
|
34
|
+
This file already exists in most projects (per memory: the External Links Context doctrine). pull-misc preserves the existing format **without breaking changes** and adds `loop` as a recognized type.
|
|
35
|
+
|
|
36
|
+
Format:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
# Comments start with #
|
|
40
|
+
# One link per line:
|
|
41
|
+
<type>|<owner>|<title>|<url-or-path>|<notes>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Recognized types (v3.9.0):**
|
|
45
|
+
|
|
46
|
+
| Type | Routed to | Captured via |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `onenote` | pull-onenote | delegated |
|
|
49
|
+
| `sharepoint` | pull-sharepoint | delegated |
|
|
50
|
+
| `ado` | pull-ado | delegated |
|
|
51
|
+
| `loop` **(new)** | pull-misc | browser (Playwright, OneNote profile) |
|
|
52
|
+
| `web` | pull-misc | http (fetch + readability) |
|
|
53
|
+
| `confluence` | pull-misc | http (anonymous) — auth'd Confluence not yet supported |
|
|
54
|
+
| `learn` | pull-misc | http |
|
|
55
|
+
| `docs` | pull-misc | http |
|
|
56
|
+
| `pdf` | pull-misc | http (binary download + text extract via pdfjs) |
|
|
57
|
+
| `github` | pull-misc | http (raw README + repo metadata) |
|
|
58
|
+
| `file` | pull-misc | file (local read; path is project-relative) |
|
|
59
|
+
| anything else | pull-misc | http (best-effort) |
|
|
60
|
+
|
|
61
|
+
**Placeholder URLs** matching `<PASTE_*_URL>` or `<TODO*>` are SKIPPED with `last_status: placeholder`. They surface in the run report so the user can fill them in.
|
|
62
|
+
|
|
63
|
+
## Empirical contract
|
|
64
|
+
|
|
65
|
+
These three facts are HARD-rule:
|
|
66
|
+
|
|
67
|
+
1. **The user's external-links.txt IS the source of truth for what counts as "misc evidence" for that project.** No fuzzy auto-discovery. If a link isn't in the file, it isn't pulled. Discovery is a separate (manual) step where the user paste links in.
|
|
68
|
+
2. **Web fetches use HTTP first, browser only if HTTP fails or returns interactive shell.** SPAs that don't server-render (Loop, some Confluence, dashboards) drop to browser. Don't run a browser when curl works.
|
|
69
|
+
3. **Loop pages MUST go through browser.** Same gap as OneNote — Loop has no Graph API for component content. The /loop skill already proved this; pull-misc reuses the same Playwright profile.
|
|
70
|
+
|
|
71
|
+
## Pre-flight
|
|
72
|
+
|
|
73
|
+
```pwsh
|
|
74
|
+
# A. Playwright profile (only required if external-links.txt contains loop links)
|
|
75
|
+
$prof = "$env:USERPROFILE\.copilot\playwright-profile\onenote"
|
|
76
|
+
if (-not (Test-Path $prof)) {
|
|
77
|
+
Write-Host "[pull-misc] Playwright profile not yet seeded — loop links will be skipped with last_status: auth-required."
|
|
78
|
+
Write-Host "[pull-misc] Run plugin/skills/pull-onenote/runner.mjs --bootstrap once to seed it."
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# B. Node deps
|
|
82
|
+
$nodeDeps = @('playwright', 'jsdom', '@mozilla/readability')
|
|
83
|
+
# Runner checks at startup; if missing, exits with "missing-deps" status and lists them.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Bootstrap discovery
|
|
87
|
+
|
|
88
|
+
For each project, the bootstrap step:
|
|
89
|
+
|
|
90
|
+
1. **Locates `<project>/external-links.txt`.** If absent, write a starter template (same format as ABN AMRO has today) and mark `boundaries.misc.externalLinksPath` in `integrations.yml`.
|
|
91
|
+
2. **Parses the file.** Skip comments, skip blank lines, skip placeholder URLs (`<PASTE_*_URL>`, `<TODO*>`).
|
|
92
|
+
3. **Initializes `misc_links[]`** in `m365-mutable.json#knownSections.<projectKey>` with one entry per non-placeholder link, all `last_status: not-yet-attempted`.
|
|
93
|
+
4. **Records** `boundaries.misc.linkCount` and `placeholderCount` to `bootstrap-status.md`.
|
|
94
|
+
|
|
95
|
+
## Step A — enumerate (every refresh)
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
1. Read external-links.txt; rebuild link inventory (in case the user added/removed links since last run).
|
|
99
|
+
2. For each existing entry in misc_links[], match by (type, url) tuple — preserve attempts, last_attempt_at, snapshot_path.
|
|
100
|
+
3. For new entries, create with last_status: not-yet-attempted.
|
|
101
|
+
4. For deleted entries (in registry but no longer in file), mark last_status: removed and DO NOT delete the snapshot file (audit trail).
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Step B — fetch (per link)
|
|
105
|
+
|
|
106
|
+
The runner branches per `type`:
|
|
107
|
+
|
|
108
|
+
### B.1 `loop` — browser
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
// pages.goto(loopUrl); detect login redirect → auth-required.
|
|
112
|
+
// Wait for primary canvas selector; for Loop, that's the Fluid container.
|
|
113
|
+
// Read text via document.body.innerText after settle.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
If login redirect detected: `last_status: auth-required`, `captured_via: browser`, exit early for this link.
|
|
117
|
+
|
|
118
|
+
### B.2 `web` / `confluence` / `learn` / `docs` / `github` / unknown — HTTP
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
// fetch(url, { headers: { 'User-Agent': 'kushi-pull-misc/0.1' } });
|
|
122
|
+
// If 200 + HTML → run @mozilla/readability for main content extract.
|
|
123
|
+
// If 200 + text/* → use raw body.
|
|
124
|
+
// If 200 + application/pdf → buffer + pdfjs text extract.
|
|
125
|
+
// If 401/403 → last_status: auth-required (cannot resolve unattended for non-MS hosts).
|
|
126
|
+
// If 404 / DNS fail → last_status: fetch-failed with HTTP code.
|
|
127
|
+
// If 5xx → last_status: fetch-failed (transient — retry next refresh).
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### B.3 `file` — local read
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
// path is project-relative; resolve under <engagement-root>/<project>/
|
|
134
|
+
// Read text; if binary, mark last_status: skipped-binary (use a different pipeline if needed).
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### B.4 `onenote` / `sharepoint` / `ado` — delegate
|
|
138
|
+
|
|
139
|
+
Skip fetch. Write registry entry with `captured_via: delegated`, `delegated_to: pull-onenote|pull-sharepoint|pull-ado`. Per-skill captures already happen in their own pipelines.
|
|
140
|
+
|
|
141
|
+
## Per-link retry registry
|
|
142
|
+
|
|
143
|
+
Stored at `m365-mutable.json#knownSections.<projectKey>.misc_links[]`. Schema:
|
|
144
|
+
|
|
145
|
+
```jsonc
|
|
146
|
+
{
|
|
147
|
+
"type": "loop | web | learn | pdf | github | file | onenote | sharepoint | ado | ...",
|
|
148
|
+
"owner": "<as in external-links.txt>",
|
|
149
|
+
"title": "<as in external-links.txt>",
|
|
150
|
+
"url": "<as in external-links.txt>", // or path for type=file
|
|
151
|
+
"notes": "<as in external-links.txt>",
|
|
152
|
+
"last_status": "captured | placeholder | auth-required | fetch-failed | skipped-binary | removed | not-yet-attempted | delegated",
|
|
153
|
+
"captured_via": "browser | http | file | delegated",
|
|
154
|
+
"delegated_to": "pull-onenote | pull-sharepoint | pull-ado", // only when captured_via=delegated
|
|
155
|
+
"http_status": 200, // only for HTTP
|
|
156
|
+
"content_type": "text/html", // only for HTTP
|
|
157
|
+
"char_count": 12345,
|
|
158
|
+
"attempts": 3,
|
|
159
|
+
"last_attempt_at": "<ISO-8601>",
|
|
160
|
+
"captured_at": "<ISO-8601, only when last_status=captured>",
|
|
161
|
+
"snapshot_path": "Evidence/<alias>/misc/snapshot/<safe-type>__<safe-title>.md",
|
|
162
|
+
"etag": "<HTTP ETag if returned>", // for change detection
|
|
163
|
+
"last_modified_http":"<HTTP Last-Modified if returned>"
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Snapshot files
|
|
168
|
+
|
|
169
|
+
One file per non-delegated link at:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
<project>/Evidence/<alias>/misc/snapshot/<safe-type>__<safe-title>.md
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Schema:
|
|
176
|
+
|
|
177
|
+
```yaml
|
|
178
|
+
---
|
|
179
|
+
type: web
|
|
180
|
+
owner: "Microsoft"
|
|
181
|
+
title: "Microsoft Fabric Documentation Hub"
|
|
182
|
+
url: "https://learn.microsoft.com/fabric/"
|
|
183
|
+
last_status: captured
|
|
184
|
+
captured_via: http
|
|
185
|
+
http_status: 200
|
|
186
|
+
content_type: text/html
|
|
187
|
+
char_count: 18420
|
|
188
|
+
captured_at: 2026-05-14T04:35:00Z
|
|
189
|
+
schema: v0.1.0-misc
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
# {title}
|
|
193
|
+
|
|
194
|
+
> Source: {url}
|
|
195
|
+
> Captured via {captured_via} ({captured_at})
|
|
196
|
+
> Owner: {owner}
|
|
197
|
+
> Type: {type}
|
|
198
|
+
|
|
199
|
+
## AI Narrative Summary
|
|
200
|
+
|
|
201
|
+
{1-2 paragraph summary derived ONLY from the body text below — never fabricate, never extrapolate beyond the captured content}
|
|
202
|
+
|
|
203
|
+
## Body (verbatim)
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
{full extracted text}
|
|
207
|
+
```
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Step C — stream
|
|
211
|
+
|
|
212
|
+
For supported types, emit edit events:
|
|
213
|
+
|
|
214
|
+
- `web` / `learn` / `docs` / `pdf`: compare current `etag` / `last_modified_http` to registry; if changed since last refresh, emit `{ts, type, title, url, change: 'updated'}`.
|
|
215
|
+
- `loop`: WorkIQ has Loop edit-event signals via search index — query when available. Otherwise infer from body diff.
|
|
216
|
+
- `file`: compare file mtime; emit if changed.
|
|
217
|
+
- `github`: query repo commits since last `captured_at`; emit per-commit events.
|
|
218
|
+
- `onenote` / `sharepoint` / `ado`: NOT emitted here (their respective pull-* skills handle stream events for their content).
|
|
219
|
+
|
|
220
|
+
## Run report
|
|
221
|
+
|
|
222
|
+
Every refresh writes one block to `Evidence/<alias>/refresh-reports/<date>.md`:
|
|
223
|
+
|
|
224
|
+
```markdown
|
|
225
|
+
### pull-misc — <project>
|
|
226
|
+
|
|
227
|
+
- external-links.txt: <N> total links (<P> placeholders, <D> delegated, <M> handled here)
|
|
228
|
+
- captured: <C> / <M>
|
|
229
|
+
- auth-required: <A>
|
|
230
|
+
- fetch-failed: <F>
|
|
231
|
+
- placeholders to fill: <list of titles where url contains <PASTE_*_URL>>
|
|
232
|
+
- removed since last run: <list>
|
|
233
|
+
- new since last run: <list>
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Anti-patterns (HARD)
|
|
237
|
+
|
|
238
|
+
1. ❌ Auto-discovering links by crawling project SharePoint or Teams chats. The user's `external-links.txt` IS the boundary.
|
|
239
|
+
2. ❌ Storing only `url` (not `(type, url)` tuple) as the registry key. Two types may share a URL (e.g. an onenote URL flagged as `web` for ad-hoc fetch test).
|
|
240
|
+
3. ❌ Silently dropping a link from the registry when it disappears from `external-links.txt`. Mark `removed` and keep the snapshot for audit.
|
|
241
|
+
4. ❌ Pulling delegated types (onenote / sharepoint / ado). Those have dedicated skills. Double-pulling creates conflicting evidence.
|
|
242
|
+
5. ❌ Persisting placeholder URLs as `fetch-failed`. They're `placeholder`, surfaced in the run report so the user knows to fill them in.
|
|
243
|
+
6. ❌ Loop link without Playwright profile silently writes empty snapshot. Must mark `auth-required`.
|
|
244
|
+
|
|
245
|
+
## Self-check enforcement
|
|
246
|
+
|
|
247
|
+
`plugin/skills/self-check/run.ps1` D-token contract for `pull-misc`: the SKILL.md must contain `external-links`, `misc_links`, `placeholder`, `delegated`. These prove the skill carries the v0.1.0 link-list pipeline + delegation contract.
|
|
248
|
+
|
|
249
|
+
## Citation
|
|
250
|
+
|
|
251
|
+
Citations from misc evidence MUST follow:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
[source: misc/<type>/<safe-title> · YYYY-MM-DD]
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Example: `[source: misc/loop/ABN-Core-Team-Sync-2026-03-27 · 2026-05-14]`
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pull-misc runner — read external-links.txt, classify by type, route to runner branches.
|
|
3
|
+
// Per pull-misc SKILL.md v0.1.0 (kushi v3.9.0).
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// node runner.mjs --project HCA --links-file "<path-to-external-links.txt>"
|
|
7
|
+
// node runner.mjs --project HCA --links-file "..." --headless
|
|
8
|
+
// node runner.mjs --project HCA --links-file "..." --types "loop,web" # only fetch some
|
|
9
|
+
// node runner.mjs --project HCA --links-file "..." --titles "ABN Workshop,..." # only specific titles
|
|
10
|
+
//
|
|
11
|
+
// Output is a single JSON object on stdout containing:
|
|
12
|
+
// { project, linksFile, links: [<perLinkRecord>...], runStatus, counts }
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import fsp from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
|
|
20
|
+
// ----- arg parse -----
|
|
21
|
+
const args = Object.fromEntries(
|
|
22
|
+
process.argv.slice(2).reduce((acc, cur, idx, arr) => {
|
|
23
|
+
if (cur.startsWith('--')) {
|
|
24
|
+
const key = cur.replace(/^--/, '');
|
|
25
|
+
const next = arr[idx + 1];
|
|
26
|
+
acc.push([key, next && !next.startsWith('--') ? next : true]);
|
|
27
|
+
}
|
|
28
|
+
return acc;
|
|
29
|
+
}, [])
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const PROJECT = args.project || null;
|
|
33
|
+
const LINKS_FILE = args['links-file'] || null;
|
|
34
|
+
const ENGAGEMENT_ROOT = args['engagement-root'] || null;
|
|
35
|
+
const HEADLESS = !!args.headless;
|
|
36
|
+
const TYPES_FILTER = args.types ? String(args.types).split(',').map(s => s.trim().toLowerCase()) : null;
|
|
37
|
+
const TITLES_FILTER = args.titles ? String(args.titles).split(',').map(s => s.trim()) : null;
|
|
38
|
+
const TIMEOUT_MS = parseInt(args.timeout || '45000', 10);
|
|
39
|
+
const PROFILE_DIR = path.join(os.homedir(), '.copilot', 'playwright-profile', 'onenote');
|
|
40
|
+
|
|
41
|
+
if (!PROJECT || !LINKS_FILE) {
|
|
42
|
+
console.error('Usage: --project <name> --links-file <path> [--headless] [--types t1,t2] [--titles "t1,t2"]');
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ----- constants -----
|
|
47
|
+
const DELEGATED_TYPES = new Set(['onenote', 'sharepoint', 'ado']);
|
|
48
|
+
const BROWSER_TYPES = new Set(['loop']);
|
|
49
|
+
const PLACEHOLDER_RE = /<\s*(PASTE_[A-Z_]*_URL|TODO[^>]*)\s*>/i;
|
|
50
|
+
|
|
51
|
+
// ----- parse external-links.txt -----
|
|
52
|
+
function parseLinksFile(p) {
|
|
53
|
+
const txt = fs.readFileSync(p, 'utf-8');
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const raw of txt.split(/\r?\n/)) {
|
|
56
|
+
const line = raw.trim();
|
|
57
|
+
if (!line || line.startsWith('#')) continue;
|
|
58
|
+
const parts = line.split('|').map(s => s.trim());
|
|
59
|
+
if (parts.length < 4) continue; // malformed
|
|
60
|
+
const [type, owner, title, url, ...noteParts] = parts;
|
|
61
|
+
out.push({
|
|
62
|
+
type: type.toLowerCase(),
|
|
63
|
+
owner,
|
|
64
|
+
title,
|
|
65
|
+
url,
|
|
66
|
+
notes: noteParts.join('|') || ''
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeName(s) {
|
|
73
|
+
return s.replace(/[\\/:*?"<>|]/g, '-').replace(/\s+/g, '-').slice(0, 120);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function classify(link) {
|
|
77
|
+
if (PLACEHOLDER_RE.test(link.url)) return { route: 'placeholder' };
|
|
78
|
+
if (DELEGATED_TYPES.has(link.type)) return { route: 'delegated', delegated_to: `pull-${link.type}` };
|
|
79
|
+
if (BROWSER_TYPES.has(link.type)) return { route: 'browser' };
|
|
80
|
+
if (link.type === 'file') return { route: 'file' };
|
|
81
|
+
return { route: 'http' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ----- HTTP fetch branch -----
|
|
85
|
+
async function fetchHttp(url) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(url, {
|
|
88
|
+
redirect: 'follow',
|
|
89
|
+
headers: { 'User-Agent': 'kushi-pull-misc/0.1', 'Accept': '*/*' },
|
|
90
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
91
|
+
});
|
|
92
|
+
const ct = res.headers.get('content-type') || '';
|
|
93
|
+
const etag = res.headers.get('etag') || null;
|
|
94
|
+
const lm = res.headers.get('last-modified') || null;
|
|
95
|
+
|
|
96
|
+
if (res.status === 401 || res.status === 403) {
|
|
97
|
+
return { last_status: 'auth-required', http_status: res.status, content_type: ct };
|
|
98
|
+
}
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
return { last_status: 'fetch-failed', http_status: res.status, content_type: ct };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (ct.startsWith('application/pdf')) {
|
|
104
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
105
|
+
// Defer text extract to a separate step (pdfjs not bundled to keep dep weight low).
|
|
106
|
+
return {
|
|
107
|
+
last_status: 'captured',
|
|
108
|
+
http_status: res.status,
|
|
109
|
+
content_type: ct,
|
|
110
|
+
etag, last_modified_http: lm,
|
|
111
|
+
body: `[binary PDF — ${buf.length} bytes; text extraction not yet implemented in v0.1]`,
|
|
112
|
+
char_count: 0
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const body = await res.text();
|
|
117
|
+
|
|
118
|
+
// Heuristic: HTML → readability extract; plain → keep raw.
|
|
119
|
+
let extracted = body;
|
|
120
|
+
if (ct.includes('html')) {
|
|
121
|
+
try {
|
|
122
|
+
const { JSDOM } = await import('jsdom');
|
|
123
|
+
const { Readability } = await import('@mozilla/readability');
|
|
124
|
+
const dom = new JSDOM(body, { url });
|
|
125
|
+
const reader = new Readability(dom.window.document);
|
|
126
|
+
const article = reader.parse();
|
|
127
|
+
if (article && article.textContent && article.textContent.length > 100) {
|
|
128
|
+
extracted = `# ${article.title || ''}\n\n${article.textContent.trim()}`;
|
|
129
|
+
}
|
|
130
|
+
} catch (e) {
|
|
131
|
+
// jsdom or readability not available — fall through to raw
|
|
132
|
+
extracted = `[readability unavailable: ${e.message}]\n\n${body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
last_status: 'captured',
|
|
138
|
+
http_status: res.status,
|
|
139
|
+
content_type: ct,
|
|
140
|
+
etag, last_modified_http: lm,
|
|
141
|
+
body: extracted,
|
|
142
|
+
char_count: extracted.length
|
|
143
|
+
};
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { last_status: 'fetch-failed', error: String(e.message || e) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ----- File read branch -----
|
|
150
|
+
async function readFileBranch(link) {
|
|
151
|
+
const root = ENGAGEMENT_ROOT || process.cwd();
|
|
152
|
+
const candidate1 = path.resolve(root, link.url);
|
|
153
|
+
const candidate2 = path.resolve(root, PROJECT, link.url);
|
|
154
|
+
const tryPath = fs.existsSync(candidate1) ? candidate1 : (fs.existsSync(candidate2) ? candidate2 : null);
|
|
155
|
+
if (!tryPath) return { last_status: 'fetch-failed', error: `not found at ${candidate1} or ${candidate2}` };
|
|
156
|
+
try {
|
|
157
|
+
const buf = await fsp.readFile(tryPath);
|
|
158
|
+
if (buf.includes(0)) return { last_status: 'skipped-binary', char_count: buf.length };
|
|
159
|
+
const txt = buf.toString('utf-8');
|
|
160
|
+
return { last_status: 'captured', body: txt, char_count: txt.length, resolved_path: tryPath };
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return { last_status: 'fetch-failed', error: String(e.message || e) };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ----- Browser (Loop) branch -----
|
|
167
|
+
async function fetchBrowser(link, ctx) {
|
|
168
|
+
const page = await ctx.newPage();
|
|
169
|
+
try {
|
|
170
|
+
const resp = await page.goto(link.url, { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS });
|
|
171
|
+
if (/login\.microsoftonline\.com|login\.live\.com/.test(page.url())) {
|
|
172
|
+
return { last_status: 'auth-required', captured_via: 'browser' };
|
|
173
|
+
}
|
|
174
|
+
// Wait for Loop content. Loop uses Fluid; the main content settles in a few seconds.
|
|
175
|
+
await page.waitForTimeout(4000);
|
|
176
|
+
// Try Loop-specific selectors first, then fallback to body
|
|
177
|
+
const body = await page.evaluate(() => {
|
|
178
|
+
const sels = [
|
|
179
|
+
'[data-loop-canvas]',
|
|
180
|
+
'.fui-FluentProvider',
|
|
181
|
+
'[role="main"]',
|
|
182
|
+
'body'
|
|
183
|
+
];
|
|
184
|
+
for (const s of sels) {
|
|
185
|
+
const el = document.querySelector(s);
|
|
186
|
+
if (el && el.innerText && el.innerText.length > 100) return el.innerText;
|
|
187
|
+
}
|
|
188
|
+
return document.body ? document.body.innerText : '';
|
|
189
|
+
});
|
|
190
|
+
if (!body || body.length < 50) {
|
|
191
|
+
return { last_status: 'fetch-failed', captured_via: 'browser', error: 'empty or near-empty body', char_count: body?.length || 0 };
|
|
192
|
+
}
|
|
193
|
+
return { last_status: 'captured', captured_via: 'browser', body, char_count: body.length };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { last_status: 'fetch-failed', captured_via: 'browser', error: String(e.message || e) };
|
|
196
|
+
} finally {
|
|
197
|
+
await page.close().catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ----- main -----
|
|
202
|
+
(async () => {
|
|
203
|
+
const links = parseLinksFile(LINKS_FILE);
|
|
204
|
+
const filtered = links.filter(l => {
|
|
205
|
+
if (TYPES_FILTER && !TYPES_FILTER.includes(l.type)) return false;
|
|
206
|
+
if (TITLES_FILTER && !TITLES_FILTER.some(t => l.title.includes(t))) return false;
|
|
207
|
+
return true;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const needsBrowser = filtered.some(l => classify(l).route === 'browser');
|
|
211
|
+
let ctx = null;
|
|
212
|
+
if (needsBrowser) {
|
|
213
|
+
if (!fs.existsSync(PROFILE_DIR)) {
|
|
214
|
+
console.error('[pull-misc] Playwright profile missing — loop links will be marked auth-required.');
|
|
215
|
+
} else {
|
|
216
|
+
const { chromium } = await import('playwright');
|
|
217
|
+
ctx = await chromium.launchPersistentContext(PROFILE_DIR, { headless: HEADLESS, viewport: { width: 1400, height: 900 } });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const results = [];
|
|
222
|
+
const now = new Date().toISOString();
|
|
223
|
+
|
|
224
|
+
for (const link of filtered) {
|
|
225
|
+
const cls = classify(link);
|
|
226
|
+
let rec = {
|
|
227
|
+
type: link.type,
|
|
228
|
+
owner: link.owner,
|
|
229
|
+
title: link.title,
|
|
230
|
+
url: link.url,
|
|
231
|
+
notes: link.notes,
|
|
232
|
+
attempts: 1,
|
|
233
|
+
last_attempt_at: now,
|
|
234
|
+
snapshot_path: `Evidence/<alias>/misc/snapshot/${safeName(link.type)}__${safeName(link.title)}.md`
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
if (cls.route === 'placeholder') {
|
|
238
|
+
Object.assign(rec, { last_status: 'placeholder', captured_via: null });
|
|
239
|
+
} else if (cls.route === 'delegated') {
|
|
240
|
+
Object.assign(rec, { last_status: 'delegated', captured_via: 'delegated', delegated_to: cls.delegated_to });
|
|
241
|
+
} else if (cls.route === 'http') {
|
|
242
|
+
const r = await fetchHttp(link.url);
|
|
243
|
+
Object.assign(rec, r, { captured_via: 'http' });
|
|
244
|
+
if (r.last_status === 'captured') rec.captured_at = now;
|
|
245
|
+
} else if (cls.route === 'file') {
|
|
246
|
+
const r = await readFileBranch(link);
|
|
247
|
+
Object.assign(rec, r, { captured_via: 'file' });
|
|
248
|
+
if (r.last_status === 'captured') rec.captured_at = now;
|
|
249
|
+
} else if (cls.route === 'browser') {
|
|
250
|
+
if (!ctx) {
|
|
251
|
+
Object.assign(rec, { last_status: 'auth-required', captured_via: 'browser', error: 'no playwright profile' });
|
|
252
|
+
} else {
|
|
253
|
+
const r = await fetchBrowser(link, ctx);
|
|
254
|
+
Object.assign(rec, r);
|
|
255
|
+
if (r.last_status === 'captured') rec.captured_at = now;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
results.push(rec);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (ctx) await ctx.close();
|
|
262
|
+
|
|
263
|
+
const counts = {
|
|
264
|
+
total: results.length,
|
|
265
|
+
captured: results.filter(r => r.last_status === 'captured').length,
|
|
266
|
+
placeholder: results.filter(r => r.last_status === 'placeholder').length,
|
|
267
|
+
delegated: results.filter(r => r.last_status === 'delegated').length,
|
|
268
|
+
auth_required: results.filter(r => r.last_status === 'auth-required').length,
|
|
269
|
+
fetch_failed: results.filter(r => r.last_status === 'fetch-failed').length,
|
|
270
|
+
skipped_binary: results.filter(r => r.last_status === 'skipped-binary').length
|
|
271
|
+
};
|
|
272
|
+
const runStatus = counts.auth_required > 0 ? 'auth-required'
|
|
273
|
+
: counts.fetch_failed > 0 ? 'partial'
|
|
274
|
+
: 'ok';
|
|
275
|
+
|
|
276
|
+
console.log(JSON.stringify({ project: PROJECT, linksFile: LINKS_FILE, runStatus, counts, links: results }, null, 2));
|
|
277
|
+
})().catch(e => {
|
|
278
|
+
console.error('[pull-misc] runner crashed:', e);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# pull-onenote — runner README
|
|
2
|
+
|
|
3
|
+
## What this is
|
|
4
|
+
|
|
5
|
+
A Playwright-driven browser scraper for OneNote-for-Web that returns verbatim page bodies for an entire section. Used by the `pull-onenote` skill (v2.6.0+) as the PRIMARY capture path.
|
|
6
|
+
|
|
7
|
+
## One-time setup
|
|
8
|
+
|
|
9
|
+
```pwsh
|
|
10
|
+
# 1. Install Node + Playwright if you don't have them
|
|
11
|
+
npm install -g playwright
|
|
12
|
+
npx playwright install chromium
|
|
13
|
+
|
|
14
|
+
# 2. Seed the persistent browser profile (interactive — one-time)
|
|
15
|
+
node plugin/skills/pull-onenote/runner.mjs --bootstrap
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
When the bootstrap command opens a Chromium window:
|
|
19
|
+
- Sign in with your work account at `onenote.cloud.microsoft`.
|
|
20
|
+
- Check **Keep me signed in**.
|
|
21
|
+
- Wait for the OneNote home page to load.
|
|
22
|
+
- Close the window.
|
|
23
|
+
|
|
24
|
+
The profile is persisted at `~/.copilot/playwright-profile/onenote/`. Subsequent runs reuse it.
|
|
25
|
+
|
|
26
|
+
## Per-section runs
|
|
27
|
+
|
|
28
|
+
```pwsh
|
|
29
|
+
node plugin/skills/pull-onenote/runner.mjs `
|
|
30
|
+
--project "HCA" `
|
|
31
|
+
--section-url "https://microsoft-my.sharepoint-df.com/personal/<upn>/_layouts/15/Doc.aspx?sourcedoc=%7B<notebookSourceDoc>%7D&action=edit&wd=target%28<sectionName>.one%7C<sectionFileId>%2F%29" `
|
|
32
|
+
--headless
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The runner emits a single JSON object on stdout with this shape:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"project": "HCA",
|
|
40
|
+
"sectionUrl": "...",
|
|
41
|
+
"runStatus": "ok | auth-required | partial",
|
|
42
|
+
"exitedEarly": false,
|
|
43
|
+
"authRequiredCount": 0,
|
|
44
|
+
"pages": [
|
|
45
|
+
{
|
|
46
|
+
"title": "4/3 - HCA with Jay and Martin",
|
|
47
|
+
"pos": 12, "total": 16,
|
|
48
|
+
"webPageId": "78aac9b5-0629-4daa-ada3-f2436cb2381c",
|
|
49
|
+
"last_status": "captured | short-suspect | auth-required | browser-error",
|
|
50
|
+
"captured_via": "browser",
|
|
51
|
+
"body": "...verbatim text...",
|
|
52
|
+
"bodyLen": 6904,
|
|
53
|
+
"capturedAt": "2026-05-14T03:55:00Z"
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The skill driver merges this into `m365-mutable.json#knownSections.<projectKey>.one_pages[]` and writes the snapshot pages.
|
|
60
|
+
|
|
61
|
+
## Filtering
|
|
62
|
+
|
|
63
|
+
For retry-only runs, pass `--titles` with a comma-separated list:
|
|
64
|
+
|
|
65
|
+
```pwsh
|
|
66
|
+
node runner.mjs --project HCA --section-url "..." --titles "4/3 - HCA with Jay and Martin,5/7" --headless
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Scheduled / unattended runs
|
|
70
|
+
|
|
71
|
+
For Monday-9am-style schedules, always pass `--headless`. The runner detects redirects to `login.microsoftonline.com` and exits early with `runStatus: "auth-required"` so the driver can mark all queued pages as `auth-required` and surface in the run report.
|
|
72
|
+
|
|
73
|
+
There is currently no way to satisfy a Conditional Access / MFA challenge unattended. When that happens (rough estimate: every 1–4 weeks under a Microsoft tenant CA policy), do one interactive `--bootstrap` to refresh the profile and the next scheduled run resumes silently.
|
|
74
|
+
|
|
75
|
+
## Constructing the section URL
|
|
76
|
+
|
|
77
|
+
Once a project's `knownSections` entry is populated in `m365-mutable.json`, the URL is:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
<one_notebookSpoBaseUrl>/_layouts/15/Doc.aspx?sourcedoc=%7B<one_notebookSourceDoc>%7D&action=edit&wd=target(<one_sectionName>%7C<one_sectionFileId>%2F)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
URL-encode the section name; double-encode the pipe (`%7C`) and trailing slash (`%2F`).
|
|
84
|
+
|
|
85
|
+
## Troubleshooting
|
|
86
|
+
|
|
87
|
+
- **Picker shown every run.** First-run only, and only if multiple work accounts are listed. After picking once it sticks.
|
|
88
|
+
- **"OneNote canvas frame did not attach."** Almost always auth — check the headless output's `runStatus`.
|
|
89
|
+
- **Bodies look short.** The `last_status: short-suspect` flag is for this. Verify in OneNote desktop; if genuinely sparse, accept; otherwise re-run.
|
|
90
|
+
- **Pages enumerate fine but no `webPageId` returned.** The `wd=target(...)` URL fragment shape may have changed. Update `parseWebPageIdFromUrl` regex in `runner.mjs`.
|