sylo-fieldbrain 0.1.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -0
  3. package/extensions/fieldbrain-tools.ts +495 -0
  4. package/extensions/index.ts +12 -0
  5. package/extensions/python-runner.ts +56 -0
  6. package/package.json +44 -0
  7. package/scripts/__pycache__/_db_lib.cpython-312.pyc +0 -0
  8. package/scripts/__pycache__/_json_out.cpython-312.pyc +0 -0
  9. package/scripts/__pycache__/models.cpython-312.pyc +0 -0
  10. package/scripts/_db_lib.py +228 -0
  11. package/scripts/_json_out.py +19 -0
  12. package/scripts/alembic/env.py +53 -0
  13. package/scripts/alembic/versions/001_baseline.py +38 -0
  14. package/scripts/alembic/versions/002_core_tables.py +429 -0
  15. package/scripts/alembic/versions/003_durability_content_in_db.py +106 -0
  16. package/scripts/alembic/versions/004_pgvector_embedding.py +42 -0
  17. package/scripts/alembic/versions/005_project_job_number.py +37 -0
  18. package/scripts/alembic/versions/006_project_parent.py +62 -0
  19. package/scripts/alembic/versions/__init__.py +1 -0
  20. package/scripts/alembic.ini +38 -0
  21. package/scripts/db_auto_migrate.py +103 -0
  22. package/scripts/db_bootstrap.py +207 -0
  23. package/scripts/db_check.py +105 -0
  24. package/scripts/db_migrate.py +85 -0
  25. package/scripts/fieldbrain_brain_delete.py +49 -0
  26. package/scripts/fieldbrain_brain_read.py +61 -0
  27. package/scripts/fieldbrain_brain_restore.py +49 -0
  28. package/scripts/fieldbrain_brain_revisions.py +54 -0
  29. package/scripts/fieldbrain_brain_write.py +67 -0
  30. package/scripts/fieldbrain_document_attach.py +81 -0
  31. package/scripts/fieldbrain_document_catalog.py +128 -0
  32. package/scripts/fieldbrain_document_ingest.py +62 -0
  33. package/scripts/fieldbrain_document_list.py +67 -0
  34. package/scripts/fieldbrain_document_promote.py +43 -0
  35. package/scripts/fieldbrain_log_create.py +55 -0
  36. package/scripts/fieldbrain_log_delete.py +39 -0
  37. package/scripts/fieldbrain_log_restore.py +39 -0
  38. package/scripts/fieldbrain_log_revisions.py +39 -0
  39. package/scripts/fieldbrain_log_search.py +58 -0
  40. package/scripts/fieldbrain_log_update.py +52 -0
  41. package/scripts/fieldbrain_project_create.py +72 -0
  42. package/scripts/fieldbrain_project_list.py +53 -0
  43. package/scripts/fieldbrain_search.py +74 -0
  44. package/scripts/fieldbrain_ui_brain_list.py +60 -0
  45. package/scripts/fieldbrain_ui_project_create.py +95 -0
  46. package/scripts/fieldbrain_ui_project_list.py +49 -0
  47. package/scripts/models.py +355 -0
  48. package/scripts/pgvector_enable.py +165 -0
  49. package/scripts/pgvector_guide.py +44 -0
  50. package/scripts/pgvector_install_files.py +66 -0
  51. package/scripts/pgvector_install_from_folder.py +148 -0
  52. package/scripts/postbuild-ui.mjs +13 -0
  53. package/scripts/requirements.txt +7 -0
  54. package/scripts/services/__init__.py +1 -0
  55. package/scripts/services/__pycache__/__init__.cpython-312.pyc +0 -0
  56. package/scripts/services/__pycache__/brain_paths.cpython-312.pyc +0 -0
  57. package/scripts/services/__pycache__/document_formats.cpython-312.pyc +0 -0
  58. package/scripts/services/__pycache__/document_service.cpython-312.pyc +0 -0
  59. package/scripts/services/__pycache__/pgvector_windows.cpython-312.pyc +0 -0
  60. package/scripts/services/__pycache__/project_naming.cpython-312.pyc +0 -0
  61. package/scripts/services/__pycache__/project_service.cpython-312.pyc +0 -0
  62. package/scripts/services/brain_paths.py +81 -0
  63. package/scripts/services/brain_service.py +280 -0
  64. package/scripts/services/document_catalog.py +186 -0
  65. package/scripts/services/document_formats.py +116 -0
  66. package/scripts/services/document_ingest.py +254 -0
  67. package/scripts/services/document_service.py +316 -0
  68. package/scripts/services/embedding_service.py +72 -0
  69. package/scripts/services/global_brain_support.py +36 -0
  70. package/scripts/services/maintenance_log_service.py +258 -0
  71. package/scripts/services/migrate_lock.py +24 -0
  72. package/scripts/services/pgvector_windows.py +190 -0
  73. package/scripts/services/project_naming.py +72 -0
  74. package/scripts/services/project_service.py +368 -0
  75. package/scripts/services/search_service.py +250 -0
  76. package/scripts/status.py +39 -0
  77. package/shared/README.md +7 -0
  78. package/skills/fieldbrain/SKILL.md +197 -0
  79. package/skills/fieldbrain/SKILL.md.bak +83 -0
  80. package/skills/fieldbrain/routes/fieldbrain/assets/index-B56utPpP.css +1 -0
  81. package/skills/fieldbrain/routes/fieldbrain/assets/index-DO0AD0df.js +55 -0
  82. package/skills/fieldbrain/routes/fieldbrain/fallback.md +7 -0
  83. package/skills/fieldbrain/routes/fieldbrain/index.html +13 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yeti-Trix <131923258+Yeti-Trix@users.noreply.github.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # sylo-fieldbrain
