takomi 2.1.34 → 2.1.35
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/.pi/extensions/takomi-runtime/context-panel.ts +37 -13
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +35 -5
- package/.pi/extensions/takomi-runtime/routing-policy.ts +35 -7
- package/.pi/extensions/takomi-runtime/takomi-stats.js +13 -3
- package/.pi/extensions/takomi-subagents/index.ts +3 -3
- package/.pi/extensions/takomi-subagents/native-render.ts +50 -5
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +32 -9
- package/.pi/extensions/takomi-subagents/tool-runner.ts +61 -15
- package/assets/.agent/skills/photo-book-builder/SKILL.md +51 -7
- package/package.json +18 -2
- package/src/cli.js +41 -11
- package/src/harness.js +12 -42
- package/src/owned-tree.js +86 -0
- package/src/pi-installer.js +12 -36
- package/src/store.js +3 -40
- package/src/takomi-stats.js +14 -3
- package/src/utils.js +81 -44
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: photo-book-builder
|
|
3
3
|
description: >
|
|
4
|
-
Automates high-resolution photo book creation, including chronological sorting, orientation balancing
|
|
4
|
+
Automates high-resolution photo book creation and correction passes, including chronological sorting, orientation balancing, print-ready full-bleed masonry layout generation, existing layout crop corrections, contact-sheet/cell mapping, and cover art upscaling. Use this skill when asked to: (1) Organize photos into page folders, (2) Generate or regenerate 12" x 30" open spreads (9000 x 3600 px at 300 DPI) with gutter margins, (3) Create or adapt full-bleed masonry grid layouts with project-specific photo counts, (4) Correct existing exports by raising/lowering images or adjusting per-image crop framing without overwriting originals, (5) Upscale front, back, or briefcase box covers using high-quality LANCZOS interpolation.
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Photo Book Builder
|
|
@@ -10,7 +10,7 @@ This skill provides a complete system for organizing, structuring, and generatin
|
|
|
10
10
|
|
|
11
11
|
## Core Capabilities
|
|
12
12
|
|
|
13
|
-
The photo book builder
|
|
13
|
+
The photo book builder supports five main stages:
|
|
14
14
|
|
|
15
15
|
```mermaid
|
|
16
16
|
graph TD
|
|
@@ -18,6 +18,8 @@ graph TD
|
|
|
18
18
|
B --> C[Stage 2 & 3: Layout Generation]
|
|
19
19
|
C -->|create_full_bleed_layouts.py| D[9000x3600 px Print Spreads]
|
|
20
20
|
E[Stage 4: Cover Upscaling] -->|upscale_covers.py| F[Print-Ready Cover JPEGs]
|
|
21
|
+
D --> G[Stage 5: Correction Pass]
|
|
22
|
+
G -->|custom renderer or patched generator| H[New Corrected Export Folder]
|
|
21
23
|
```
|
|
22
24
|
|
|
23
25
|
---
|
|
@@ -50,6 +52,8 @@ Layout configurations are determined dynamically based on photo counts and orien
|
|
|
50
52
|
* **Template 6A (6 Photos: 5L, 1P)**: Two landscape rows (4 total), one portrait + landscape col.
|
|
51
53
|
* **Template 6B (6 Photos: 4L, 2P)**: Two large landscape rows (2 total), one double-portrait + double-landscape col.
|
|
52
54
|
|
|
55
|
+
These templates are defaults, not hard limits. When a project already has a custom generator, page folders, or spreads with a different number of photos (for example 23-24 images per spread), preserve that project's generator and layout rules. Do not force the default 5/6-photo half-page templates onto an existing custom book.
|
|
56
|
+
|
|
53
57
|
For a complete coordinate reference and details on column configurations, see [layout_templates.md](references/layout_templates.md).
|
|
54
58
|
|
|
55
59
|
---
|
|
@@ -58,6 +62,8 @@ For a complete coordinate reference and details on column configurations, see [l
|
|
|
58
62
|
The layout compiler reads the page directories, detects photo orientations on the fly, applies the coordinate grids, crops photos to fill cells (using centered LANCZOS fit-scale), and merges them into $9000 \times 3600$ px JPEG spreads.
|
|
59
63
|
* **Page Swapping**: Alternating layout orientation avoids monotonous designs. Left page columns swap if `page_num % 2 == 0`, and right page columns swap if `page_num % 3 == 0`.
|
|
60
64
|
|
|
65
|
+
When regenerating an existing project, first inspect local scripts in the delivery/project folder. If a project-specific script exists, prefer adapting it over the bundled default scripts. Avoid rerunning any organizer/reorganizer that flattens or moves page folders unless the user explicitly asks for a new organization pass.
|
|
66
|
+
|
|
61
67
|
**Execution**:
|
|
62
68
|
Compile the print-ready layouts:
|
|
63
69
|
```bash
|
|
@@ -79,6 +85,44 @@ python scripts/upscale_covers.py --src "path/to/cover.png" --out "path/to/upscal
|
|
|
79
85
|
|
|
80
86
|
---
|
|
81
87
|
|
|
88
|
+
### Stage 5: Existing Layout Correction Pass
|
|
89
|
+
Use this when the user provides screenshots or notes such as "blue means raise the image" and "red means take it down" for already-exported spreads.
|
|
90
|
+
|
|
91
|
+
**Correction workflow**:
|
|
92
|
+
1. Identify the current source workspace, existing export folder, and correction screenshots.
|
|
93
|
+
2. Inspect the local layout generator and `photo_organization_map.json` if present.
|
|
94
|
+
3. Create a contact sheet or map preview that overlays each cell with its source filename and rectangle. This prevents guessing from screenshots.
|
|
95
|
+
4. Translate annotations into per-image corrections:
|
|
96
|
+
* Blue / "raise image up": move the visible subject up in the cell. In crop-ratio terms this usually means increasing vertical crop offset when there is extra source height below the crop.
|
|
97
|
+
* Red / "take it down": move the visible subject down in the cell. In crop-ratio terms this usually means decreasing vertical crop offset when there is extra source height above the crop.
|
|
98
|
+
* If the crop is already pinned at the top or bottom, use a small top-anchored or bottom-anchored overscale instead of adding filler or painting pixels. Start tiny, e.g. `1.006` to `1.012`; larger values can overshoot.
|
|
99
|
+
5. Render to a new export folder. Never overwrite the original export unless the user explicitly asks.
|
|
100
|
+
6. Verify file count, output dimensions, and a reduced review sheet of corrected spreads.
|
|
101
|
+
|
|
102
|
+
**Per-image correction patterns**:
|
|
103
|
+
```python
|
|
104
|
+
CROP_OVERRIDES = {
|
|
105
|
+
"DSC01234.jpg": {"y_ratio": 0.30}, # raise visible subject
|
|
106
|
+
"DSC05678.jpg": {"y_ratio": 0.00}, # lower visible subject
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For edge-pinned images where ratio changes cannot move the frame:
|
|
111
|
+
```python
|
|
112
|
+
TOP_ANCHORED_OVERSCALE = {
|
|
113
|
+
"DSC01234.jpg": 1.010, # tiny downward nudge without filler
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Keep corrections local to filenames/cells. Avoid global crop changes unless every affected image needs the same behavior.
|
|
118
|
+
|
|
119
|
+
**Safety rules for correction passes**:
|
|
120
|
+
* Preserve existing page folders and mapping files.
|
|
121
|
+
* Prefer a renderer that reads existing pages and writes new spreads over a script that reorganizes files.
|
|
122
|
+
* Use output folder names like `10 - layouts - crop-corrections-YYYYMMDD`.
|
|
123
|
+
* Keep a short correction log with source filenames, correction type, output folder, and verification result.
|
|
124
|
+
* On Windows, avoid giant one-shot patches or command lines; keep scripts and path lists small.
|
|
125
|
+
|
|
82
126
|
## Revert Option
|
|
83
127
|
If you need to restart the process or undo the page folder classification, run the revert tool to move photos back to the root workspace directory and remove empty page folders:
|
|
84
128
|
```bash
|
|
@@ -88,9 +132,9 @@ python scripts/revert_organization.py --workspace-dir "path/to/workspace"
|
|
|
88
132
|
## Bundled Resources
|
|
89
133
|
|
|
90
134
|
* **Scripts**:
|
|
91
|
-
* [organize_photos.py](
|
|
92
|
-
* [create_full_bleed_layouts.py](
|
|
93
|
-
* [upscale_covers.py](
|
|
94
|
-
* [revert_organization.py](
|
|
135
|
+
* [organize_photos.py](scripts/organize_photos.py) - Groups and balances photo directories.
|
|
136
|
+
* [create_full_bleed_layouts.py](scripts/create_full_bleed_layouts.py) - Assembles masonry grid pages into print-ready spreads.
|
|
137
|
+
* [upscale_covers.py](scripts/upscale_covers.py) - General-purpose upscaling script.
|
|
138
|
+
* [revert_organization.py](scripts/revert_organization.py) - Restores files back to flat source directories.
|
|
95
139
|
* **References**:
|
|
96
|
-
* [layout_templates.md](
|
|
140
|
+
* [layout_templates.md](references/layout_templates.md) - Exact coordinates, dimensions, and grid cells.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "takomi",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.35",
|
|
4
4
|
"description": "🎯 Stop wrestling with AI. Start building with purpose. The artisan's toolkit for agent workflows, Codex skills, and original Takomi capabilities like 21st.dev integration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"postinstall": "node src/postinstall.js",
|
|
26
|
-
"test": "
|
|
26
|
+
"test": "npm run test:typecheck && npm run test:regressions && npm run test:skills",
|
|
27
|
+
"test:typecheck": "tsc --noEmit",
|
|
28
|
+
"test:regressions": "node scripts/test-regressions.js",
|
|
27
29
|
"test:skills": "node scripts/test-skill-selection.js"
|
|
28
30
|
},
|
|
29
31
|
"repository": {
|
|
@@ -52,6 +54,20 @@
|
|
|
52
54
|
"picocolors": "^1.1.1",
|
|
53
55
|
"prompts": "^2.4.2"
|
|
54
56
|
},
|
|
57
|
+
"overrides": {
|
|
58
|
+
"protobufjs": "7.6.3",
|
|
59
|
+
"@protobufjs/utf8": "1.1.1",
|
|
60
|
+
"ws": "8.21.0",
|
|
61
|
+
"brace-expansion": "5.0.6"
|
|
62
|
+
},
|
|
63
|
+
"pnpm": {
|
|
64
|
+
"overrides": {
|
|
65
|
+
"protobufjs": "7.6.3",
|
|
66
|
+
"@protobufjs/utf8": "1.1.1",
|
|
67
|
+
"ws": "8.21.0",
|
|
68
|
+
"brace-expansion": "5.0.6"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
55
71
|
"bugs": {
|
|
56
72
|
"url": "https://github.com/JStaRFilms/VibeCode-Protocol-Suite/issues"
|
|
57
73
|
},
|
package/src/cli.js
CHANGED
|
@@ -928,11 +928,14 @@ async function add(url) {
|
|
|
928
928
|
console.log(pc.cyan('📡 Fetching from GitHub...\n'));
|
|
929
929
|
|
|
930
930
|
// Try assets/.agent/skills/ first (Takomi structure)
|
|
931
|
+
const githubOptions = { repo: repoSlug };
|
|
932
|
+
|
|
931
933
|
let skillCount = await downloadDirectoryFromGitHub(
|
|
932
934
|
'assets/.agent/skills',
|
|
933
935
|
skillsDest,
|
|
934
936
|
null,
|
|
935
|
-
250
|
|
937
|
+
250,
|
|
938
|
+
githubOptions
|
|
936
939
|
);
|
|
937
940
|
|
|
938
941
|
// Fallback: try .agent/skills/
|
|
@@ -941,7 +944,8 @@ async function add(url) {
|
|
|
941
944
|
'.agent/skills',
|
|
942
945
|
skillsDest,
|
|
943
946
|
null,
|
|
944
|
-
250
|
|
947
|
+
250,
|
|
948
|
+
githubOptions
|
|
945
949
|
);
|
|
946
950
|
}
|
|
947
951
|
|
|
@@ -950,7 +954,8 @@ async function add(url) {
|
|
|
950
954
|
'assets/.agent/workflows',
|
|
951
955
|
workflowsDest,
|
|
952
956
|
(item) => item.name.endsWith('.md'),
|
|
953
|
-
250
|
|
957
|
+
250,
|
|
958
|
+
githubOptions
|
|
954
959
|
);
|
|
955
960
|
|
|
956
961
|
if (workflowCount === 0) {
|
|
@@ -958,7 +963,8 @@ async function add(url) {
|
|
|
958
963
|
'.agent/workflows',
|
|
959
964
|
workflowsDest,
|
|
960
965
|
(item) => item.name.endsWith('.md'),
|
|
961
|
-
250
|
|
966
|
+
250,
|
|
967
|
+
githubOptions
|
|
962
968
|
);
|
|
963
969
|
}
|
|
964
970
|
|
|
@@ -1050,19 +1056,30 @@ async function updateProjectResources() {
|
|
|
1050
1056
|
if (!response.components || response.components.length === 0) return;
|
|
1051
1057
|
|
|
1052
1058
|
const destRoot = process.cwd();
|
|
1059
|
+
let remoteDownloadCount = 0;
|
|
1060
|
+
let remoteUpdateRequested = false;
|
|
1061
|
+
const remoteFailures = [];
|
|
1062
|
+
const runRemoteUpdate = async (label, updater) => {
|
|
1063
|
+
remoteUpdateRequested = true;
|
|
1064
|
+
try {
|
|
1065
|
+
remoteDownloadCount += await updater();
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
remoteFailures.push(`${label}: ${error.message}`);
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1053
1070
|
|
|
1054
1071
|
if (response.components.includes('agent')) {
|
|
1055
1072
|
const agentDest = path.join(destRoot, '.agent');
|
|
1056
|
-
await updateWorkflows(path.join(agentDest, 'workflows'));
|
|
1057
|
-
await updateSkills(path.join(agentDest, 'skills'));
|
|
1073
|
+
await runRemoteUpdate('workflows', () => updateWorkflows(path.join(agentDest, 'workflows')));
|
|
1074
|
+
await runRemoteUpdate('skills', () => updateSkills(path.join(agentDest, 'skills')));
|
|
1058
1075
|
}
|
|
1059
1076
|
|
|
1060
1077
|
if (response.components.includes('yamls')) {
|
|
1061
|
-
await updateAgentYamls(path.join(destRoot, 'Takomi-Agents'));
|
|
1078
|
+
await runRemoteUpdate('agent YAMLs', () => updateAgentYamls(path.join(destRoot, 'Takomi-Agents')));
|
|
1062
1079
|
}
|
|
1063
1080
|
|
|
1064
1081
|
if (response.components.includes('legacy')) {
|
|
1065
|
-
await updateLegacyManual(path.join(destRoot, 'Legacy-Protocols'));
|
|
1082
|
+
await runRemoteUpdate('legacy protocols', () => updateLegacyManual(path.join(destRoot, 'Legacy-Protocols')));
|
|
1066
1083
|
}
|
|
1067
1084
|
|
|
1068
1085
|
// Update global store if selected
|
|
@@ -1089,6 +1106,19 @@ async function updateProjectResources() {
|
|
|
1089
1106
|
await writeManifest(manifest);
|
|
1090
1107
|
}
|
|
1091
1108
|
|
|
1109
|
+
if (remoteFailures.length) {
|
|
1110
|
+
console.log(pc.red('\nSome project resources failed to download from GitHub:'));
|
|
1111
|
+
for (const failure of remoteFailures) console.log(pc.dim(` - ${failure}`));
|
|
1112
|
+
process.exitCode = 1;
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (remoteUpdateRequested && remoteDownloadCount === 0) {
|
|
1117
|
+
console.log(pc.red('\nNo project resources were downloaded from GitHub. Check the network or repository paths.'));
|
|
1118
|
+
process.exitCode = 1;
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1092
1122
|
console.log(pc.magenta('\n✨ Your toolkit is fresh and ready to ship.'));
|
|
1093
1123
|
}
|
|
1094
1124
|
|
|
@@ -1116,7 +1146,7 @@ Examples:
|
|
|
1116
1146
|
takomi refresh pi Refresh only Pi-related pieces
|
|
1117
1147
|
|
|
1118
1148
|
Legacy aliases still work:
|
|
1119
|
-
install -> setup, sync
|
|
1149
|
+
install -> setup, sync -> sync, upgrade -> refresh, init -> setup project,
|
|
1120
1150
|
harnesses -> status, update -> refresh project
|
|
1121
1151
|
`);
|
|
1122
1152
|
|
|
@@ -1171,8 +1201,8 @@ program
|
|
|
1171
1201
|
// Re-sync (legacy alias)
|
|
1172
1202
|
program
|
|
1173
1203
|
.command('sync [target]', { hidden: true })
|
|
1174
|
-
.description('Legacy alias
|
|
1175
|
-
.action(
|
|
1204
|
+
.description('Legacy alias for global store sync')
|
|
1205
|
+
.action(sync);
|
|
1176
1206
|
|
|
1177
1207
|
// Add remote skills (NEW)
|
|
1178
1208
|
program
|
package/src/harness.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import crypto from 'crypto';
|
|
5
4
|
import pc from 'picocolors';
|
|
5
|
+
import { hashPath, normalizeOwnedMap, copyOwnedTree } from './owned-tree.js';
|
|
6
6
|
|
|
7
7
|
// ─── Cross-Platform Home Directory ───────────────────────────────────────────
|
|
8
8
|
const HOME = os.homedir();
|
|
@@ -170,44 +170,6 @@ export function printHarnessStatus(detected) {
|
|
|
170
170
|
* @param {string} label - Human-readable label for logging
|
|
171
171
|
* @returns {Promise<number>} - Number of items copied
|
|
172
172
|
*/
|
|
173
|
-
function sha256(value) {
|
|
174
|
-
return crypto.createHash('sha256').update(value).digest('hex');
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
async function hashPath(targetPath) {
|
|
178
|
-
if (!await fs.pathExists(targetPath)) return null;
|
|
179
|
-
const stat = await fs.stat(targetPath);
|
|
180
|
-
if (stat.isFile()) return sha256(await fs.readFile(targetPath));
|
|
181
|
-
|
|
182
|
-
const entries = [];
|
|
183
|
-
async function walk(current, prefix = '') {
|
|
184
|
-
const names = (await fs.readdir(current)).sort();
|
|
185
|
-
for (const name of names) {
|
|
186
|
-
const full = path.join(current, name);
|
|
187
|
-
const rel = path.join(prefix, name).replace(/\\/g, '/');
|
|
188
|
-
const st = await fs.stat(full);
|
|
189
|
-
if (st.isDirectory()) {
|
|
190
|
-
entries.push(`dir:${rel}`);
|
|
191
|
-
await walk(full, rel);
|
|
192
|
-
} else {
|
|
193
|
-
entries.push(`file:${rel}:${sha256(await fs.readFile(full))}`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
await walk(targetPath);
|
|
198
|
-
return sha256(entries.join('\n'));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function normalizeOwnedMap(value) {
|
|
202
|
-
if (!value || typeof value !== 'object') return {};
|
|
203
|
-
const normalized = {};
|
|
204
|
-
for (const [name, entry] of Object.entries(value)) {
|
|
205
|
-
if (typeof entry === 'string') normalized[name] = { hash: entry };
|
|
206
|
-
else if (entry?.hash) normalized[name] = entry;
|
|
207
|
-
}
|
|
208
|
-
return normalized;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
173
|
/**
|
|
212
174
|
* Syncs a directory to a target path. When ownership is supplied, it also
|
|
213
175
|
* prunes previous Takomi-owned items that are no longer in the source while
|
|
@@ -263,7 +225,7 @@ export async function syncDirectory(sourcePath, targetPath, label = '', options
|
|
|
263
225
|
await fs.remove(dest);
|
|
264
226
|
}
|
|
265
227
|
|
|
266
|
-
await
|
|
228
|
+
await copyOwnedTree(src, dest);
|
|
267
229
|
nextOwned[item] = {
|
|
268
230
|
hash: await hashPath(dest),
|
|
269
231
|
targetPath: dest,
|
|
@@ -285,7 +247,7 @@ export async function syncDirectory(sourcePath, targetPath, label = '', options
|
|
|
285
247
|
if (label) {
|
|
286
248
|
console.log(pc.red(` ✗ ${label}: ${error.message}`));
|
|
287
249
|
}
|
|
288
|
-
return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned:
|
|
250
|
+
return options.returnDetails ? { copied: 0, pruned: 0, preservedManual: [], preservedModified: [], owned: normalizeOwnedMap(options.owned), ok: false, error: error.message } : 0;
|
|
289
251
|
}
|
|
290
252
|
}
|
|
291
253
|
|
|
@@ -329,8 +291,8 @@ export async function syncFile(sourceFile, targetPaths, label = '') {
|
|
|
329
291
|
* @returns {Promise<{skills: number, workflows: number, yamls: number}>}
|
|
330
292
|
*/
|
|
331
293
|
export async function syncToHarness(harness, storePath, options = {}) {
|
|
332
|
-
const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: {}, workflows: {} } };
|
|
333
294
|
const harnessOwned = options.owned?.[harness.id] || {};
|
|
295
|
+
const results = { skills: 0, workflows: 0, yamls: 0, owned: { skills: normalizeOwnedMap(harnessOwned.skills), workflows: normalizeOwnedMap(harnessOwned.workflows) }, ok: true, errors: [] };
|
|
334
296
|
const useOwnership = Boolean(options.useOwnership);
|
|
335
297
|
|
|
336
298
|
console.log(pc.cyan(`\n 📡 Syncing to ${harness.name}...`));
|
|
@@ -352,6 +314,10 @@ export async function syncToHarness(harness, storePath, options = {}) {
|
|
|
352
314
|
if (useOwnership) {
|
|
353
315
|
results.skills = skillResult.copied;
|
|
354
316
|
results.owned.skills = skillResult.owned;
|
|
317
|
+
if (skillResult.ok === false) {
|
|
318
|
+
results.ok = false;
|
|
319
|
+
results.errors.push(skillResult.error);
|
|
320
|
+
}
|
|
355
321
|
} else {
|
|
356
322
|
results.skills = skillResult;
|
|
357
323
|
}
|
|
@@ -374,6 +340,10 @@ export async function syncToHarness(harness, storePath, options = {}) {
|
|
|
374
340
|
if (useOwnership) {
|
|
375
341
|
results.workflows = workflowResult.copied;
|
|
376
342
|
results.owned.workflows = workflowResult.owned;
|
|
343
|
+
if (workflowResult.ok === false) {
|
|
344
|
+
results.ok = false;
|
|
345
|
+
results.errors.push(workflowResult.error);
|
|
346
|
+
}
|
|
377
347
|
} else {
|
|
378
348
|
results.workflows = workflowResult;
|
|
379
349
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import { createReadStream } from 'fs';
|
|
5
|
+
|
|
6
|
+
export function sha256(value) {
|
|
7
|
+
return crypto.createHash('sha256').update(value).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function hashFile(filePath) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const hash = crypto.createHash('sha256');
|
|
13
|
+
const stream = createReadStream(filePath);
|
|
14
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
15
|
+
stream.on('error', reject);
|
|
16
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hash a file tree without following symlinks.
|
|
22
|
+
*
|
|
23
|
+
* Symlinks are represented by their link target string instead of the linked
|
|
24
|
+
* file contents. This prevents store/harness reconciliation from recursively
|
|
25
|
+
* reading outside the intended tree or looping through symlink cycles.
|
|
26
|
+
*/
|
|
27
|
+
export async function hashPath(targetPath) {
|
|
28
|
+
if (!await fs.pathExists(targetPath)) return null;
|
|
29
|
+
|
|
30
|
+
const rootStat = await fs.lstat(targetPath);
|
|
31
|
+
if (rootStat.isSymbolicLink()) return sha256(`symlink:${await fs.readlink(targetPath).catch(() => '')}`);
|
|
32
|
+
if (rootStat.isFile()) return hashFile(targetPath);
|
|
33
|
+
if (!rootStat.isDirectory()) return sha256(`other:${rootStat.mode}:${rootStat.size}`);
|
|
34
|
+
|
|
35
|
+
const entries = [];
|
|
36
|
+
async function walk(current, prefix = '') {
|
|
37
|
+
const names = (await fs.readdir(current)).sort();
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const full = path.join(current, name);
|
|
40
|
+
const rel = path.join(prefix, name).replace(/\\/g, '/');
|
|
41
|
+
const stat = await fs.lstat(full);
|
|
42
|
+
if (stat.isSymbolicLink()) {
|
|
43
|
+
const linkTarget = await fs.readlink(full).catch(() => '');
|
|
44
|
+
entries.push(`symlink:${rel}:${linkTarget}`);
|
|
45
|
+
} else if (stat.isDirectory()) {
|
|
46
|
+
entries.push(`dir:${rel}`);
|
|
47
|
+
await walk(full, rel);
|
|
48
|
+
} else if (stat.isFile()) {
|
|
49
|
+
entries.push(`file:${rel}:${await hashFile(full)}`);
|
|
50
|
+
} else {
|
|
51
|
+
entries.push(`other:${rel}:${stat.mode}:${stat.size}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
await walk(targetPath);
|
|
56
|
+
return sha256(entries.join('\n'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function normalizeOwnedMap(value) {
|
|
60
|
+
if (!value || typeof value !== 'object') return {};
|
|
61
|
+
const normalized = {};
|
|
62
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
63
|
+
if (typeof entry === 'string') normalized[name] = { hash: entry };
|
|
64
|
+
else if (entry?.hash) normalized[name] = entry;
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function copyOwnedTree(src, dest, options = {}) {
|
|
70
|
+
const { allowSymlinks = false, filter, ...copyOptions } = options;
|
|
71
|
+
await fs.copy(src, dest, {
|
|
72
|
+
overwrite: true,
|
|
73
|
+
errorOnExist: false,
|
|
74
|
+
dereference: false,
|
|
75
|
+
...copyOptions,
|
|
76
|
+
filter: async (candidate) => {
|
|
77
|
+
if (filter && !await filter(candidate)) return false;
|
|
78
|
+
if (allowSymlinks) return true;
|
|
79
|
+
const stat = await fs.lstat(candidate).catch(() => null);
|
|
80
|
+
if (stat?.isSymbolicLink()) {
|
|
81
|
+
throw new Error(`Refusing to copy symlink in managed tree: ${candidate}`);
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
package/src/pi-installer.js
CHANGED
|
@@ -1,47 +1,15 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import crypto from 'crypto';
|
|
5
4
|
import pc from 'picocolors';
|
|
6
5
|
import { PATHS } from './utils.js';
|
|
7
6
|
import { getPiGlobalTargets } from './pi-harness.js';
|
|
7
|
+
import { hashPath, copyOwnedTree } from './owned-tree.js';
|
|
8
8
|
|
|
9
9
|
const HOME = os.homedir();
|
|
10
10
|
const TAKOMI_HOME = path.join(HOME, '.takomi');
|
|
11
11
|
export const PI_MANIFEST_PATH = path.join(TAKOMI_HOME, 'pi-manifest.json');
|
|
12
12
|
|
|
13
|
-
function sha256(value) {
|
|
14
|
-
return crypto.createHash('sha256').update(value).digest('hex');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function hashPath(targetPath) {
|
|
18
|
-
if (!await fs.pathExists(targetPath)) return null;
|
|
19
|
-
const stat = await fs.stat(targetPath);
|
|
20
|
-
if (stat.isFile()) {
|
|
21
|
-
const buf = await fs.readFile(targetPath);
|
|
22
|
-
return sha256(buf);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const entries = [];
|
|
26
|
-
async function walk(dir, prefix = '') {
|
|
27
|
-
const names = (await fs.readdir(dir)).sort();
|
|
28
|
-
for (const name of names) {
|
|
29
|
-
const full = path.join(dir, name);
|
|
30
|
-
const rel = path.join(prefix, name).replace(/\\/g, '/');
|
|
31
|
-
const st = await fs.stat(full);
|
|
32
|
-
if (st.isDirectory()) {
|
|
33
|
-
entries.push(`dir:${rel}`);
|
|
34
|
-
await walk(full, rel);
|
|
35
|
-
} else {
|
|
36
|
-
const buf = await fs.readFile(full);
|
|
37
|
-
entries.push(`file:${rel}:${sha256(buf)}`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
await walk(targetPath);
|
|
42
|
-
return sha256(entries.join('\n'));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
13
|
async function pathIsSameSymlink(dest, src) {
|
|
46
14
|
try {
|
|
47
15
|
const stat = await fs.lstat(dest);
|
|
@@ -69,7 +37,7 @@ async function copyOwnedDirectory(src, dest) {
|
|
|
69
37
|
await fs.ensureDir(path.dirname(dest));
|
|
70
38
|
const mode = await prepareOwnedTarget(src, dest);
|
|
71
39
|
if (mode === 'symlink') return hashPath(src);
|
|
72
|
-
await
|
|
40
|
+
await copyOwnedTree(src, dest);
|
|
73
41
|
return hashPath(dest);
|
|
74
42
|
}
|
|
75
43
|
|
|
@@ -77,7 +45,7 @@ async function copyOwnedFile(src, dest) {
|
|
|
77
45
|
await fs.ensureDir(path.dirname(dest));
|
|
78
46
|
const mode = await prepareOwnedTarget(src, dest);
|
|
79
47
|
if (mode === 'symlink') return hashPath(src);
|
|
80
|
-
await
|
|
48
|
+
await copyOwnedTree(src, dest);
|
|
81
49
|
return hashPath(dest);
|
|
82
50
|
}
|
|
83
51
|
|
|
@@ -96,6 +64,8 @@ export async function writePiInstallManifest(manifest) {
|
|
|
96
64
|
export async function installPiHarnessAssets(version = 'unknown') {
|
|
97
65
|
const srcRoot = PATHS.pi;
|
|
98
66
|
const targets = getPiGlobalTargets();
|
|
67
|
+
const previousSettingsHash = await hashPath(targets.settings);
|
|
68
|
+
const previousRoutingPolicyHash = await hashPath(targets.routingPolicy);
|
|
99
69
|
|
|
100
70
|
const copied = {};
|
|
101
71
|
|
|
@@ -153,7 +123,9 @@ export async function installPiHarnessAssets(version = 'unknown') {
|
|
|
153
123
|
owned: copied,
|
|
154
124
|
preserved: {
|
|
155
125
|
settings: targets.settings,
|
|
126
|
+
settingsHash: previousSettingsHash,
|
|
156
127
|
routingPolicy: targets.routingPolicy,
|
|
128
|
+
routingPolicyHash: previousRoutingPolicyHash,
|
|
157
129
|
userStateDir: targets.takomi,
|
|
158
130
|
},
|
|
159
131
|
};
|
|
@@ -168,6 +140,9 @@ export async function syncPiHarnessAssets(version = 'unknown') {
|
|
|
168
140
|
|
|
169
141
|
export async function validatePiHarnessInstall() {
|
|
170
142
|
const targets = getPiGlobalTargets();
|
|
143
|
+
const manifest = await readPiInstallManifest();
|
|
144
|
+
const currentSettingsHash = await hashPath(targets.settings);
|
|
145
|
+
const currentRoutingPolicyHash = await hashPath(targets.routingPolicy);
|
|
171
146
|
return {
|
|
172
147
|
runtime: await fs.pathExists(path.join(targets.extensions, 'takomi-runtime')),
|
|
173
148
|
subagents: await fs.pathExists(path.join(targets.extensions, 'takomi-subagents')),
|
|
@@ -180,7 +155,8 @@ export async function validatePiHarnessInstall() {
|
|
|
180
155
|
readme: await fs.pathExists(path.join(targets.root, 'README.md')),
|
|
181
156
|
core: await fs.pathExists(path.join(path.dirname(targets.root), 'src', 'pi-takomi-core')),
|
|
182
157
|
piSubagentsModule: await fs.pathExists(path.join(targets.root, 'node_modules', 'pi-subagents')),
|
|
183
|
-
settingsPreserved: !
|
|
158
|
+
settingsPreserved: !manifest || manifest.preserved?.settingsHash === currentSettingsHash,
|
|
159
|
+
routingPolicyPreserved: !manifest || manifest.preserved?.routingPolicyHash === currentRoutingPolicyHash,
|
|
184
160
|
};
|
|
185
161
|
}
|
|
186
162
|
|
package/src/store.js
CHANGED
|
@@ -1,53 +1,16 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import crypto from 'crypto';
|
|
5
4
|
import pc from 'picocolors';
|
|
6
5
|
import { PATHS } from './utils.js';
|
|
7
6
|
import { CORE_SKILLS } from './skills-catalog.js';
|
|
7
|
+
import { hashPath as hashDirectory, normalizeOwnedMap, copyOwnedTree } from './owned-tree.js';
|
|
8
8
|
|
|
9
9
|
// ─── Global Store Path ───────────────────────────────────────────────────────
|
|
10
10
|
const HOME = os.homedir();
|
|
11
11
|
export const STORE_PATH = process.env.TAKOMI_STORE_PATH || process.env.TAKOMI_HOME_DIR || path.join(HOME, '.takomi');
|
|
12
12
|
export const MANIFEST_PATH = path.join(STORE_PATH, 'manifest.json');
|
|
13
13
|
|
|
14
|
-
function sha256(value) {
|
|
15
|
-
return crypto.createHash('sha256').update(value).digest('hex');
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function hashDirectory(dir) {
|
|
19
|
-
if (!await fs.pathExists(dir)) return null;
|
|
20
|
-
const rootStat = await fs.stat(dir);
|
|
21
|
-
if (rootStat.isFile()) return sha256(await fs.readFile(dir));
|
|
22
|
-
const entries = [];
|
|
23
|
-
async function walk(current, prefix = '') {
|
|
24
|
-
const names = (await fs.readdir(current)).sort();
|
|
25
|
-
for (const name of names) {
|
|
26
|
-
const full = path.join(current, name);
|
|
27
|
-
const rel = path.join(prefix, name).replace(/\\/g, '/');
|
|
28
|
-
const stat = await fs.stat(full);
|
|
29
|
-
if (stat.isDirectory()) {
|
|
30
|
-
entries.push(`dir:${rel}`);
|
|
31
|
-
await walk(full, rel);
|
|
32
|
-
} else {
|
|
33
|
-
entries.push(`file:${rel}:${sha256(await fs.readFile(full))}`);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
await walk(dir);
|
|
38
|
-
return sha256(entries.join('\n'));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function normalizeOwnedMap(value) {
|
|
42
|
-
if (!value || typeof value !== 'object') return {};
|
|
43
|
-
const normalized = {};
|
|
44
|
-
for (const [name, entry] of Object.entries(value)) {
|
|
45
|
-
if (typeof entry === 'string') normalized[name] = { hash: entry };
|
|
46
|
-
else if (entry?.hash) normalized[name] = entry;
|
|
47
|
-
}
|
|
48
|
-
return normalized;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
14
|
function createDefaultManifest() {
|
|
52
15
|
return {
|
|
53
16
|
version: '2.0.0',
|
|
@@ -250,7 +213,7 @@ export async function populateSkills(mode) {
|
|
|
250
213
|
await fs.remove(dest);
|
|
251
214
|
}
|
|
252
215
|
|
|
253
|
-
await
|
|
216
|
+
await copyOwnedTree(src, dest);
|
|
254
217
|
nextOwned[skill] = {
|
|
255
218
|
hash: await hashDirectory(dest),
|
|
256
219
|
targetPath: dest,
|
|
@@ -327,7 +290,7 @@ export async function populateWorkflows(mode) {
|
|
|
327
290
|
await fs.remove(dest);
|
|
328
291
|
}
|
|
329
292
|
|
|
330
|
-
await
|
|
293
|
+
await copyOwnedTree(src, dest);
|
|
331
294
|
nextOwned[workflow] = {
|
|
332
295
|
hash: await hashDirectory(dest),
|
|
333
296
|
targetPath: dest,
|
package/src/takomi-stats.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
1
|
+
import { createReadStream, promises as fs } from 'node:fs';
|
|
2
|
+
import readline from 'node:readline';
|
|
2
3
|
import os from 'os';
|
|
3
4
|
import path from 'path';
|
|
4
5
|
|
|
@@ -167,9 +168,16 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
|
|
|
167
168
|
const row = { key: session, session, source, file, project: projectKey(file), cwd, start: '', end: '', turns: 0, messages: 0, toolCalls: 0, subagentCalls: 0, roles: new Map(), stages: new Map(), workflows: new Map(), activeMs: 0, activityIntervals: [] };
|
|
168
169
|
const rowTracker = createActivityTracker();
|
|
169
170
|
const toolCalls = new Map();
|
|
170
|
-
const text = await fs.readFile(file, 'utf8').catch(() => '');
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
let lines;
|
|
173
|
+
try {
|
|
174
|
+
lines = readline.createInterface({ input: createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
|
|
175
|
+
} catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
for await (const line of lines) {
|
|
173
181
|
const obj = safeJson(line); if (!obj) continue;
|
|
174
182
|
if (obj.timestamp) { row.start ||= obj.timestamp; row.end = obj.timestamp; }
|
|
175
183
|
if (obj.type === 'session') { session = obj.id || session; cwd = obj.cwd || cwd; row.key = session; row.session = session; row.cwd = cwd; }
|
|
@@ -254,6 +262,9 @@ async function scanPiSessions(root, source, events, sessionRows = [], taskRows =
|
|
|
254
262
|
if (currentTask) currentTask.end = ts;
|
|
255
263
|
}
|
|
256
264
|
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
continue;
|
|
257
268
|
}
|
|
258
269
|
pushTask(taskRows, currentTask);
|
|
259
270
|
if (currentTaskTracker && currentTask) {
|