pi-scout 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -2
- package/CONTRIBUTING.md +8 -0
- package/README.md +68 -7
- package/SECURITY.md +16 -1
- package/extensions/index.ts +35 -12
- package/package.json +11 -6
- package/src/install-telemetry.ts +19 -46
- package/src/repo.ts +54 -21
- package/src/state.ts +29 -8
- package/src/tool-contracts.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,31 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.0] - 2026-10-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Return declared `{ repo }` / `{ removed, deletedClone }` structured results with public repo fields, `scout` namespace discovery, behavior annotations and sequential tool dispatch.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Store reference context in the named `scout_repos` prompt section using Pi's structured API. Remove empty sections, preserve unrelated prompt changes, and leave deliberate full-prompt overrides intact.
|
|
18
|
+
- Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
|
|
19
|
+
- Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Keep origin credentials out of tool text/details/results, inferred URL-based names and Git failure diagnostics; terminate Git options before source arguments.
|
|
24
|
+
- Preserve removal-tool deactivation across prompts/reload and respect explicit default-tool exclusions during conditional registration.
|
|
25
|
+
- Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
|
|
26
|
+
|
|
27
|
+
## [0.1.4] - 2026-07-17
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Store temporary clones in a private per-user directory and reject unsafe clone roots.
|
|
32
|
+
- Serialize repository state mutations and persist state through atomic file replacement.
|
|
33
|
+
|
|
9
34
|
## [0.1.3] - 2026-07-01
|
|
10
35
|
|
|
11
36
|
### Changed
|
|
@@ -18,13 +43,13 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
18
43
|
|
|
19
44
|
- Update `author` field to full name for monorepo consistency.
|
|
20
45
|
|
|
21
|
-
## [0.1.1] -
|
|
46
|
+
## [0.1.1] - 2026-05-22
|
|
22
47
|
|
|
23
48
|
### Added
|
|
24
49
|
|
|
25
50
|
- Add install/update telemetry ping to `mocito.dev`, gated by Pi telemetry/offline settings and disabled in CI.
|
|
26
51
|
|
|
27
|
-
## [0.1.0] -
|
|
52
|
+
## [0.1.0] - 2026-05-21
|
|
28
53
|
|
|
29
54
|
### Added
|
|
30
55
|
|
package/CONTRIBUTING.md
CHANGED
|
@@ -30,6 +30,14 @@ pi -e /path/to/pi-mono/packages/pi-scout --print "list your tools"
|
|
|
30
30
|
|
|
31
31
|
## Pull request checklist
|
|
32
32
|
|
|
33
|
+
For prompt changes, also run the shared real-session contracts from the monorepo root:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
node --import tsx --test tests/structured-prompts.test.mjs
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
These load both Scout and Skillful through Pi's SDK and inspect serialized provider requests and session history. They use disposable profiles, synthetic credentials, temporary reference directories, and mocked provider responses; they do not contact Git remotes, use a live model, or change existing Scout records. Coverage includes stale-reference pruning, section removal, extension load order, explicit prompt overrides, codemode, reload, resume, fork/tree navigation, and providers that collapse system updates.
|
|
40
|
+
|
|
33
41
|
Before opening a pull request:
|
|
34
42
|
|
|
35
43
|
- Run `npm run check`.
|
package/README.md
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
# pi-scout
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give [Pi](https://pi.dev) proven codebases to learn from before it changes yours.
|
|
4
|
+
|
|
5
|
+
`pi-scout` clones and registers reference repositories, then exposes their local paths to the agent for fast, tool-native exploration across sessions.
|
|
4
6
|
|
|
5
7
|
> [!WARNING]
|
|
6
8
|
> Pi packages can execute arbitrary code through extensions. Review package source before installing any third-party Pi package.
|
|
7
9
|
|
|
8
10
|
## Features
|
|
9
11
|
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
12
|
+
- **Reference-driven coding** — let Pi inspect real implementations, conventions, and patterns instead of guessing.
|
|
13
|
+
- **One-step registration** — add Git URLs, local paths, or GitHub `owner/repo` shorthand from `/scout` or natural-language requests.
|
|
14
|
+
- **Fast local exploration** — shallow-clone references into a reusable private cache compatible with Pi's normal file tools.
|
|
15
|
+
- **Cross-session memory** — keep registered references available while their cached clones exist.
|
|
16
|
+
- **Clean context** — tell the agent only which references exist and where to inspect them; stale clones are pruned automatically.
|
|
15
17
|
|
|
16
|
-
Registered repositories are cloned under
|
|
18
|
+
Registered repositories are cloned in a private, per-user directory under the OS temp directory (`<temp>/pi-scout-<uid>` on Unix-like systems). Root and clone permissions are restricted to the current user on Unix. Set `PI_SCOUT_TMPDIR` to override the parent temp directory. Pi Scout uses shallow clones with depth `1` by default because it is for code exploration, not history exploration. Pi Scout keeps records in Pi's agent directory and reuses them across sessions while the cloned directories still exist.
|
|
17
19
|
|
|
18
20
|
## Installation
|
|
19
21
|
|
|
@@ -42,6 +44,7 @@ pi -e /path/to/pi-mono/packages/pi-scout --print "list your tools"
|
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
This is an npm-compatible TypeScript Pi package. There is no runtime build step.
|
|
47
|
+
Use Pi 1.1.0 or newer and Node.js >=22.19.0 for the structured tool contracts.
|
|
45
48
|
|
|
46
49
|
## Configuration
|
|
47
50
|
|
|
@@ -74,6 +77,16 @@ Register https://github.com/owner/repo.git with Pi Scout, then inspect how it im
|
|
|
74
77
|
|
|
75
78
|
After a repository is registered, the agent sees its local path in the system prompt and can inspect it with local file tools.
|
|
76
79
|
|
|
80
|
+
### Prompt updates
|
|
81
|
+
|
|
82
|
+
Pi Scout uses the structured prompt API in Pi 1.1.0 or newer. It owns the `scout_repos` section and updates that section without replacing the full system prompt or other extensions' sections. When the last reference is removed or its directory disappears, the section is removed on the next prompt.
|
|
83
|
+
|
|
84
|
+
The section contains only repository names, local paths, and read-only usage guidance. Origin URLs, credentials, branch names, and other record metadata are not included.
|
|
85
|
+
|
|
86
|
+
A deliberate full-prompt override from another extension (`systemPrompt` or `forceSystemPrompt`) takes precedence. Scout does not append to or rewrite that override, so its author controls whether reference context is included. Scout tools remain available according to their normal registration rules.
|
|
87
|
+
|
|
88
|
+
Pi records section updates in the session transcript. Providers that do not support mid-conversation system changes may require a full prompt checkpoint; this does not guarantee cache savings.
|
|
89
|
+
|
|
77
90
|
## Tools
|
|
78
91
|
|
|
79
92
|
| Tool | Purpose |
|
|
@@ -81,12 +94,59 @@ After a repository is registered, the agent sees its local path in the system pr
|
|
|
81
94
|
| `scout_add` | Clone and register a Git repository as a local reference codebase. Takes only `source`: Git URL, local path, or GitHub `owner/repo` shorthand. |
|
|
82
95
|
| `scout_rm` | Remove a repository from Pi Scout records, optionally deleting the temporary clone. Available to the model only while repos are registered. |
|
|
83
96
|
|
|
97
|
+
### Script results and discovery
|
|
98
|
+
|
|
99
|
+
Both tools declare output schemas and return objects, not strings, in codemode:
|
|
100
|
+
|
|
101
|
+
- `scout_add`: `{ repo }`.
|
|
102
|
+
- `scout_rm`: `{ removed: repo | null, deletedClone: boolean }`.
|
|
103
|
+
- `repo`: `{ id, name, path, branch?, createdAt, lastSeenAt }`. Origin/source
|
|
104
|
+
metadata is deliberately excluded from text, structured results, and renderer
|
|
105
|
+
details. Pi still records caller arguments; use Git's credential mechanisms,
|
|
106
|
+
not credentials embedded in `source`.
|
|
107
|
+
|
|
108
|
+
After an explicit registration request:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const { repo } = await tools.scout_add({ source: "owner/repo" });
|
|
112
|
+
text({ id: repo.id, path: repo.path });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The namespace is `scout`; tool names are unchanged. Await
|
|
116
|
+
`describeNamespace("scout")`, `describeTool("scout_add")`, or
|
|
117
|
+
`searchTools("reference repository", { namespace: "scout" })` for discovery,
|
|
118
|
+
including with zero inline budget. When adding the first reference, start a
|
|
119
|
+
**new codemode call** before discovering/calling `scout_rm`: Pi snapshots the
|
|
120
|
+
callable tools at script start.
|
|
121
|
+
|
|
122
|
+
Clone/process/storage failures throw and reject scripted calls. A missing
|
|
123
|
+
removal target is successful `{ removed: null, deletedClone: false }` data.
|
|
124
|
+
`deletedClone: true` means deletion was requested and the removal completed;
|
|
125
|
+
filesystem errors throw, and state removal may already have happened. These tools
|
|
126
|
+
do not return a structured success object with `isError: true`.
|
|
127
|
+
Human-readable direct results and the `/scout` menu remain available without
|
|
128
|
+
codemode.
|
|
129
|
+
|
|
130
|
+
Both tools run sequentially within Pi's dispatch queue. Await dependent calls;
|
|
131
|
+
this is not a cross-process transaction. Addition is a non-idempotent local
|
|
132
|
+
mutation that may contact a Git host. Removal is destructive, non-idempotent
|
|
133
|
+
(repeated names can match different records), and local-only. These advisory
|
|
134
|
+
hints do not authorize cloning or deletion; existing approval hooks still run.
|
|
135
|
+
|
|
136
|
+
No optional deferred/codemode exposure setting is added. Direct activation,
|
|
137
|
+
CLI exclusions, and conditional removal availability remain in force. Explicit
|
|
138
|
+
`defaultTools: ["-scout_rm"]` suppresses automatic removal-tool activation.
|
|
139
|
+
Manually disabling it is retained across prompts and reload, while repos remain.
|
|
140
|
+
If the repo set becomes empty and later gains a reference, the normal availability
|
|
141
|
+
transition can activate it again. Use a CLI exclusion for a lasting prohibition.
|
|
142
|
+
|
|
84
143
|
## Notes
|
|
85
144
|
|
|
86
145
|
- On startup, Pi Scout sends a best-effort install/update telemetry ping once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI.
|
|
87
146
|
- Pi Scout uses local file access for exploration. It does not provide web search or remote content-fetching tools.
|
|
88
147
|
- Registering a Git URL still uses `git clone`, so Git may contact the configured remote.
|
|
89
148
|
- Registered repositories are intended as read-only references unless the user explicitly asks otherwise.
|
|
149
|
+
- Repository state changes are serialized and persisted with atomic file replacement.
|
|
90
150
|
- The system prompt includes only registered repo names and local paths, not origin URLs or branch metadata.
|
|
91
151
|
|
|
92
152
|
## Development
|
|
@@ -94,5 +154,6 @@ After a repository is registered, the agent sees its local path in the system pr
|
|
|
94
154
|
```bash
|
|
95
155
|
npm install
|
|
96
156
|
npm run check
|
|
157
|
+
npm test
|
|
97
158
|
npm run pack:dry-run
|
|
98
159
|
```
|
package/SECURITY.md
CHANGED
|
@@ -23,6 +23,21 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
|
|
|
23
23
|
|
|
24
24
|
Do not commit API keys, tokens, credentials, local settings, or machine-specific paths.
|
|
25
25
|
|
|
26
|
-
On startup,
|
|
26
|
+
On startup, `@mocito/install-telemetry` sends a best-effort install/update telemetry ping to the configured telemetry endpoint once per package version unless Pi telemetry is disabled, offline mode is enabled, or Pi runs in CI. The ping includes only the package name, version, and parsed platform/runtime/architecture from its User-Agent; it does not include prompts, repository sources, clone paths, config values, or API keys.
|
|
27
27
|
|
|
28
28
|
`pi-scout` stores repository records under Pi's agent directory and clones registered repositories into the OS temporary directory. It does not provide web search or content-fetching tools, but registering a Git URL uses `git clone`, which may contact the configured remote. Registered repository paths are appended to the system prompt so the agent can inspect them with local file tools.
|
|
29
|
+
|
|
30
|
+
Direct tool text, structured results and renderer details expose only repository
|
|
31
|
+
IDs, names, local paths, optional branches and timestamps—not origin/source
|
|
32
|
+
metadata. URL userinfo/query/fragment are not used to infer public clone names.
|
|
33
|
+
Git receives arguments without a shell and with an option terminator; clone
|
|
34
|
+
failures do not echo stderr or remote response bodies. This does not remove
|
|
35
|
+
caller-supplied origins from Pi's tool-call transcript, Scout's private records,
|
|
36
|
+
the local `/scout` listing, or Git configuration. Prefer Git's credential
|
|
37
|
+
mechanisms to embedding credentials in URLs.
|
|
38
|
+
|
|
39
|
+
Scout tool calls run sequentially within one Pi dispatcher; atomic state writes
|
|
40
|
+
remain separate from clone creation/deletion and are not a cross-process
|
|
41
|
+
transaction. Namespaces and behavior hints are not authorization. Registration
|
|
42
|
+
can contact remote hosts; removal can delete a clone when explicitly requested.
|
|
43
|
+
Normal approval hooks and tool exclusions remain effective.
|
package/extensions/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
4
4
|
import { buildScoutPrompt, formatRepo, loadPrunedState, registerRepo, removeRepo } from "../src/index.js";
|
|
5
|
+
import { ADD_REPO_OUTPUT, REMOVE_REPO_OUTPUT, SCOUT_NAMESPACE, formatPublicRepo, publicRepo } from "../src/tool-contracts.js";
|
|
5
6
|
|
|
6
7
|
const RegisterRepoParams = Type.Object({
|
|
7
8
|
source: Type.String({ description: "Git URL/path or owner/repo." }),
|
|
@@ -16,6 +17,7 @@ export default function piScout(pi: ExtensionAPI) {
|
|
|
16
17
|
reportInstallTelemetry();
|
|
17
18
|
|
|
18
19
|
let scoutRmRegistered = false;
|
|
20
|
+
let hadRepos = false;
|
|
19
21
|
|
|
20
22
|
function setToolActive(name: string, active: boolean): void {
|
|
21
23
|
const activeTools = pi.getActiveTools();
|
|
@@ -24,11 +26,20 @@ export default function piScout(pi: ExtensionAPI) {
|
|
|
24
26
|
if (!active && hasTool) pi.setActiveTools(activeTools.filter((tool) => tool !== name));
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
async function syncScoutRmTool(): Promise<void> {
|
|
29
|
+
async function syncScoutRmTool(restoring = false): Promise<void> {
|
|
28
30
|
const hasRepos = (await loadPrunedState()).repos.length > 0;
|
|
31
|
+
const selection = pi.getSettings?.().defaultTools;
|
|
32
|
+
const disabled = Array.isArray(selection)
|
|
33
|
+
&& selection.filter(entry => entry === "+scout_rm" || entry === "-scout_rm").at(-1) === "-scout_rm";
|
|
29
34
|
if (hasRepos && !scoutRmRegistered) {
|
|
30
35
|
pi.registerTool({
|
|
31
36
|
name: "scout_rm",
|
|
37
|
+
namespace: SCOUT_NAMESPACE,
|
|
38
|
+
outputSchema: REMOVE_REPO_OUTPUT,
|
|
39
|
+
executionMode: "sequential",
|
|
40
|
+
defaultActive: !restoring && !disabled,
|
|
41
|
+
// Repeating a name may remove another clone with that name.
|
|
42
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
32
43
|
label: "Scout Remove",
|
|
33
44
|
description: "Remove a Scout repo record.",
|
|
34
45
|
promptSnippet: "Remove Scout repo records.",
|
|
@@ -40,19 +51,26 @@ export default function piScout(pi: ExtensionAPI) {
|
|
|
40
51
|
const params = rawParams as { idOrName: string; deleteClone?: boolean };
|
|
41
52
|
const removed = await removeRepo(params.idOrName, { deleteClone: params.deleteClone });
|
|
42
53
|
await syncScoutRmTool();
|
|
54
|
+
const payload = { removed: removed ? publicRepo(removed) : null, deletedClone: Boolean(params.deleteClone && removed) };
|
|
43
55
|
const text = removed
|
|
44
|
-
? `Removed Pi Scout repository from records:\n${
|
|
45
|
-
:
|
|
46
|
-
return { content: [{ type: "text", text }],
|
|
56
|
+
? `Removed Pi Scout repository from records:\n${formatPublicRepo(removed)}\n\nLocal clone ${params.deleteClone ? "deleted" : "was not deleted"}.`
|
|
57
|
+
: "No Pi Scout repository matched.";
|
|
58
|
+
return { content: [{ type: "text", text }], structuredContent: payload, details: payload };
|
|
47
59
|
},
|
|
48
60
|
});
|
|
49
61
|
scoutRmRegistered = true;
|
|
50
62
|
}
|
|
51
|
-
if (scoutRmRegistered)
|
|
63
|
+
if (scoutRmRegistered) {
|
|
64
|
+
if (!hasRepos) setToolActive("scout_rm", false);
|
|
65
|
+
// Do not reactivate a manually disabled tool on every prompt. On reload,
|
|
66
|
+
// Pi restores pending active names; this extension only restores availability.
|
|
67
|
+
else if (!hadRepos && !restoring && !disabled) setToolActive("scout_rm", true);
|
|
68
|
+
}
|
|
69
|
+
hadRepos = hasRepos;
|
|
52
70
|
}
|
|
53
71
|
|
|
54
|
-
pi.on("session_start", async () => {
|
|
55
|
-
await syncScoutRmTool();
|
|
72
|
+
pi.on("session_start", async (event) => {
|
|
73
|
+
await syncScoutRmTool(event.reason === "reload");
|
|
56
74
|
});
|
|
57
75
|
|
|
58
76
|
pi.registerCommand("scout", {
|
|
@@ -64,6 +82,10 @@ export default function piScout(pi: ExtensionAPI) {
|
|
|
64
82
|
|
|
65
83
|
pi.registerTool({
|
|
66
84
|
name: "scout_add",
|
|
85
|
+
namespace: SCOUT_NAMESPACE,
|
|
86
|
+
outputSchema: ADD_REPO_OUTPUT,
|
|
87
|
+
executionMode: "sequential",
|
|
88
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
67
89
|
label: "Scout Add",
|
|
68
90
|
description: "Clone/register a reference repo.",
|
|
69
91
|
promptSnippet: "Add Scout reference repos.",
|
|
@@ -76,19 +98,20 @@ export default function piScout(pi: ExtensionAPI) {
|
|
|
76
98
|
const repo = await registerRepo(pi, { source: params.source, signal });
|
|
77
99
|
await syncScoutRmTool();
|
|
78
100
|
return {
|
|
79
|
-
content: [{ type: "text", text: `Registered Pi Scout repository:\n${
|
|
80
|
-
|
|
101
|
+
content: [{ type: "text", text: `Registered Pi Scout repository:\n${formatPublicRepo(repo)}` }],
|
|
102
|
+
structuredContent: { repo: publicRepo(repo) },
|
|
103
|
+
details: { repo: publicRepo(repo) },
|
|
81
104
|
};
|
|
82
105
|
},
|
|
83
106
|
});
|
|
84
107
|
|
|
85
|
-
|
|
86
108
|
pi.on("before_agent_start", async (event) => {
|
|
87
109
|
await syncScoutRmTool();
|
|
88
110
|
const state = await loadPrunedState();
|
|
89
111
|
const scoutPrompt = buildScoutPrompt(state.repos);
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
// Change only our section; a deliberate forceSystemPrompt remains authoritative.
|
|
113
|
+
if (scoutPrompt) event.systemPromptOptions.sections.scout_repos = scoutPrompt;
|
|
114
|
+
else delete event.systemPromptOptions.sections.scout_repos;
|
|
92
115
|
});
|
|
93
116
|
}
|
|
94
117
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-scout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Register local reference codebases for Pi agent exploration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"scripts": {
|
|
45
45
|
"check": "tsc --noEmit",
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "node --import tsx --test tests/*.test.mjs",
|
|
47
48
|
"pack:dry-run": "npm pack --dry-run"
|
|
48
49
|
},
|
|
49
50
|
"peerDependencies": {
|
|
@@ -51,15 +52,19 @@
|
|
|
51
52
|
"typebox": "*"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
|
-
"@earendil-works/pi-coding-agent": "
|
|
55
|
-
"@types/node": "^
|
|
56
|
-
"
|
|
57
|
-
"
|
|
55
|
+
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
56
|
+
"@types/node": "^26.6.3",
|
|
57
|
+
"tsx": "^4.23.15",
|
|
58
|
+
"typebox": "^1.3.34",
|
|
59
|
+
"typescript": "^7.0.2"
|
|
58
60
|
},
|
|
59
61
|
"publishConfig": {
|
|
60
62
|
"access": "public"
|
|
61
63
|
},
|
|
62
64
|
"engines": {
|
|
63
|
-
"node": ">=
|
|
65
|
+
"node": ">=22.19.0"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@mocito/install-telemetry": "0.1.1"
|
|
64
69
|
}
|
|
65
70
|
}
|
package/src/install-telemetry.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
7
|
const PACKAGE_NAME = "pi-scout";
|
|
8
|
-
const
|
|
9
|
-
const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
|
|
8
|
+
const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
|
|
10
9
|
const CI_ENVIRONMENT_VARIABLES = [
|
|
11
10
|
"APPVEYOR",
|
|
12
11
|
"BITBUCKET_BUILD_NUMBER",
|
|
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
|
|
|
24
23
|
"VERCEL",
|
|
25
24
|
];
|
|
26
25
|
|
|
27
|
-
interface InstallTelemetryState {
|
|
28
|
-
lastReportedVersion?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
26
|
interface PiSettingsDocument {
|
|
32
27
|
enableInstallTelemetry?: unknown;
|
|
33
28
|
}
|
|
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
51
46
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
function
|
|
55
|
-
if (isTruthyEnvFlag(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
60
|
-
if (isCiEnvironment()) return false;
|
|
61
|
-
if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
|
|
62
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
49
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
50
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
51
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
52
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
63
53
|
|
|
64
|
-
const settings = readJsonFile(
|
|
65
|
-
|
|
54
|
+
const settings = readJsonFile(settingsPath) as PiSettingsDocument;
|
|
55
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
56
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
57
|
+
return true;
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
function getPackageVersion(): string {
|
|
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
|
|
|
70
62
|
return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
|
|
71
63
|
}
|
|
72
64
|
|
|
73
|
-
function
|
|
74
|
-
const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
|
|
75
|
-
const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
|
|
76
|
-
return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async function reportInstallTelemetryAsync(): Promise<void> {
|
|
65
|
+
export function reportInstallTelemetry(): void {
|
|
80
66
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await mkdir(extensionsDir, { recursive: true });
|
|
90
|
-
await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
|
|
91
|
-
|
|
92
|
-
const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
|
|
93
|
-
await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
|
|
94
|
-
headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
|
|
95
|
-
signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
|
|
96
|
-
});
|
|
67
|
+
void report({
|
|
68
|
+
endpoint: INSTALL_TELEMETRY_ENDPOINT,
|
|
69
|
+
tool: PACKAGE_NAME,
|
|
70
|
+
version: getPackageVersion(),
|
|
71
|
+
statePath: join(getAgentDir(), "extensions", "pi-scout-install.json"),
|
|
72
|
+
enabled: isInstallTelemetryEnabled(),
|
|
73
|
+
}).catch(() => undefined);
|
|
97
74
|
} catch {
|
|
98
|
-
// Best-effort telemetry: ignore
|
|
75
|
+
// Best-effort telemetry: ignore local policy and filesystem failures.
|
|
99
76
|
}
|
|
100
77
|
}
|
|
101
|
-
|
|
102
|
-
export function reportInstallTelemetry(): void {
|
|
103
|
-
void reportInstallTelemetryAsync();
|
|
104
|
-
}
|
package/src/repo.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { mkdir, rm } from "node:fs/promises";
|
|
1
|
+
import { chmod, lstat, mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
3
|
import { platform, tmpdir } from "node:os";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import { mutateState, type ScoutRepo } from "./state.js";
|
|
7
7
|
|
|
8
8
|
export interface RegisterRepoOptions {
|
|
9
9
|
source: string;
|
|
@@ -21,19 +21,28 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
|
|
|
21
21
|
const name = sanitizeName(options.name?.trim() || inferName(source) || `repo-${id}`);
|
|
22
22
|
const root = getScoutCloneRoot();
|
|
23
23
|
const destination = join(root, `${name}-${id}`);
|
|
24
|
-
await
|
|
24
|
+
await ensurePrivateCloneRoot(root);
|
|
25
|
+
await mkdir(destination, { mode: 0o700 });
|
|
25
26
|
|
|
26
27
|
const args = ["clone"];
|
|
27
28
|
if (options.branch?.trim()) args.push("--branch", options.branch.trim());
|
|
28
29
|
const depth = options.depth && Number.isInteger(options.depth) && options.depth > 0 ? options.depth : 1;
|
|
29
30
|
args.push("--depth", String(depth));
|
|
30
|
-
args.push(source, destination);
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
args.push("--", source, destination);
|
|
32
|
+
|
|
33
|
+
let result;
|
|
34
|
+
try {
|
|
35
|
+
result = await pi.exec("git", args, { signal: options.signal, timeout: 120_000 });
|
|
36
|
+
} catch {
|
|
37
|
+
await rm(destination, { recursive: true, force: true });
|
|
38
|
+
throw new Error(options.signal?.aborted ? "Git clone cancelled." : "Git clone could not run. Check Git and authentication.");
|
|
39
|
+
}
|
|
33
40
|
if (result.code !== 0) {
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
await rm(destination, { recursive: true, force: true });
|
|
42
|
+
// Git can echo origin URLs, credentials or arbitrary remote output.
|
|
43
|
+
throw new Error(`Git clone failed (exit code ${result.code}). Check the source and Git authentication.`);
|
|
36
44
|
}
|
|
45
|
+
if (platform() !== "win32") await chmod(destination, 0o700);
|
|
37
46
|
|
|
38
47
|
const now = new Date().toISOString();
|
|
39
48
|
const repo: ScoutRepo = {
|
|
@@ -46,9 +55,9 @@ export async function registerRepo(pi: ExtensionAPI, options: RegisterRepoOption
|
|
|
46
55
|
lastSeenAt: now,
|
|
47
56
|
};
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
58
|
+
await mutateState((state) => {
|
|
59
|
+
state.repos.push(repo);
|
|
60
|
+
});
|
|
52
61
|
return repo;
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -56,12 +65,11 @@ export async function removeRepo(idOrName: string, options: { deleteClone?: bool
|
|
|
56
65
|
const needle = idOrName.trim();
|
|
57
66
|
if (!needle) return undefined;
|
|
58
67
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await saveState(state);
|
|
68
|
+
const removed = await mutateState((state) => {
|
|
69
|
+
const index = state.repos.findIndex((repo) => repo.id === needle || repo.name === needle);
|
|
70
|
+
if (index === -1) return undefined;
|
|
71
|
+
return state.repos.splice(index, 1)[0];
|
|
72
|
+
});
|
|
65
73
|
|
|
66
74
|
if (options.deleteClone && removed) {
|
|
67
75
|
await rm(removed.path, { recursive: true, force: true });
|
|
@@ -75,16 +83,41 @@ export function formatRepo(repo: ScoutRepo): string {
|
|
|
75
83
|
return `${repo.name}${branch}\n id: ${repo.id}\n source: ${repo.source}\n path: ${repo.path}`;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
function getScoutCloneRoot(): string {
|
|
79
|
-
|
|
80
|
-
if (platform()
|
|
81
|
-
return join(
|
|
86
|
+
export function getScoutCloneRoot(): string {
|
|
87
|
+
const parent = process.env.PI_SCOUT_TMPDIR || tmpdir();
|
|
88
|
+
if (platform() === "win32") return join(parent, "pi-scout");
|
|
89
|
+
return join(parent, `pi-scout-${getCurrentUid()}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function ensurePrivateCloneRoot(root: string): Promise<void> {
|
|
93
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
94
|
+
if (platform() === "win32") return;
|
|
95
|
+
|
|
96
|
+
const info = await lstat(root);
|
|
97
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
98
|
+
throw new Error(`Unsafe Pi Scout clone root: ${root} is not a real directory.`);
|
|
99
|
+
}
|
|
100
|
+
if (info.uid !== getCurrentUid()) {
|
|
101
|
+
throw new Error(`Unsafe Pi Scout clone root: ${root} is not owned by current user.`);
|
|
102
|
+
}
|
|
103
|
+
await chmod(root, 0o700);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getCurrentUid(): number {
|
|
107
|
+
if (!process.getuid) throw new Error("Pi Scout cannot determine current user ID.");
|
|
108
|
+
return process.getuid();
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
function inferName(source: string): string {
|
|
85
112
|
const shorthand = parseGitHubShorthand(source);
|
|
86
113
|
if (shorthand) return shorthand.repo;
|
|
87
114
|
|
|
115
|
+
// URL userinfo/query/fragment may contain credentials. Never use them as a
|
|
116
|
+
// public name or destination basename, including origins without a path.
|
|
117
|
+
if (source.includes("://")) {
|
|
118
|
+
try { source = new URL(source).pathname; }
|
|
119
|
+
catch { return "repo"; }
|
|
120
|
+
}
|
|
88
121
|
const withoutTrailingSlash = source.replace(/[\\/]+$/, "");
|
|
89
122
|
const last = basename(withoutTrailingSlash).replace(/\.git$/i, "");
|
|
90
123
|
return last || "repo";
|
package/src/state.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import {
|
|
1
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { getAgentDir, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
6
|
export interface ScoutRepo {
|
|
6
7
|
id: string;
|
|
@@ -36,7 +37,25 @@ export async function loadState(): Promise<ScoutState> {
|
|
|
36
37
|
|
|
37
38
|
export async function saveState(state: ScoutState): Promise<void> {
|
|
38
39
|
await mkdir(STATE_DIR, { recursive: true });
|
|
39
|
-
|
|
40
|
+
const temporaryPath = join(dirname(STATE_PATH), `.${basename(STATE_PATH)}.${randomUUID()}.tmp`);
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
43
|
+
await rename(temporaryPath, STATE_PATH);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function mutateState<T>(
|
|
51
|
+
mutation: (state: ScoutState) => Promise<T> | T,
|
|
52
|
+
): Promise<T> {
|
|
53
|
+
return withFileMutationQueue(STATE_PATH, async () => {
|
|
54
|
+
const state = (await pruneMissingRepos(await loadState())).state;
|
|
55
|
+
const result = await mutation(state);
|
|
56
|
+
await saveState(state);
|
|
57
|
+
return result;
|
|
58
|
+
});
|
|
40
59
|
}
|
|
41
60
|
|
|
42
61
|
export async function pruneMissingRepos(state: ScoutState): Promise<{ state: ScoutState; removed: ScoutRepo[] }> {
|
|
@@ -52,13 +71,15 @@ export async function pruneMissingRepos(state: ScoutState): Promise<{ state: Sco
|
|
|
52
71
|
}
|
|
53
72
|
}
|
|
54
73
|
|
|
55
|
-
|
|
56
|
-
if (removed.length > 0) await saveState(next);
|
|
57
|
-
return { state: next, removed };
|
|
74
|
+
return { state: { repos }, removed };
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
export async function loadPrunedState(): Promise<ScoutState> {
|
|
61
|
-
return (
|
|
78
|
+
return withFileMutationQueue(STATE_PATH, async () => {
|
|
79
|
+
const { state, removed } = await pruneMissingRepos(await loadState());
|
|
80
|
+
if (removed.length > 0) await saveState(state);
|
|
81
|
+
return state;
|
|
82
|
+
});
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
async function pathExists(path: string): Promise<boolean> {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ScoutRepo } from "./state.js";
|
|
3
|
+
|
|
4
|
+
export const SCOUT_NAMESPACE = {
|
|
5
|
+
name: "scout",
|
|
6
|
+
description: "Register and remove local read-only reference clones.",
|
|
7
|
+
instructions: "scout_add returns { repo }; scout_rm returns { removed: repo|null, deletedClone: boolean }. Public repo fields exclude origin/source metadata and credentials. Clone/process/storage failures reject; a missing removal target is successful data with removed:null. Both operations mutate state and run sequentially. Await dependent operations. After the first registration, start a new codemode call to discover/use scout_rm: the running script has a snapshot of callable tools. scout_add may access remote Git hosts. scout_rm can delete a clone when requested; use it only with user authorization. Repositories remain read-only references unless the user requests edits. Availability follows the active tool set; scout_rm is present only while references exist.",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const repo = Type.Object({
|
|
11
|
+
id: Type.String(), name: Type.String(), path: Type.String(),
|
|
12
|
+
branch: Type.Optional(Type.String()), createdAt: Type.String(), lastSeenAt: Type.String(),
|
|
13
|
+
}, { additionalProperties: false });
|
|
14
|
+
export const ADD_REPO_OUTPUT = Type.Object({ repo }, { additionalProperties: false });
|
|
15
|
+
export const REMOVE_REPO_OUTPUT = Type.Object({
|
|
16
|
+
removed: Type.Union([repo, Type.Null()]), deletedClone: Type.Boolean(),
|
|
17
|
+
}, { additionalProperties: false });
|
|
18
|
+
|
|
19
|
+
export function publicRepo(value: ScoutRepo) {
|
|
20
|
+
return {
|
|
21
|
+
id: value.id, name: value.name, path: value.path,
|
|
22
|
+
...(value.branch ? { branch: value.branch } : {}),
|
|
23
|
+
createdAt: value.createdAt, lastSeenAt: value.lastSeenAt,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatPublicRepo(value: ScoutRepo): string {
|
|
28
|
+
const repo = publicRepo(value);
|
|
29
|
+
return `${repo.name}${repo.branch ? ` (${repo.branch})` : ""}\n id: ${repo.id}\n path: ${repo.path}`;
|
|
30
|
+
}
|