2
+
3
+ Pi package: **FieldBrain** — shared shop knowledge (projects, docs, brains, maintenance notes, hybrid search).
4
+
5
+ **Tracker:** `features_tracker/active/2026-07-04_14-56-00_sylo_fieldbrain_package_migration.md`
6
+
7
+ ## Skill
8
+
9
+ | Skill | Role |
10
+ |-------|------|
11
+ | **fieldbrain** | Projects, docs, brains, maintenance notes, search, Settings UI |
12
+
13
+ PLC logic (`.acd` export, L5X parse, ladder edits) lives in **sylo-logicforge** (`logicforge` skill), not FieldBrain.
14
+
15
+ ## Postgres config
16
+
17
+ Sylo **FieldBrain → Settings** syncs to:
18
+
19
+ 1. `SYLO_FIELDBRAIN_DATABASE_URL` (host env when set)
20
+ 2. `%USERPROFILE%\.sylo\fieldbrain\database_config.json`
21
+ 3. Default: `postgresql://fieldbrain:fieldbrain@localhost:5432/fieldbrain`
22
+
23
+ ## UI build
24
+
25
+ Source: `packages/sylo-fieldbrain/ui/` (React + TypeScript). Built by `npm run build:ui -w sylo-fieldbrain` (runs automatically via `start-sylo.cmd`).
26
+
27
+ | Tool | Purpose |
28
+ |------|---------|
29
+ | `fieldbrain_status` | Package paths and config hint |
30
+ | `fieldbrain_db_check` | Postgres + pgvector + schema version check |
31
+ | `fieldbrain_db_migrate` | Apply Alembic migrations |
32
+ | `fieldbrain_search` | Hybrid search over brains, docs, maintenance notes |
@@ -0,0 +1,495 @@
1
+ /**
2
+ * FieldBrain — shared shop knowledge (projects, docs, brains, search). Field notes are brain entries.
3
+ */
4
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
5
+ import { Type } from 'typebox'
6
+
7
+ import { runPythonScript } from './python-runner.js'
8
+
9
+ export function registerFieldBrainTools(pi: ExtensionAPI): void {
10
+ pi.registerTool({
11
+ name: 'fieldbrain_status',
12
+ label: 'FieldBrain status',
13
+ description:
14
+ 'Package paths, Postgres config location, and Ollama endpoint hint. Run before other FieldBrain tools.',
15
+ parameters: Type.Object({}),
16
+ async execute() {
17
+ return runPythonScript('status.py', [])
18
+ },
19
+ })
20
+
21
+ pi.registerTool({
22
+ name: 'fieldbrain_db_check',
23
+ label: 'FieldBrain database check',
24
+ description:
25
+ 'Verify Postgres connection, pgvector extension, and schema version. Use --guided in script for setup steps when failing.',
26
+ parameters: Type.Object({
27
+ guided: Type.Optional(
28
+ Type.Boolean({ description: 'Include guided setup steps when a check fails' }),
29
+ ),
30
+ }),
31
+ async execute(_toolCallId, params) {
32
+ const args: string[] = []
33
+ if (params.guided === true) args.push('--guided')
34
+ return runPythonScript('db_check.py', args)
35
+ },
36
+ })
37
+
38
+ pi.registerTool({
39
+ name: 'fieldbrain_db_bootstrap',
40
+ label: 'FieldBrain create database',
41
+ description:
42
+ 'One-time setup: connect as Postgres superuser, create the FieldBrain role + database, enable pgvector if installed, and migrate. Superuser password is not stored.',
43
+ parameters: Type.Object({
44
+ admin_username: Type.Optional(
45
+ Type.String({ description: 'Postgres superuser (default postgres)' }),
46
+ ),
47
+ admin_password: Type.String({ description: 'Postgres superuser password (not saved)' }),
48
+ host: Type.Optional(Type.String({ description: 'Postgres host (default localhost)' })),
49
+ port: Type.Optional(Type.Integer({ description: 'Postgres port (default 5432)' })),
50
+ app_database: Type.Optional(Type.String({ description: 'Database name to create (default fieldbrain)' })),
51
+ app_username: Type.Optional(Type.String({ description: 'App role name (default fieldbrain)' })),
52
+ app_password: Type.Optional(
53
+ Type.String({ description: 'Password for the FieldBrain role (default fieldbrain)' }),
54
+ ),
55
+ skip_migrate: Type.Optional(Type.Boolean({ description: 'Create role/DB only; do not run migrations' })),
56
+ }),
57
+ async execute(_toolCallId, params) {
58
+ const args = [
59
+ '--admin-user',
60
+ params.admin_username?.trim() || 'postgres',
61
+ '--admin-password',
62
+ params.admin_password,
63
+ ]
64
+ if (params.host) args.push('--host', params.host)
65
+ if (params.port != null) args.push('--port', String(params.port))
66
+ if (params.app_database) args.push('--app-database', params.app_database)
67
+ if (params.app_username) args.push('--app-username', params.app_username)
68
+ if (params.app_password) args.push('--app-password', params.app_password)
69
+ if (params.skip_migrate === true) args.push('--skip-migrate')
70
+ return runPythonScript('db_bootstrap.py', args, 180_000)
71
+ },
72
+ })
73
+
74
+ pi.registerTool({
75
+ name: 'fieldbrain_db_migrate',
76
+ label: 'FieldBrain database migrate',
77
+ description:
78
+ 'Apply Alembic migrations to the configured Postgres database. Run once on the server DB before clients connect.',
79
+ parameters: Type.Object({
80
+ check_only: Type.Optional(Type.Boolean({ description: 'Report version without migrating' })),
81
+ }),
82
+ async execute(_toolCallId, params) {
83
+ const args: string[] = []
84
+ if (params.check_only === true) args.push('--check-only')
85
+ return runPythonScript('db_migrate.py', args)
86
+ },
87
+ })
88
+
89
+ pi.registerTool({
90
+ name: 'fieldbrain_pgvector_guide',
91
+ label: 'FieldBrain pgvector setup guide',
92
+ description: 'Plain-language Windows steps for optional semantic search (pgvector).',
93
+ parameters: Type.Object({}),
94
+ async execute() {
95
+ return runPythonScript('pgvector_guide.py', [])
96
+ },
97
+ })
98
+
99
+ pi.registerTool({
100
+ name: 'fieldbrain_pgvector_install',
101
+ label: 'FieldBrain install pgvector from folder',
102
+ description:
103
+ 'Copy pgvector files from an operator-selected folder or zip into local PostgreSQL, then enable the extension. Superuser password is not stored.',
104
+ parameters: Type.Object({
105
+ source_path: Type.String({ description: 'Folder or .zip with pgvector files (local Postgres only)' }),
106
+ admin_username: Type.Optional(Type.String()),
107
+ admin_password: Type.String({ description: 'Postgres superuser password (not saved)' }),
108
+ host: Type.Optional(Type.String()),
109
+ port: Type.Optional(Type.Integer()),
110
+ database: Type.Optional(Type.String()),
111
+ skip_file_copy: Type.Optional(
112
+ Type.Boolean({ description: 'Only CREATE EXTENSION on remote/server Postgres' }),
113
+ ),
114
+ }),
115
+ async execute(_toolCallId, params) {
116
+ const args = [
117
+ '--admin-password',
118
+ params.admin_password,
119
+ ]
120
+ if (params.admin_username) args.push('--admin-user', params.admin_username)
121
+ if (params.host) args.push('--host', params.host)
122
+ if (params.port != null) args.push('--port', String(params.port))
123
+ if (params.database) args.push('--database', params.database)
124
+ if (params.skip_file_copy === true) args.push('--skip-file-copy')
125
+ else if (params.source_path) args.push('--source', params.source_path)
126
+ return runPythonScript('pgvector_install_from_folder.py', args, 180_000)
127
+ },
128
+ })
129
+
130
+ pi.registerTool({
131
+ name: 'fieldbrain_project_list',
132
+ label: 'FieldBrain list projects',
133
+ description:
134
+ 'List shared projects. Always run before create. Pass with_stats for brain/doc counts per project.',
135
+ parameters: Type.Object({
136
+ include_hidden: Type.Optional(
137
+ Type.Boolean({ description: 'Include system-hidden projects (e.g. global brain shell)' }),
138
+ ),
139
+ with_stats: Type.Optional(
140
+ Type.Boolean({ description: 'Include brain and library doc counts' }),
141
+ ),
142
+ }),
143
+ async execute(_toolCallId, params) {
144
+ const args: string[] = []
145
+ if (params.include_hidden === true) args.push('--include-hidden')
146
+ if (params.with_stats === true) args.push('--with-stats')
147
+ return runPythonScript('fieldbrain_project_list.py', args)
148
+ },
149
+ })
150
+
151
+ pi.registerTool({
152
+ name: 'fieldbrain_project_create',
153
+ label: 'FieldBrain create project',
154
+ description:
155
+ 'Manually create a project: name 12345 (job) or 12345-001 (sub-project). Duplicate names rejected. List first.',
156
+ parameters: Type.Object({
157
+ name: Type.String({ description: '12345 for job-level, or 12345-001 for sub-project' }),
158
+ org_id: Type.Optional(Type.String({ description: 'Org id (default: default)' })),
159
+ tags: Type.Optional(
160
+ Type.Array(Type.String(), { description: 'Optional project tag strings' }),
161
+ ),
162
+ }),
163
+ async execute(_toolCallId, params) {
164
+ const args = ['--name', params.name]
165
+ if (params.org_id) args.push('--org-id', params.org_id)
166
+ if (params.tags && params.tags.length > 0) {
167
+ args.push('--tags-json', JSON.stringify(params.tags))
168
+ }
169
+ return runPythonScript('fieldbrain_project_create.py', args)
170
+ },
171
+ })
172
+
173
+ pi.registerTool({
174
+ name: 'fieldbrain_log_search',
175
+ label: 'FieldBrain search legacy maintenance logs',
176
+ description:
177
+ 'Legacy read-only: search old maintenance log rows from before field notes moved to brain entries. For new notes use fieldbrain_brain_write; for current search use fieldbrain_search.',
178
+ parameters: Type.Object({
179
+ query: Type.Optional(Type.String({ description: 'Full-text search across title, body, fault, tag' })),
180
+ project_id: Type.Optional(Type.Integer({ description: 'Filter by project id' })),
181
+ fault_code: Type.Optional(Type.String({ description: 'Filter by fault code' })),
182
+ equipment_tag: Type.Optional(Type.String({ description: 'Filter by equipment tag substring' })),
183
+ limit: Type.Optional(Type.Integer({ description: 'Max results (default 25)' })),
184
+ }),
185
+ async execute(_toolCallId, params) {
186
+ const args: string[] = []
187
+ if (params.query) args.push('--query', params.query)
188
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
189
+ if (params.fault_code) args.push('--fault-code', params.fault_code)
190
+ if (params.equipment_tag) args.push('--equipment-tag', params.equipment_tag)
191
+ if (params.limit != null) args.push('--limit', String(params.limit))
192
+ return runPythonScript('fieldbrain_log_search.py', args)
193
+ },
194
+ })
195
+
196
+ pi.registerTool({
197
+ name: 'fieldbrain_document_list',
198
+ label: 'FieldBrain list documents',
199
+ description: 'List global library documents or documents attached to a project.',
200
+ parameters: Type.Object({
201
+ scope: Type.Optional(
202
+ Type.Union([Type.Literal('global'), Type.Literal('project')], {
203
+ description: 'global library or project-attached docs (default global)',
204
+ }),
205
+ ),
206
+ project_id: Type.Optional(Type.Integer({ description: 'Required when scope=project' })),
207
+ category: Type.Optional(Type.String({ description: 'Filter global docs by category' })),
208
+ search: Type.Optional(Type.String({ description: 'Filter global docs by title/filename' })),
209
+ limit: Type.Optional(Type.Integer({ description: 'Max global results (default 200)' })),
210
+ }),
211
+ async execute(_toolCallId, params) {
212
+ const args = ['--scope', params.scope ?? 'global']
213
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
214
+ if (params.category) args.push('--category', params.category)
215
+ if (params.search) args.push('--search', params.search)
216
+ if (params.limit != null) args.push('--limit', String(params.limit))
217
+ return runPythonScript('fieldbrain_document_list.py', args)
218
+ },
219
+ })
220
+
221
+ pi.registerTool({
222
+ name: 'fieldbrain_document_attach',
223
+ label: 'FieldBrain attach or register document',
224
+ description:
225
+ 'Register file bytes in the library (PDF, DOCX, XLSX, TXT, MD, CSV, images) or attach an existing document to a project. Does not index for search — follow with fieldbrain_document_catalog after reading the file.',
226
+ parameters: Type.Object({
227
+ document_id: Type.Optional(Type.Integer({ description: 'Existing document id to attach' })),
228
+ file_path: Type.Optional(Type.String({ description: 'New file path to register and optionally link' })),
229
+ project_id: Type.Optional(Type.Integer({ description: 'Target project (required for attach)' })),
230
+ scope: Type.Optional(
231
+ Type.Union([Type.Literal('global'), Type.Literal('project')], {
232
+ description: 'Scope when registering a new file (default project)',
233
+ }),
234
+ ),
235
+ title: Type.Optional(Type.String({ description: 'Optional title when registering a file' })),
236
+ role: Type.Optional(Type.String({ description: 'Link role: reference or schematic' })),
237
+ }),
238
+ async execute(_toolCallId, params) {
239
+ const args: string[] = []
240
+ if (params.document_id != null) args.push('--document-id', String(params.document_id))
241
+ if (params.file_path) args.push('--file-path', params.file_path)
242
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
243
+ if (params.scope) args.push('--scope', params.scope)
244
+ if (params.title) args.push('--title', params.title)
245
+ if (params.role) args.push('--role', params.role)
246
+ return runPythonScript('fieldbrain_document_attach.py', args)
247
+ },
248
+ })
249
+
250
+ pi.registerTool({
251
+ name: 'fieldbrain_document_promote',
252
+ label: 'FieldBrain promote document to global library',
253
+ description:
254
+ 'Promote a project-local document to the global library so it appears in global search. The project link is kept. Confirm with operator before promoting.',
255
+ parameters: Type.Object({
256
+ document_id: Type.Integer({ description: 'Project-local document id to promote' }),
257
+ }),
258
+ async execute(_toolCallId, params) {
259
+ return runPythonScript('fieldbrain_document_promote.py', [
260
+ '--document-id',
261
+ String(params.document_id),
262
+ ])
263
+ },
264
+ })
265
+
266
+ pi.registerTool({
267
+ name: 'fieldbrain_brain_read',
268
+ label: 'FieldBrain read brain',
269
+ description: 'Read markdown from a project or global brain library.',
270
+ parameters: Type.Object({
271
+ path: Type.String({ description: 'Relative brain path, e.g. gotchas/conveyor_jam.md' }),
272
+ scope: Type.Optional(
273
+ Type.Union([Type.Literal('global'), Type.Literal('project')], {
274
+ description: 'global org brain or project brain (default project)',
275
+ }),
276
+ ),
277
+ project_id: Type.Optional(Type.Integer({ description: 'Required when scope=project' })),
278
+ }),
279
+ async execute(_toolCallId, params) {
280
+ const args = ['--path', params.path, '--scope', params.scope ?? 'project']
281
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
282
+ return runPythonScript('fieldbrain_brain_read.py', args)
283
+ },
284
+ })
285
+
286
+ pi.registerTool({
287
+ name: 'fieldbrain_brain_write',
288
+ label: 'FieldBrain write brain',
289
+ description:
290
+ 'Write or append brain markdown. scope=project requires confirmed project_id — ask operator if unclear.',
291
+ parameters: Type.Object({
292
+ path: Type.String({ description: 'Relative brain path, e.g. gotchas/conveyor_jam.md' }),
293
+ content: Type.String({ description: 'Markdown content' }),
294
+ scope: Type.Optional(
295
+ Type.Union([Type.Literal('global'), Type.Literal('project')], {
296
+ description: 'global org brain or project brain (default project)',
297
+ }),
298
+ ),
299
+ project_id: Type.Optional(Type.Integer({ description: 'Required when scope=project' })),
300
+ mode: Type.Optional(
301
+ Type.Union([Type.Literal('replace'), Type.Literal('append')], {
302
+ description: 'replace entire file or append (default replace)',
303
+ }),
304
+ ),
305
+ }),
306
+ async execute(_toolCallId, params) {
307
+ const args = [
308
+ '--path',
309
+ params.path,
310
+ '--content',
311
+ params.content,
312
+ '--scope',
313
+ params.scope ?? 'project',
314
+ '--mode',
315
+ params.mode ?? 'replace',
316
+ ]
317
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
318
+ return runPythonScript('fieldbrain_brain_write.py', args)
319
+ },
320
+ })
321
+
322
+ pi.registerTool({
323
+ name: 'fieldbrain_brain_delete',
324
+ label: 'FieldBrain soft-delete brain doc',
325
+ description: 'Soft-delete a brain markdown document (revision saved; use fieldbrain_brain_restore).',
326
+ parameters: Type.Object({
327
+ path: Type.String({ description: 'Relative brain path' }),
328
+ scope: Type.Optional(Type.Union([Type.Literal('global'), Type.Literal('project')])),
329
+ project_id: Type.Optional(Type.Integer()),
330
+ }),
331
+ async execute(_toolCallId, params) {
332
+ const args = ['--path', params.path, '--scope', params.scope ?? 'project']
333
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
334
+ return runPythonScript('fieldbrain_brain_delete.py', args)
335
+ },
336
+ })
337
+
338
+ pi.registerTool({
339
+ name: 'fieldbrain_brain_restore',
340
+ label: 'FieldBrain restore brain doc',
341
+ description: 'Restore a soft-deleted brain markdown document.',
342
+ parameters: Type.Object({
343
+ path: Type.String({ description: 'Relative brain path' }),
344
+ scope: Type.Optional(Type.Union([Type.Literal('global'), Type.Literal('project')])),
345
+ project_id: Type.Optional(Type.Integer()),
346
+ }),
347
+ async execute(_toolCallId, params) {
348
+ const args = ['--path', params.path, '--scope', params.scope ?? 'project']
349
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
350
+ return runPythonScript('fieldbrain_brain_restore.py', args)
351
+ },
352
+ })
353
+
354
+ pi.registerTool({
355
+ name: 'fieldbrain_brain_revisions',
356
+ label: 'FieldBrain brain revisions',
357
+ description: 'List revision history for a brain markdown document.',
358
+ parameters: Type.Object({
359
+ path: Type.String({ description: 'Relative brain path' }),
360
+ scope: Type.Optional(Type.Union([Type.Literal('global'), Type.Literal('project')])),
361
+ project_id: Type.Optional(Type.Integer()),
362
+ limit: Type.Optional(Type.Integer({ description: 'Max revisions (default 20)' })),
363
+ }),
364
+ async execute(_toolCallId, params) {
365
+ const args = ['--path', params.path, '--scope', params.scope ?? 'project']
366
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
367
+ if (params.limit != null) args.push('--limit', String(params.limit))
368
+ return runPythonScript('fieldbrain_brain_revisions.py', args)
369
+ },
370
+ })
371
+
372
+ pi.registerTool({
373
+ name: 'fieldbrain_document_catalog',
374
+ label: 'FieldBrain catalog document',
375
+ description:
376
+ 'After you read a file with the matching Sylo reader (pdf-reader, docx, spreadsheet, read, vision), write catalog metadata and index the summary for search. Supports PDF, DOCX, XLSX, TXT, MD, CSV, images. Does not full-text ingest the file.',
377
+ parameters: Type.Object({
378
+ document_id: Type.Optional(
379
+ Type.Integer({ description: 'Existing library document id (use this OR file_path)' }),
380
+ ),
381
+ file_path: Type.Optional(
382
+ Type.String({
383
+ description: 'Register file into library first, then catalog (use this OR document_id)',
384
+ }),
385
+ ),
386
+ description: Type.String({
387
+ description:
388
+ 'Your catalog summary: what this file is, key topics, when to open it (required)',
389
+ }),
390
+ title: Type.Optional(Type.String({ description: 'Display title override' })),
391
+ category: Type.Optional(
392
+ Type.String({
393
+ description:
394
+ 'manual | datasheet | requirements | email | howto | guide | schematic | spreadsheet | image | markdown | reference | other',
395
+ }),
396
+ ),
397
+ tags: Type.Optional(
398
+ Type.String({ description: 'Comma-separated tags or JSON array string' }),
399
+ ),
400
+ manufacturer: Type.Optional(Type.String()),
401
+ model: Type.Optional(Type.String()),
402
+ version: Type.Optional(Type.String()),
403
+ outline_json: Type.Optional(
404
+ Type.String({
405
+ description:
406
+ 'Optional JSON array of outline rows: [{ "level": 1, "title": "Section", "page": 0 }]',
407
+ }),
408
+ ),
409
+ scope: Type.Optional(
410
+ Type.Union([Type.Literal('global'), Type.Literal('project')], {
411
+ description: 'When using file_path (default global)',
412
+ }),
413
+ ),
414
+ project_id: Type.Optional(
415
+ Type.Integer({ description: 'Project id when scope=project or attaching to project' }),
416
+ ),
417
+ }),
418
+ async execute(_toolCallId, params) {
419
+ if (params.document_id == null && !params.file_path?.trim()) {
420
+ return {
421
+ content: [{ type: 'text', text: 'Provide document_id or file_path.' }],
422
+ details: { ok: false },
423
+ }
424
+ }
425
+ if (params.document_id != null && params.file_path?.trim()) {
426
+ return {
427
+ content: [{ type: 'text', text: 'Provide only one of document_id or file_path.' }],
428
+ details: { ok: false },
429
+ }
430
+ }
431
+ const args = ['--description', params.description]
432
+ if (params.document_id != null) args.push('--document-id', String(params.document_id))
433
+ if (params.file_path?.trim()) args.push('--file-path', params.file_path.trim())
434
+ if (params.title) args.push('--title', params.title)
435
+ if (params.category) args.push('--category', params.category)
436
+ if (params.tags) args.push('--tags', params.tags)
437
+ if (params.manufacturer) args.push('--manufacturer', params.manufacturer)
438
+ if (params.model) args.push('--model', params.model)
439
+ if (params.version) args.push('--version', params.version)
440
+ if (params.outline_json) args.push('--outline-json', params.outline_json)
441
+ if (params.scope) args.push('--scope', params.scope)
442
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
443
+ return runPythonScript('fieldbrain_document_catalog.py', args, 120_000)
444
+ },
445
+ })
446
+
447
+ pi.registerTool({
448
+ name: 'fieldbrain_document_ingest',
449
+ label: 'FieldBrain ingest document (legacy)',
450
+ description:
451
+ 'Legacy: pymupdf full-text extract + chunk index. Prefer attach → read with Sylo readers → fieldbrain_document_catalog for PDF/DOCX/XLSX/images.',
452
+ parameters: Type.Object({
453
+ file_path: Type.String({ description: 'Path to PDF or .txt on disk' }),
454
+ scope: Type.Optional(
455
+ Type.Union([Type.Literal('global'), Type.Literal('project')], {
456
+ description: 'global library or project-local (default project)',
457
+ }),
458
+ ),
459
+ project_id: Type.Optional(Type.Integer({ description: 'Required when scope=project' })),
460
+ title: Type.Optional(Type.String({ description: 'Optional document title' })),
461
+ }),
462
+ async execute(_toolCallId, params) {
463
+ const args = ['--file-path', params.file_path, '--scope', params.scope ?? 'project']
464
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
465
+ if (params.title) args.push('--title', params.title)
466
+ return runPythonScript('fieldbrain_document_ingest.py', args, 300_000)
467
+ },
468
+ })
469
+
470
+ pi.registerTool({
471
+ name: 'fieldbrain_search',
472
+ label: 'FieldBrain hybrid search',
473
+ description:
474
+ 'Hybrid keyword + semantic search. With project_id: that project PLUS the curated global library/brain. Without project_id: global only — project-local knowledge stays invisible until promoted.',
475
+ parameters: Type.Object({
476
+ project_id: Type.Optional(
477
+ Type.Integer({
478
+ description: 'Project to search (includes global). Omit for global-only search.',
479
+ }),
480
+ ),
481
+ query: Type.String({ description: 'Search query text' }),
482
+ limit: Type.Optional(Type.Integer({ description: 'Max results (default 25)' })),
483
+ source_type: Type.Optional(
484
+ Type.String({ description: 'Optional filter: document, brain, tag, rung, etc.' }),
485
+ ),
486
+ }),
487
+ async execute(_toolCallId, params) {
488
+ const args = ['--query', params.query]
489
+ if (params.project_id != null) args.push('--project-id', String(params.project_id))
490
+ if (params.limit != null) args.push('--limit', String(params.limit))
491
+ if (params.source_type) args.push('--source-type', params.source_type)
492
+ return runPythonScript('fieldbrain_search.py', args)
493
+ },
494
+ })
495
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * sylo-fieldbrain — shared shop knowledge (projects, docs, brains, maintenance notes).
3
+ *
4
+ * @see features_tracker/active/2026-07-04_14-56-00_sylo_fieldbrain_package_migration.md
5
+ */
6
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
7
+
8
+ import { registerFieldBrainTools } from './fieldbrain-tools.js'
9
+
10
+ export default function piSyloFieldBrainExtension(pi: ExtensionAPI): void {
11
+ registerFieldBrainTools(pi)
12
+ }
@@ -0,0 +1,56 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { fileURLToPath } from 'node:url'
4
+ import path from 'node:path'
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
+ export const SCRIPTS_DIR = path.join(PACKAGE_ROOT, 'scripts')
10
+
11
+ export type ToolContentBlock = { type: 'text'; text: string }
12
+
13
+ function resolvePython(): { command: string; prefixArgs: string[] } {
14
+ const envPython = process.env.SYLO_PYTHON?.trim()
15
+ if (envPython) {
16
+ return { command: envPython, prefixArgs: [] }
17
+ }
18
+ return { command: process.platform === 'win32' ? 'python' : 'python3', prefixArgs: [] }
19
+ }
20
+
21
+ export function toolError(text: string): { content: ToolContentBlock[] } {
22
+ return { content: [{ type: 'text', text }] }
23
+ }
24
+
25
+ export async function runPythonScript(
26
+ scriptName: string,
27
+ args: string[] = [],
28
+ timeoutMs = 120_000,
29
+ ): Promise<{ content: ToolContentBlock[] }> {
30
+ const scriptPath = path.join(SCRIPTS_DIR, scriptName)
31
+ const { command, prefixArgs } = resolvePython()
32
+ try {
33
+ const { stdout, stderr } = await execFileAsync(command, [...prefixArgs, scriptPath, ...args], {
34
+ cwd: PACKAGE_ROOT,
35
+ maxBuffer: 8 * 1024 * 1024,
36
+ windowsHide: true,
37
+ timeout: timeoutMs,
38
+ env: { ...process.env },
39
+ })
40
+ const trimmed = stdout.trim()
41
+ if (!trimmed) {
42
+ return toolError(stderr.trim() || `${scriptName} produced no output`)
43
+ }
44
+ const parsed = JSON.parse(trimmed) as { ok?: boolean; error?: string; operator_chat?: string }
45
+ if (parsed.ok === false) {
46
+ return toolError(parsed.error ?? `${scriptName} failed`)
47
+ }
48
+ if (typeof parsed.operator_chat === 'string' && parsed.operator_chat.trim()) {
49
+ return { content: [{ type: 'text', text: parsed.operator_chat.trim() }] }
50
+ }
51
+ return { content: [{ type: 'text', text: trimmed }] }
52
+ } catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err)
54
+ return toolError(message)
55
+ }
56
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "sylo-fieldbrain",
3
+ "version": "0.1.0",
4
+ "description": "FieldBrain — shared shop knowledge (projects, docs, brains, maintenance notes)",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "extensions",
11
+ "scripts",
12
+ "shared",
13
+ "skills",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build:ui": "vite build --config ui/vite.config.ts && node scripts/postbuild-ui.mjs"
19
+ },
20
+ "pi": {
21
+ "extensions": [
22
+ "./extensions/index.ts"
23
+ ],
24
+ "skills": [
25
+ "./skills/fieldbrain"
26
+ ]
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "^0.84.2"
30
+ },
31
+ "dependencies": {
32
+ "typebox": "^1.1.24"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.0.10",
36
+ "@types/react-dom": "^19.0.4",
37
+ "@vitejs/plugin-react": "^4.3.4",
38
+ "react": "^19.0.0",
39
+ "react-dom": "^19.0.0",
40
+ "typescript": "^5.7.3",
41
+ "vite": "^6.4.3"
42
+ },
43
+ "license": "MIT"
44
+ }