newo 1.2.2 → 1.4.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/.env.example CHANGED
@@ -1,8 +1,8 @@
1
1
  # NEWO endpoints
2
2
  NEWO_BASE_URL=https://app.newo.ai
3
3
 
4
- # Project you want to sync
5
- NEWO_PROJECT_ID=b78188ba-0df0-46a8-8713-f0d7cff0a06e
4
+ # Project you want to sync (optional - leave blank to sync all accessible projects)
5
+ # NEWO_PROJECT_ID=b78188ba-0df0-46a8-8713-f0d7cff0a06e
6
6
 
7
7
  # Auth (choose one)
8
8
  # 1) Recommended: API key that can be exchanged for tokens:
package/CHANGELOG.md ADDED
@@ -0,0 +1,135 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.4.0] - 2025-08-20
9
+
10
+ ### Added
11
+ - **Multi-Project Support**: Major feature allowing users to work with multiple NEWO projects
12
+ - Optional `NEWO_PROJECT_ID` environment variable - if not set, pulls all accessible projects
13
+ - New API endpoint: `GET /api/v1/designer/projects` to list all accessible projects
14
+ - Projects stored in organized folder structure: `./projects/{project-idn}/`
15
+ - Each project folder contains `metadata.json` with complete project information
16
+ - Project-specific `flows.yaml` files for individual project structure exports
17
+ - **Enhanced Project Structure**:
18
+ - Changed from single `./project/` to multi-project `./projects/{project-idn}/` hierarchy
19
+ - Backward compatibility maintained for existing single-project workflows
20
+ - Improved organization with project-specific metadata and flows
21
+
22
+ ### Changed
23
+ - **Folder Structure**: Project files now stored in `./projects/{project-idn}/` instead of `./project/`
24
+ - **CLI Behavior**: `newo pull` now downloads all projects by default (unless NEWO_PROJECT_ID specified)
25
+ - **CI/CD Paths**: GitHub Actions workflow paths updated from `project/**/*` to `projects/**/*`
26
+ - **Help Documentation**: Updated CLI help text to reflect multi-project capabilities
27
+ - **API Integration**: Enhanced sync logic to handle both single and multi-project scenarios
28
+
29
+ ### Technical Details
30
+ - **New API Functions**:
31
+ - `listProjects()`: Fetch all accessible projects from NEWO platform
32
+ - `pullSingleProject()`: Pull individual project with metadata generation
33
+ - `metadataPath()`: Generate project-specific metadata file paths
34
+ - **Enhanced Sync Engine**:
35
+ - Multi-project mapping in `.newo/map.json` with backward compatibility
36
+ - Project-specific hash tracking for efficient change detection
37
+ - Automatic project metadata collection and storage
38
+ - **File System Updates**:
39
+ - Updated `fsutil.js` with multi-project path utilities
40
+ - Enhanced `skillPath()` function to include project identifier
41
+ - New `projectDir()` and `metadataPath()` helper functions
42
+
43
+ ### Migration Guide
44
+ - **Existing Users**: Single-project setups continue to work with `NEWO_PROJECT_ID` set
45
+ - **New Users**: Leave `NEWO_PROJECT_ID` unset to access all projects automatically
46
+ - **File Paths**: Update any scripts referencing `./project/` to use `./projects/{project-idn}/`
47
+ - **CI/CD**: Update workflow paths from `project/**/*` to `projects/**/*`
48
+
49
+ ### Example Usage
50
+ ```bash
51
+ # Pull all accessible projects (new default behavior)
52
+ npx newo pull
53
+
54
+ # Pull specific project (original behavior)
55
+ NEWO_PROJECT_ID=your-project-id npx newo pull
56
+
57
+ # Push changes from any project structure
58
+ npx newo push
59
+
60
+ # Status works with both single and multi-project setups
61
+ npx newo status
62
+ ```
63
+
64
+ ## [1.3.0] - 2025-08-20
65
+
66
+ ### Added
67
+ - **AKB Import Feature**: New `import-akb` command to import knowledge base articles from structured text files
68
+ - Parse multi-article files with standardized format (separated by `---`)
69
+ - Extract article metadata: ID, category, summary, keywords, and pricing data
70
+ - Import articles to NEWO personas via `/api/v1/akb/append-manual` endpoint
71
+ - Support for verbose logging with `--verbose` flag
72
+ - Progress tracking with success/failure counts
73
+ - **Enhanced CLI**: Added `import-akb <file> <persona_id>` command with validation and error handling
74
+ - **New API Endpoint**: `importAkbArticle()` function for AKB article imports
75
+ - **Documentation**: Comprehensive AKB format documentation in README.md
76
+
77
+ ### Changed
78
+ - Updated help text to include new `import-akb` command
79
+ - Enhanced CLI command parsing to handle AKB import arguments
80
+ - Updated project documentation with AKB import workflow and examples
81
+
82
+ ### Technical Details
83
+ - **New Files**:
84
+ - `src/akb.js`: AKB file parser and article formatter
85
+ - Article parsing supports category/subcategory structure with pricing data
86
+ - **API Integration**:
87
+ - Articles mapped with `topic_name` (descriptive title) and `source` (article ID)
88
+ - Full category content stored in `topic_summary`
89
+ - Structured metadata in `topic_facts` array
90
+ - **Error Handling**: Comprehensive validation for file paths, persona IDs, and API responses
91
+
92
+ ### Example Usage
93
+ ```bash
94
+ # Import AKB articles from file to specific persona
95
+ npx newo import-akb akb.txt da4550db-2b95-4500-91ff-fb4b60fe7be9
96
+
97
+ # With verbose logging
98
+ npx newo import-akb akb.txt da4550db-2b95-4500-91ff-fb4b60fe7be9 --verbose
99
+ ```
100
+
101
+ ### AKB File Format
102
+ ```
103
+ ---
104
+ # r001
105
+ ## Category / Subcategory / Description
106
+ ## Summary description of the category
107
+ ## Keywords; separated; by; semicolons
108
+
109
+ <Category type="Category Name">
110
+ Item Name: $Price [Modifiers: modifier1, modifier2]
111
+ Another Item: $Price [Modifiers: modifier3]
112
+ </Category>
113
+ ---
114
+ ```
115
+
116
+ ## [1.2.2] - 2025-08-12
117
+
118
+ ### Changed
119
+ - Updated README with API key image
120
+ - Removed unused files and .DS_Store entries
121
+ - Package version bump
122
+
123
+ ### Fixed
124
+ - Repository cleanup and organization
125
+
126
+ ## [1.2.1] - Previous Release
127
+
128
+ ### Added
129
+ - Initial NEWO CLI functionality
130
+ - Two-way sync between NEWO platform and local files
131
+ - Support for .guidance and .jinja file types
132
+ - SHA256-based change detection
133
+ - Project structure export to flows.yaml
134
+ - GitHub Actions CI/CD integration
135
+ - Robust authentication with token refresh
package/README.md CHANGED
@@ -44,6 +44,8 @@ npm install
44
44
  4. **Create** a new **Connector** for this Integration
45
45
  5. **Copy** your API key (it will look like: `458663bd41f2d1...`)
46
46
 
47
+ ![How to get your NEWO API Key](assets/newo-api-key.png)
48
+
47
49
  ### Step 2: Setup Environment
48
50
  ```bash
49
51
  cp .env.example .env
@@ -52,32 +54,42 @@ cp .env.example .env
52
54
 
53
55
  Required environment variables:
54
56
  - `NEWO_BASE_URL` (default `https://app.newo.ai`)
55
- - `NEWO_PROJECT_ID` (your project UUID from NEWO)
56
57
  - `NEWO_API_KEY` (your API key from Step 1)
57
58
 
58
- Optional (advanced):
59
+ Optional environment variables:
60
+ - `NEWO_PROJECT_ID` (specific project UUID - if not set, pulls all accessible projects)
59
61
  - `NEWO_ACCESS_TOKEN` (direct access token)
60
62
  - `NEWO_REFRESH_TOKEN` (refresh token)
61
63
  - `NEWO_REFRESH_URL` (custom refresh endpoint)
62
64
 
63
65
  ## Commands
64
66
  ```bash
65
- npx newo pull # download project -> ./project
66
- npx newo status # list modified files
67
- npx newo push # upload modified *.guidance/*.jinja back to NEWO
67
+ npx newo pull # download all projects -> ./projects/ OR specific project if NEWO_PROJECT_ID set
68
+ npx newo status # list modified files
69
+ npx newo push # upload modified *.guidance/*.jinja back to NEWO
70
+ npx newo import-akb <file> <persona_id> # import AKB articles from file
71
+ npx newo meta # get project metadata (debug, requires NEWO_PROJECT_ID)
68
72
  ```
69
73
 
74
+ ### Project Structure
70
75
  Files are stored as:
71
- - `./project/<AgentIdn>/<FlowIdn>/<SkillIdn>.guidance` (AI guidance scripts)
72
- - `./project/<AgentIdn>/<FlowIdn>/<SkillIdn>.jinja` (NSL/Jinja template scripts)
76
+ - **Multi-project mode** (no NEWO_PROJECT_ID): `./projects/<ProjectIdn>/<AgentIdn>/<FlowIdn>/<SkillIdn>.guidance|.jinja`
77
+ - **Single-project mode** (NEWO_PROJECT_ID set): `./projects/<ProjectIdn>/<AgentIdn>/<FlowIdn>/<SkillIdn>.guidance|.jinja`
78
+
79
+ Each project folder contains:
80
+ - `metadata.json` - Project metadata (title, description, version, etc.)
81
+ - `flows.yaml` - Complete project structure export for external tools
82
+ - Agent/Flow/Skill hierarchy with `.guidance` (AI prompts) and `.jinja` (NSL templates)
73
83
 
74
84
  Hashes are tracked in `.newo/hashes.json` so only changed files are pushed.
75
- Project structure is also exported to `flows.yaml` for reference.
76
85
 
77
86
  ## Features
87
+ - **Multi-project support**: Pull all accessible projects or specify a single project
78
88
  - **Two-way sync**: Pull NEWO projects to local files, push local changes back
79
89
  - **Change detection**: SHA256 hashing prevents unnecessary uploads
80
90
  - **Multiple file types**: `.guidance` (AI prompts) and `.jinja` (NSL templates)
91
+ - **Project metadata**: Each project includes `metadata.json` with complete project info
92
+ - **AKB import**: Import knowledge base articles from structured text files
81
93
  - **Project structure export**: Generates `flows.yaml` with complete project metadata
82
94
  - **Robust authentication**: API key exchange with automatic token refresh
83
95
  - **CI/CD ready**: GitHub Actions workflow included
@@ -90,8 +102,8 @@ on:
90
102
  push:
91
103
  branches: [ main ]
92
104
  paths:
93
- - 'project/**/*.guidance'
94
- - 'project/**/*.jinja'
105
+ - 'projects/**/*.guidance'
106
+ - 'projects/**/*.jinja'
95
107
  jobs:
96
108
  deploy:
97
109
  runs-on: ubuntu-latest
@@ -110,11 +122,47 @@ jobs:
110
122
  # NEWO_REFRESH_URL: ${{ secrets.NEWO_REFRESH_URL }}
111
123
  ```
112
124
 
125
+ ## AKB Import
126
+
127
+ Import knowledge base articles from structured text files into NEWO personas:
128
+
129
+ ```bash
130
+ npx newo import-akb akb.txt da4550db-2b95-4500-91ff-fb4b60fe7be9
131
+ ```
132
+
133
+ ### AKB File Format
134
+ ```
135
+ ---
136
+ # r001
137
+ ## Category / Subcategory / Description
138
+ ## Summary description of the category
139
+ ## Keywords; separated; by; semicolons
140
+
141
+ <Category type="Category Name">
142
+ Item Name: $Price [Modifiers: modifier1, modifier2]
143
+ Another Item: $Price [Modifiers: modifier3]
144
+ </Category>
145
+ ---
146
+ ```
147
+
148
+ Each article will be imported with:
149
+ - **topic_name**: The descriptive category title
150
+ - **source**: The article ID (e.g., "r001")
151
+ - **topic_summary**: The full category content with pricing
152
+ - **topic_facts**: Array containing category, summary, and keywords
153
+ - **confidence**: 100
154
+ - **labels**: ["rag_context"]
155
+
156
+ Use `--verbose` flag to see detailed import progress.
157
+
113
158
  ## API Endpoints
159
+ - `GET /api/v1/designer/projects` - List all accessible projects
160
+ - `GET /api/v1/designer/projects/by-id/{projectId}` - Get specific project metadata
114
161
  - `GET /api/v1/bff/agents/list?project_id=...` - List project agents
115
162
  - `GET /api/v1/designer/flows/{flowId}/skills` - List skills in flow
116
163
  - `GET /api/v1/designer/skills/{skillId}` - Get skill content
117
164
  - `PUT /api/v1/designer/flows/skills/{skillId}` - Update skill content
118
165
  - `GET /api/v1/designer/flows/{flowId}/events` - List flow events (for flows.yaml)
119
166
  - `GET /api/v1/designer/flows/{flowId}/states` - List flow states (for flows.yaml)
120
- - `POST /api/v1/auth/api-key/token` - Exchange API key for access tokens
167
+ - `POST /api/v1/auth/api-key/token` - Exchange API key for access tokens
168
+ - `POST /api/v1/akb/append-manual` - Import AKB articles
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "1.2.2",
4
- "description": "NEWO CLI: sync flows/skills between NEWO and local files",
3
+ "version": "1.4.0",
4
+ "description": "NEWO CLI: sync flows/skills between NEWO and local files, multi-project support, import AKB articles",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "newo": "src/cli.js"
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "src/**/*.js",
11
11
  "README.md",
12
+ "CHANGELOG.md",
12
13
  ".env.example"
13
14
  ],
14
15
  "keywords": [
@@ -18,7 +19,12 @@
18
19
  "agent",
19
20
  "automation",
20
21
  "sync",
21
- "local-development"
22
+ "local-development",
23
+ "akb",
24
+ "knowledge-base",
25
+ "import",
26
+ "multi-project",
27
+ "workspace"
22
28
  ],
23
29
  "author": "sabbah13",
24
30
  "license": "MIT",
@@ -40,10 +46,17 @@
40
46
  "js-yaml": "^4.1.0",
41
47
  "minimist": "^1.2.8"
42
48
  },
49
+ "devDependencies": {
50
+ "mocha": "^10.2.0"
51
+ },
43
52
  "scripts": {
44
53
  "dev": "node ./src/cli.js",
45
54
  "pull": "node ./src/cli.js pull",
46
55
  "push": "node ./src/cli.js push",
47
- "status": "node ./src/cli.js status"
56
+ "status": "node ./src/cli.js status",
57
+ "test": "mocha test/*.test.js --timeout 60000",
58
+ "test:api": "mocha test/api.test.js --timeout 30000",
59
+ "test:sync": "mocha test/sync.test.js --timeout 60000",
60
+ "test:integration": "mocha test/integration.test.js --timeout 120000"
48
61
  }
49
62
  }
package/src/akb.js ADDED
@@ -0,0 +1,112 @@
1
+ import fs from 'fs-extra';
2
+
3
+ /**
4
+ * Parse AKB file and extract articles
5
+ * @param {string} filePath - Path to AKB file
6
+ * @returns {Array} Array of parsed articles
7
+ */
8
+ function parseAkbFile(filePath) {
9
+ const content = fs.readFileSync(filePath, 'utf8');
10
+ const articles = [];
11
+
12
+ // Split by article separators (---)
13
+ const sections = content.split(/^---\s*$/gm).filter(section => section.trim());
14
+
15
+ for (const section of sections) {
16
+ const lines = section.split('\n').filter(line => line.trim());
17
+ if (lines.length === 0) continue;
18
+
19
+ const article = parseArticleSection(lines);
20
+ if (article) {
21
+ articles.push(article);
22
+ }
23
+ }
24
+
25
+ return articles;
26
+ }
27
+
28
+ /**
29
+ * Parse individual article section
30
+ * @param {Array} lines - Lines of the article section
31
+ * @returns {Object|null} Parsed article object
32
+ */
33
+ function parseArticleSection(lines) {
34
+ let topicName = '';
35
+ let category = '';
36
+ let summary = '';
37
+ let keywords = '';
38
+ let topicSummary = '';
39
+
40
+ // Find topic name (# r001)
41
+ const topicLine = lines.find(line => line.match(/^#\s+r\d+/));
42
+ if (!topicLine) return null;
43
+
44
+ topicName = topicLine.replace(/^#\s+/, '').trim();
45
+
46
+ // Extract category/subcategory/description (first ## line)
47
+ const categoryLine = lines.find(line => line.startsWith('## ') && line.includes(' / '));
48
+ if (categoryLine) {
49
+ category = categoryLine.replace(/^##\s+/, '').trim();
50
+ }
51
+
52
+ // Extract summary (second ## line)
53
+ const summaryLineIndex = lines.findIndex(line => line.startsWith('## ') && line.includes(' / '));
54
+ if (summaryLineIndex >= 0 && summaryLineIndex + 1 < lines.length) {
55
+ const nextLine = lines[summaryLineIndex + 1];
56
+ if (nextLine.startsWith('## ') && !nextLine.includes(' / ')) {
57
+ summary = nextLine.replace(/^##\s+/, '').trim();
58
+ }
59
+ }
60
+
61
+ // Extract keywords (third ## line)
62
+ const keywordsLineIndex = lines.findIndex((line, index) =>
63
+ index > summaryLineIndex + 1 && line.startsWith('## ') && !line.includes(' / ')
64
+ );
65
+ if (keywordsLineIndex >= 0) {
66
+ keywords = lines[keywordsLineIndex].replace(/^##\s+/, '').trim();
67
+ }
68
+
69
+ // Extract category content
70
+ const categoryStartIndex = lines.findIndex(line => line.includes('<Category type='));
71
+ const categoryEndIndex = lines.findIndex(line => line.includes('</Category>'));
72
+
73
+ if (categoryStartIndex >= 0 && categoryEndIndex >= 0) {
74
+ const categoryLines = lines.slice(categoryStartIndex, categoryEndIndex + 1);
75
+ topicSummary = categoryLines.join('\n');
76
+ }
77
+
78
+ // Create topic_facts array
79
+ const topicFacts = [
80
+ category,
81
+ summary,
82
+ keywords
83
+ ].filter(fact => fact.trim() !== '');
84
+
85
+ return {
86
+ topic_name: category, // Use the descriptive title as topic_name
87
+ persona_id: null, // Will be set when importing
88
+ topic_summary: topicSummary,
89
+ topic_facts: topicFacts,
90
+ confidence: 100,
91
+ source: topicName, // Use the ID (r001) as source
92
+ labels: ["rag_context"]
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Convert parsed articles to API format for bulk import
98
+ * @param {Array} articles - Parsed articles
99
+ * @param {string} personaId - Target persona ID
100
+ * @returns {Array} Array of articles ready for API import
101
+ */
102
+ function prepareArticlesForImport(articles, personaId) {
103
+ return articles.map(article => ({
104
+ ...article,
105
+ persona_id: personaId
106
+ }));
107
+ }
108
+
109
+ export {
110
+ parseAkbFile,
111
+ prepareArticlesForImport
112
+ };
package/src/api.js CHANGED
@@ -58,6 +58,11 @@ export async function makeClient(verbose = false) {
58
58
  return client;
59
59
  }
60
60
 
61
+ export async function listProjects(client) {
62
+ const r = await client.get(`/api/v1/designer/projects`);
63
+ return r.data;
64
+ }
65
+
61
66
  export async function listAgents(client, projectId) {
62
67
  const r = await client.get(`/api/v1/bff/agents/list`, { params: { project_id: projectId } });
63
68
  return r.data;
@@ -90,4 +95,9 @@ export async function listFlowEvents(client, flowId) {
90
95
  export async function listFlowStates(client, flowId) {
91
96
  const r = await client.get(`/api/v1/designer/flows/${flowId}/states`);
92
97
  return r.data;
98
+ }
99
+
100
+ export async function importAkbArticle(client, articleData) {
101
+ const r = await client.post('/api/v1/akb/append-manual', articleData);
102
+ return r.data;
93
103
  }
package/src/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import minimist from 'minimist';
3
3
  import dotenv from 'dotenv';
4
- import { makeClient, getProjectMeta } from './api.js';
4
+ import { makeClient, getProjectMeta, importAkbArticle } from './api.js';
5
5
  import { pullAll, pushChanged, status } from './sync.js';
6
+ import { parseAkbFile, prepareArticlesForImport } from './akb.js';
7
+ import path from 'path';
6
8
 
7
9
  dotenv.config();
8
10
  const { NEWO_PROJECT_ID } = process.env;
@@ -15,16 +17,24 @@ async function main() {
15
17
  if (!cmd || ['help', '-h', '--help'].includes(cmd)) {
16
18
  console.log(`NEWO CLI
17
19
  Usage:
18
- newo pull # download project -> ./project
19
- newo push # upload modified *.guidance/*.jinja back to NEWO
20
- newo status # show modified files
21
- newo meta # get project metadata (debug)
20
+ newo pull # download all projects -> ./projects/ OR specific project if NEWO_PROJECT_ID set
21
+ newo push # upload modified *.guidance/*.jinja back to NEWO
22
+ newo status # show modified files
23
+ newo meta # get project metadata (debug, requires NEWO_PROJECT_ID)
24
+ newo import-akb <file> <persona_id> # import AKB articles from file
22
25
 
23
26
  Flags:
24
- --verbose, -v # enable detailed logging
27
+ --verbose, -v # enable detailed logging
25
28
 
26
29
  Env:
27
- NEWO_BASE_URL, NEWO_PROJECT_ID, NEWO_API_KEY, NEWO_REFRESH_URL (optional)
30
+ NEWO_BASE_URL, NEWO_PROJECT_ID (optional), NEWO_API_KEY, NEWO_REFRESH_URL (optional)
31
+
32
+ Notes:
33
+ - multi-project support: pull downloads all accessible projects or single project based on NEWO_PROJECT_ID
34
+ - If NEWO_PROJECT_ID is set, pull downloads only that project
35
+ - If NEWO_PROJECT_ID is not set, pull downloads all projects accessible with your API key
36
+ - Projects are stored in ./projects/{project-idn}/ folders
37
+ - Each project folder contains metadata.json and flows.yaml
28
38
  `);
29
39
  return;
30
40
  }
@@ -32,8 +42,8 @@ Env:
32
42
  const client = await makeClient(verbose);
33
43
 
34
44
  if (cmd === 'pull') {
35
- if (!NEWO_PROJECT_ID) throw new Error('NEWO_PROJECT_ID is not set in env');
36
- await pullAll(client, NEWO_PROJECT_ID, verbose);
45
+ // If PROJECT_ID is set, pull single project; otherwise pull all projects
46
+ await pullAll(client, NEWO_PROJECT_ID || null, verbose);
37
47
  } else if (cmd === 'push') {
38
48
  await pushChanged(client, verbose);
39
49
  } else if (cmd === 'status') {
@@ -42,6 +52,50 @@ Env:
42
52
  if (!NEWO_PROJECT_ID) throw new Error('NEWO_PROJECT_ID is not set in env');
43
53
  const meta = await getProjectMeta(client, NEWO_PROJECT_ID);
44
54
  console.log(JSON.stringify(meta, null, 2));
55
+ } else if (cmd === 'import-akb') {
56
+ const akbFile = args._[1];
57
+ const personaId = args._[2];
58
+
59
+ if (!akbFile || !personaId) {
60
+ console.error('Usage: newo import-akb <file> <persona_id>');
61
+ console.error('Example: newo import-akb akb.txt da4550db-2b95-4500-91ff-fb4b60fe7be9');
62
+ process.exit(1);
63
+ }
64
+
65
+ const filePath = path.resolve(akbFile);
66
+
67
+ try {
68
+ if (verbose) console.log(`📖 Parsing AKB file: ${filePath}`);
69
+ const articles = parseAkbFile(filePath);
70
+ console.log(`✓ Parsed ${articles.length} articles from ${akbFile}`);
71
+
72
+ if (verbose) console.log(`🔧 Preparing articles for persona: ${personaId}`);
73
+ const preparedArticles = prepareArticlesForImport(articles, personaId);
74
+
75
+ let successCount = 0;
76
+ let errorCount = 0;
77
+
78
+ console.log(`📤 Importing ${preparedArticles.length} articles...`);
79
+
80
+ for (const [index, article] of preparedArticles.entries()) {
81
+ try {
82
+ if (verbose) console.log(` [${index + 1}/${preparedArticles.length}] Importing ${article.topic_name}...`);
83
+ await importAkbArticle(client, article);
84
+ successCount++;
85
+ if (!verbose) process.stdout.write('.');
86
+ } catch (error) {
87
+ errorCount++;
88
+ console.error(`\n❌ Failed to import ${article.topic_name}:`, error?.response?.data || error.message);
89
+ }
90
+ }
91
+
92
+ if (!verbose) console.log(''); // new line after dots
93
+ console.log(`✅ Import complete: ${successCount} successful, ${errorCount} failed`);
94
+
95
+ } catch (error) {
96
+ console.error('❌ AKB import failed:', error.message);
97
+ process.exit(1);
98
+ }
45
99
  } else {
46
100
  console.error('Unknown command:', cmd);
47
101
  process.exit(1);
package/src/fsutil.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
 
4
- export const ROOT_DIR = path.join(process.cwd(), 'project');
4
+ export const ROOT_DIR = path.join(process.cwd(), 'projects');
5
5
  export const STATE_DIR = path.join(process.cwd(), '.newo');
6
6
  export const MAP_PATH = path.join(STATE_DIR, 'map.json');
7
7
  export const HASHES_PATH = path.join(STATE_DIR, 'hashes.json');
@@ -11,9 +11,17 @@ export async function ensureState() {
11
11
  await fs.ensureDir(ROOT_DIR);
12
12
  }
13
13
 
14
- export function skillPath(agentIdn, flowIdn, skillIdn, runnerType = 'guidance') {
14
+ export function projectDir(projectIdn) {
15
+ return path.join(ROOT_DIR, projectIdn);
16
+ }
17
+
18
+ export function skillPath(projectIdn, agentIdn, flowIdn, skillIdn, runnerType = 'guidance') {
15
19
  const extension = runnerType === 'nsl' ? '.jinja' : '.guidance';
16
- return path.join(ROOT_DIR, agentIdn, flowIdn, `${skillIdn}${extension}`);
20
+ return path.join(ROOT_DIR, projectIdn, agentIdn, flowIdn, `${skillIdn}${extension}`);
21
+ }
22
+
23
+ export function metadataPath(projectIdn) {
24
+ return path.join(ROOT_DIR, projectIdn, 'metadata.json');
17
25
  }
18
26
 
19
27
  export async function writeFileAtomic(filepath, content) {
package/src/sync.js CHANGED
@@ -1,31 +1,35 @@
1
- import { listAgents, listFlowSkills, updateSkill, listFlowEvents, listFlowStates } from './api.js';
2
- import { ensureState, skillPath, writeFileAtomic, readIfExists, MAP_PATH } from './fsutil.js';
1
+ import { listProjects, listAgents, listFlowSkills, updateSkill, listFlowEvents, listFlowStates, getProjectMeta } from './api.js';
2
+ import { ensureState, skillPath, writeFileAtomic, readIfExists, MAP_PATH, projectDir, metadataPath } from './fsutil.js';
3
3
  import fs from 'fs-extra';
4
4
  import { sha256, loadHashes, saveHashes } from './hash.js';
5
5
  import yaml from 'js-yaml';
6
6
  import path from 'path';
7
7
 
8
- export async function pullAll(client, projectId, verbose = false) {
9
- await ensureState();
10
- if (verbose) console.log(`🔍 Fetching agents for project ${projectId}...`);
8
+ export async function pullSingleProject(client, projectId, projectIdn, verbose = false) {
9
+ if (verbose) console.log(`🔍 Fetching agents for project ${projectId} (${projectIdn})...`);
11
10
  const agents = await listAgents(client, projectId);
12
11
  if (verbose) console.log(`📦 Found ${agents.length} agents`);
13
12
 
14
- const idMap = { projectId, agents: {} };
13
+ // Get and save project metadata
14
+ const projectMeta = await getProjectMeta(client, projectId);
15
+ await writeFileAtomic(metadataPath(projectIdn), JSON.stringify(projectMeta, null, 2));
16
+ if (verbose) console.log(`✓ Saved metadata for ${projectIdn}`);
17
+
18
+ const projectMap = { projectId, projectIdn, agents: {} };
15
19
 
16
20
  for (const agent of agents) {
17
21
  const aKey = agent.idn;
18
- idMap.agents[aKey] = { id: agent.id, flows: {} };
22
+ projectMap.agents[aKey] = { id: agent.id, flows: {} };
19
23
 
20
24
  for (const flow of agent.flows ?? []) {
21
- idMap.agents[aKey].flows[flow.idn] = { id: flow.id, skills: {} };
25
+ projectMap.agents[aKey].flows[flow.idn] = { id: flow.id, skills: {} };
22
26
 
23
27
  const skills = await listFlowSkills(client, flow.id);
24
28
  for (const s of skills) {
25
- const file = skillPath(agent.idn, flow.idn, s.idn, s.runner_type);
29
+ const file = skillPath(projectIdn, agent.idn, flow.idn, s.idn, s.runner_type);
26
30
  await writeFileAtomic(file, s.prompt_script || '');
27
31
  // Store complete skill metadata for push operations
28
- idMap.agents[aKey].flows[flow.idn].skills[s.idn] = {
32
+ projectMap.agents[aKey].flows[flow.idn].skills[s.idn] = {
29
33
  id: s.id,
30
34
  title: s.title,
31
35
  idn: s.idn,
@@ -39,23 +43,66 @@ export async function pullAll(client, projectId, verbose = false) {
39
43
  }
40
44
  }
41
45
 
42
- await fs.writeJson(MAP_PATH, idMap, { spaces: 2 });
46
+ // Generate flows.yaml for this project
47
+ if (verbose) console.log(`📄 Generating flows.yaml for ${projectIdn}...`);
48
+ await generateFlowsYaml(client, agents, projectIdn, verbose);
49
+
50
+ return projectMap;
51
+ }
52
+
53
+ export async function pullAll(client, projectId = null, verbose = false) {
54
+ await ensureState();
55
+
56
+ if (projectId) {
57
+ // Single project mode
58
+ const projectMeta = await getProjectMeta(client, projectId);
59
+ const projectMap = await pullSingleProject(client, projectId, projectMeta.idn, verbose);
60
+
61
+ const idMap = { projects: { [projectMeta.idn]: projectMap } };
62
+ await fs.writeJson(MAP_PATH, idMap, { spaces: 2 });
63
+
64
+ // Generate hash tracking for this project
65
+ const hashes = {};
66
+ for (const [agentIdn, agentObj] of Object.entries(projectMap.agents)) {
67
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
68
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
69
+ const p = skillPath(projectMeta.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
70
+ const content = await fs.readFile(p, 'utf8');
71
+ hashes[p] = sha256(content);
72
+ }
73
+ }
74
+ }
75
+ await saveHashes(hashes);
76
+ return;
77
+ }
78
+
79
+ // Multi-project mode
80
+ if (verbose) console.log(`🔍 Fetching all projects...`);
81
+ const projects = await listProjects(client);
82
+ if (verbose) console.log(`📦 Found ${projects.length} projects`);
83
+
84
+ const idMap = { projects: {} };
85
+ const allHashes = {};
43
86
 
44
- // Generate flows.yaml
45
- if (verbose) console.log('📄 Generating flows.yaml...');
46
- await generateFlowsYaml(client, agents, verbose);
87
+ for (const project of projects) {
88
+ if (verbose) console.log(`\n📁 Processing project: ${project.idn} (${project.title})`);
89
+ const projectMap = await pullSingleProject(client, project.id, project.idn, verbose);
90
+ idMap.projects[project.idn] = projectMap;
47
91
 
48
- const hashes = {};
49
- for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
50
- for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
51
- for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
52
- const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
53
- const content = await fs.readFile(p, 'utf8');
54
- hashes[p] = sha256(content);
92
+ // Collect hashes for this project
93
+ for (const [agentIdn, agentObj] of Object.entries(projectMap.agents)) {
94
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
95
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
96
+ const p = skillPath(project.idn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
97
+ const content = await fs.readFile(p, 'utf8');
98
+ allHashes[p] = sha256(content);
99
+ }
55
100
  }
56
101
  }
57
102
  }
58
- await saveHashes(hashes);
103
+
104
+ await fs.writeJson(MAP_PATH, idMap, { spaces: 2 });
105
+ await saveHashes(allHashes);
59
106
  }
60
107
 
61
108
  export async function pushChanged(client, verbose = false) {
@@ -74,59 +121,68 @@ export async function pushChanged(client, verbose = false) {
74
121
  let pushed = 0;
75
122
  let scanned = 0;
76
123
 
77
- for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
78
- if (verbose) console.log(` 📁 Scanning agent: ${agentIdn}`);
79
- for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
80
- if (verbose) console.log(` 📁 Scanning flow: ${flowIdn}`);
81
- for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
82
- const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
83
- scanned++;
84
- if (verbose) console.log(` 📄 Checking: ${p}`);
85
-
86
- const content = await readIfExists(p);
87
- if (content === null) {
88
- if (verbose) console.log(` ⚠️ File not found: ${p}`);
89
- continue;
90
- }
91
-
92
- const h = sha256(content);
93
- const oldHash = oldHashes[p];
94
- if (verbose) {
95
- console.log(` 🔍 Hash comparison:`);
96
- console.log(` Old: ${oldHash || 'none'}`);
97
- console.log(` New: ${h}`);
98
- }
99
-
100
- if (oldHash !== h) {
101
- if (verbose) console.log(` 🔄 File changed, preparing to push...`);
124
+ // Handle both old single-project format and new multi-project format
125
+ const projects = idMap.projects || { '': idMap };
126
+
127
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
128
+ if (verbose && projectIdn) console.log(`📁 Scanning project: ${projectIdn}`);
129
+
130
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
131
+ if (verbose) console.log(` 📁 Scanning agent: ${agentIdn}`);
132
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
133
+ if (verbose) console.log(` 📁 Scanning flow: ${flowIdn}`);
134
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
135
+ const p = projectIdn ?
136
+ skillPath(projectIdn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type) :
137
+ skillPath('', agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
138
+ scanned++;
139
+ if (verbose) console.log(` 📄 Checking: ${p}`);
102
140
 
103
- // Create complete skill object with updated prompt_script
104
- const skillObject = {
105
- id: skillMeta.id,
106
- title: skillMeta.title,
107
- idn: skillMeta.idn,
108
- prompt_script: content,
109
- runner_type: skillMeta.runner_type,
110
- model: skillMeta.model,
111
- parameters: skillMeta.parameters,
112
- path: skillMeta.path
113
- };
141
+ const content = await readIfExists(p);
142
+ if (content === null) {
143
+ if (verbose) console.log(` ⚠️ File not found: ${p}`);
144
+ continue;
145
+ }
114
146
 
147
+ const h = sha256(content);
148
+ const oldHash = oldHashes[p];
115
149
  if (verbose) {
116
- console.log(` 📤 Pushing skill object:`);
117
- console.log(` ID: ${skillObject.id}`);
118
- console.log(` Title: ${skillObject.title}`);
119
- console.log(` IDN: ${skillObject.idn}`);
120
- console.log(` Content length: ${content.length} chars`);
121
- console.log(` Content preview: ${content.substring(0, 100).replace(/\n/g, '\\n')}...`);
150
+ console.log(` 🔍 Hash comparison:`);
151
+ console.log(` Old: ${oldHash || 'none'}`);
152
+ console.log(` New: ${h}`);
122
153
  }
123
154
 
124
- await updateSkill(client, skillObject);
125
- console.log(`↑ Pushed ${p}`);
126
- newHashes[p] = h;
127
- pushed++;
128
- } else if (verbose) {
129
- console.log(` ✓ No changes`);
155
+ if (oldHash !== h) {
156
+ if (verbose) console.log(` 🔄 File changed, preparing to push...`);
157
+
158
+ // Create complete skill object with updated prompt_script
159
+ const skillObject = {
160
+ id: skillMeta.id,
161
+ title: skillMeta.title,
162
+ idn: skillMeta.idn,
163
+ prompt_script: content,
164
+ runner_type: skillMeta.runner_type,
165
+ model: skillMeta.model,
166
+ parameters: skillMeta.parameters,
167
+ path: skillMeta.path
168
+ };
169
+
170
+ if (verbose) {
171
+ console.log(` 📤 Pushing skill object:`);
172
+ console.log(` ID: ${skillObject.id}`);
173
+ console.log(` Title: ${skillObject.title}`);
174
+ console.log(` IDN: ${skillObject.idn}`);
175
+ console.log(` Content length: ${content.length} chars`);
176
+ console.log(` Content preview: ${content.substring(0, 100).replace(/\n/g, '\\n')}...`);
177
+ }
178
+
179
+ await updateSkill(client, skillObject);
180
+ console.log(`↑ Pushed ${p}`);
181
+ newHashes[p] = h;
182
+ pushed++;
183
+ } else if (verbose) {
184
+ console.log(` ✓ No changes`);
185
+ }
130
186
  }
131
187
  }
132
188
  }
@@ -149,33 +205,42 @@ export async function status(verbose = false) {
149
205
  const hashes = await loadHashes();
150
206
  let dirty = 0;
151
207
 
152
- for (const [agentIdn, agentObj] of Object.entries(idMap.agents)) {
153
- if (verbose) console.log(` 📁 Checking agent: ${agentIdn}`);
154
- for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
155
- if (verbose) console.log(` 📁 Checking flow: ${flowIdn}`);
156
- for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
157
- const p = skillPath(agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
158
- const exists = await fs.pathExists(p);
159
- if (!exists) {
160
- console.log(`D ${p}`);
161
- dirty++;
162
- if (verbose) console.log(` ❌ Deleted: ${p}`);
163
- continue;
164
- }
165
- const content = await fs.readFile(p, 'utf8');
166
- const h = sha256(content);
167
- const oldHash = hashes[p];
168
- if (verbose) {
169
- console.log(` 📄 ${p}`);
170
- console.log(` Old hash: ${oldHash || 'none'}`);
171
- console.log(` New hash: ${h}`);
172
- }
173
- if (oldHash !== h) {
174
- console.log(`M ${p}`);
175
- dirty++;
176
- if (verbose) console.log(` 🔄 Modified: ${p}`);
177
- } else if (verbose) {
178
- console.log(` Unchanged: ${p}`);
208
+ // Handle both old single-project format and new multi-project format
209
+ const projects = idMap.projects || { '': idMap };
210
+
211
+ for (const [projectIdn, projectData] of Object.entries(projects)) {
212
+ if (verbose && projectIdn) console.log(`📁 Checking project: ${projectIdn}`);
213
+
214
+ for (const [agentIdn, agentObj] of Object.entries(projectData.agents)) {
215
+ if (verbose) console.log(` 📁 Checking agent: ${agentIdn}`);
216
+ for (const [flowIdn, flowObj] of Object.entries(agentObj.flows)) {
217
+ if (verbose) console.log(` 📁 Checking flow: ${flowIdn}`);
218
+ for (const [skillIdn, skillMeta] of Object.entries(flowObj.skills)) {
219
+ const p = projectIdn ?
220
+ skillPath(projectIdn, agentIdn, flowIdn, skillIdn, skillMeta.runner_type) :
221
+ skillPath('', agentIdn, flowIdn, skillIdn, skillMeta.runner_type);
222
+ const exists = await fs.pathExists(p);
223
+ if (!exists) {
224
+ console.log(`D ${p}`);
225
+ dirty++;
226
+ if (verbose) console.log(` Deleted: ${p}`);
227
+ continue;
228
+ }
229
+ const content = await fs.readFile(p, 'utf8');
230
+ const h = sha256(content);
231
+ const oldHash = hashes[p];
232
+ if (verbose) {
233
+ console.log(` 📄 ${p}`);
234
+ console.log(` Old hash: ${oldHash || 'none'}`);
235
+ console.log(` New hash: ${h}`);
236
+ }
237
+ if (oldHash !== h) {
238
+ console.log(`M ${p}`);
239
+ dirty++;
240
+ if (verbose) console.log(` 🔄 Modified: ${p}`);
241
+ } else if (verbose) {
242
+ console.log(` ✓ Unchanged: ${p}`);
243
+ }
179
244
  }
180
245
  }
181
246
  }
@@ -183,7 +248,7 @@ export async function status(verbose = false) {
183
248
  console.log(dirty ? `${dirty} changed file(s).` : 'Clean.');
184
249
  }
185
250
 
186
- async function generateFlowsYaml(client, agents, verbose = false) {
251
+ async function generateFlowsYaml(client, agents, projectIdn, verbose = false) {
187
252
  const flowsData = { flows: [] };
188
253
 
189
254
  for (const agent of agents) {
@@ -278,7 +343,7 @@ async function generateFlowsYaml(client, agents, verbose = false) {
278
343
  // Post-process to fix enum formatting
279
344
  yamlContent = yamlContent.replace(/"(!enum "[^"]+")"/g, '$1');
280
345
 
281
- const yamlPath = path.join('flows.yaml');
282
- await fs.writeFile(yamlPath, yamlContent, 'utf8');
283
- console.log(`✓ Generated flows.yaml`);
346
+ const yamlPath = path.join(projectDir(projectIdn), 'flows.yaml');
347
+ await writeFileAtomic(yamlPath, yamlContent);
348
+ console.log(`✓ Generated flows.yaml for ${projectIdn}`);
284
349
  }