localsage 1.0.0 → 1.0.2
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/LICENSE +1 -1
- package/README.md +110 -9
- package/bin/localsage +4 -0
- package/cli.js +129 -0
- package/index.js +97 -5
- package/package.json +52 -6
- package/scripts/setup.js +46 -0
- package/src/history.js +66 -0
- package/src/knowledge.js +139 -0
- package/src/model.js +99 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,25 +1,126 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 🧙 LocalSage
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Your local, offline AI assistant that knows your documents.**
|
|
4
|
+
|
|
5
|
+
LocalSage runs a Qwen language model entirely on your machine (via Transformers.js) and answers questions using your own PDF, TXT, and Markdown files. Pure Node.js — no Python, no API keys, no data leaving your computer.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 🤖 Runs AI locally via Transformers.js (ONNX) — no cloud, no API costs
|
|
10
|
+
- 📚 Chat with your PDF, TXT, and MD files
|
|
11
|
+
- 🔒 100% private — everything stays on your machine
|
|
12
|
+
- 💾 Conversation history saved automatically
|
|
13
|
+
- 🔧 Customizable model, knowledge folder, and generation settings
|
|
14
|
+
- 📦 Pure Node.js — no Python required
|
|
4
15
|
|
|
5
16
|
## Installation
|
|
6
17
|
|
|
7
18
|
```bash
|
|
8
|
-
npm install localsage
|
|
19
|
+
npm install -g localsage
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
1. Go to any folder that contains your documents (PDF, TXT, MD) — or create a `knowledge` subfolder for them:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd my-documents
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
2. Start LocalSage right there:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
localsage
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
LocalSage picks the knowledge source automatically:
|
|
37
|
+
1. the path you pass with `-k / --knowledge`, if given
|
|
38
|
+
2. otherwise a `knowledge/` subfolder in the current directory, if it exists
|
|
39
|
+
3. otherwise **the current directory itself** — so you can just `cd` into your docs folder and run `localsage`
|
|
40
|
+
|
|
41
|
+
The first run downloads the model (~500 MB) to your Hugging Face cache. After that, it works fully offline.
|
|
42
|
+
|
|
43
|
+
3. Ask questions about your documents!
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
- `exit` — save history and quit
|
|
48
|
+
- `clear` — clear conversation history
|
|
49
|
+
- `history` — show number of stored messages
|
|
50
|
+
|
|
51
|
+
## Options
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
localsage [options]
|
|
55
|
+
|
|
56
|
+
-m, --model <model> Model to use (default: "onnx-community/Qwen2.5-0.5B-Instruct")
|
|
57
|
+
-k, --knowledge <path> Knowledge folder path (default: ./knowledge if it
|
|
58
|
+
exists, else the current directory)
|
|
59
|
+
--no-pdf Disable PDF support
|
|
60
|
+
--max-tokens <number> Max tokens for response (default: 200)
|
|
61
|
+
--temperature <number> Temperature for generation (default: 0.7)
|
|
62
|
+
-h, --help Show help
|
|
63
|
+
-V, --version Show version
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
> **Important:** the model must be a repo that ships ONNX weights, e.g. the
|
|
67
|
+
> `onnx-community/...` mirrors. Plain `Qwen/Qwen2.5-*` repos will NOT load
|
|
68
|
+
> in Transformers.js.
|
|
69
|
+
|
|
70
|
+
### Examples
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# Use a bigger model
|
|
74
|
+
localsage --model onnx-community/Qwen2.5-1.5B-Instruct
|
|
75
|
+
|
|
76
|
+
# Custom knowledge folder
|
|
77
|
+
localsage --knowledge ./my_docs
|
|
78
|
+
|
|
79
|
+
# Adjust generation
|
|
80
|
+
localsage --max-tokens 300 --temperature 0.8
|
|
9
81
|
```
|
|
10
82
|
|
|
11
|
-
## Usage
|
|
83
|
+
## Programmatic Usage
|
|
12
84
|
|
|
13
85
|
```javascript
|
|
14
|
-
const
|
|
86
|
+
const LocalSage = require('localsage');
|
|
15
87
|
|
|
16
|
-
|
|
17
|
-
|
|
88
|
+
async function main() {
|
|
89
|
+
const assistant = new LocalSage({
|
|
90
|
+
model: 'onnx-community/Qwen2.5-0.5B-Instruct',
|
|
91
|
+
knowledgePath: './knowledge',
|
|
92
|
+
maxTokens: 200,
|
|
93
|
+
temperature: 0.7,
|
|
94
|
+
});
|
|
18
95
|
|
|
19
|
-
|
|
20
|
-
|
|
96
|
+
await assistant.initialize();
|
|
97
|
+
const response = await assistant.chat('What is the capital of France?');
|
|
98
|
+
console.log(response);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
main();
|
|
21
102
|
```
|
|
22
103
|
|
|
104
|
+
## How It Works
|
|
105
|
+
|
|
106
|
+
1. **Knowledge loading** — reads all TXT, MD, and PDF files from the resolved knowledge folder (explicit `-k` path → `./knowledge` subfolder → current directory), plus an optional `knowledge.txt` in the working directory
|
|
107
|
+
2. **AI processing** — runs the Qwen model locally with `@huggingface/transformers`
|
|
108
|
+
3. **History** — saves the conversation to `history.json` in the working directory
|
|
109
|
+
|
|
110
|
+
## Requirements
|
|
111
|
+
|
|
112
|
+
- Node.js 18+
|
|
113
|
+
- 4 GB+ RAM recommended
|
|
114
|
+
- ~1–2 GB free disk space for model files
|
|
115
|
+
|
|
116
|
+
## Model Storage
|
|
117
|
+
|
|
118
|
+
Models are cached by Transformers.js on first run. Override the cache location with the `LOCALSAGE_MODEL_DIR` environment variable.
|
|
119
|
+
|
|
120
|
+
## 💼 Open to Work
|
|
121
|
+
|
|
122
|
+
Built by a developer available for freelance and full-time opportunities. Got a project? Let's talk — 📧 [susheelhbti@gmail.com](mailto:susheelhbti@gmail.com)
|
|
123
|
+
|
|
23
124
|
## License
|
|
24
125
|
|
|
25
126
|
MIT
|
package/bin/localsage
ADDED
package/cli.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const inquirer = require('inquirer');
|
|
5
|
+
const { program } = require('commander');
|
|
6
|
+
const LocalSage = require('./index');
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name('localsage')
|
|
10
|
+
.version('1.0.0')
|
|
11
|
+
.description('LocalSage — local, offline AI assistant with PDF & document knowledge')
|
|
12
|
+
.option('-m, --model <model>', 'Model name', 'onnx-community/Qwen2.5-0.5B-Instruct')
|
|
13
|
+
.option(
|
|
14
|
+
'-k, --knowledge <path>',
|
|
15
|
+
'Knowledge folder path (default: ./knowledge if it exists, else the current directory)'
|
|
16
|
+
)
|
|
17
|
+
.option('--no-pdf', 'Disable PDF support')
|
|
18
|
+
.option('--max-tokens <number>', 'Max tokens for response', '200')
|
|
19
|
+
.option('--temperature <number>', 'Temperature for generation', '0.7')
|
|
20
|
+
.parse(process.argv);
|
|
21
|
+
|
|
22
|
+
const options = program.opts();
|
|
23
|
+
|
|
24
|
+
async function runCLI() {
|
|
25
|
+
const assistant = new LocalSage({
|
|
26
|
+
model: options.model,
|
|
27
|
+
knowledgePath: options.knowledge,
|
|
28
|
+
maxTokens: parseInt(options.maxTokens, 10),
|
|
29
|
+
temperature: parseFloat(options.temperature),
|
|
30
|
+
pdfSupport: options.pdf !== false,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await assistant.initialize();
|
|
34
|
+
|
|
35
|
+
console.log(chalk.blue('='.repeat(60)));
|
|
36
|
+
console.log(chalk.green.bold(' 🧙 LocalSage — your local AI assistant '));
|
|
37
|
+
console.log(chalk.blue('='.repeat(60)));
|
|
38
|
+
console.log(chalk.gray(` Model: ${options.model}`));
|
|
39
|
+
console.log(chalk.gray(` Knowledge: ${assistant.getResolvedKnowledgePath()}`));
|
|
40
|
+
console.log(chalk.gray(` PDF Support: ${options.pdf !== false ? 'Enabled' : 'Disabled'}`));
|
|
41
|
+
console.log(chalk.blue('='.repeat(60)));
|
|
42
|
+
console.log(chalk.gray(' Commands:'));
|
|
43
|
+
console.log(chalk.gray(' exit - Save and exit'));
|
|
44
|
+
console.log(chalk.gray(' clear - Clear conversation history'));
|
|
45
|
+
console.log(chalk.gray(' history - Show number of stored messages'));
|
|
46
|
+
console.log(chalk.blue('='.repeat(60)));
|
|
47
|
+
console.log(
|
|
48
|
+
chalk.magenta(' 💼 Open to Work — built by a developer available for freelance')
|
|
49
|
+
);
|
|
50
|
+
console.log(
|
|
51
|
+
chalk.magenta(' and full-time opportunities. Got a project? Let\'s talk —')
|
|
52
|
+
);
|
|
53
|
+
console.log(chalk.magenta.bold(' 📧 susheelhbti@gmail.com'));
|
|
54
|
+
console.log(chalk.blue('='.repeat(60)));
|
|
55
|
+
|
|
56
|
+
// eslint-disable-next-line no-constant-condition
|
|
57
|
+
while (true) {
|
|
58
|
+
try {
|
|
59
|
+
const { input } = await inquirer.prompt([
|
|
60
|
+
{
|
|
61
|
+
type: 'input',
|
|
62
|
+
name: 'input',
|
|
63
|
+
message: 'You',
|
|
64
|
+
prefix: '',
|
|
65
|
+
validate: (v) => v.trim().length > 0 || 'Please enter a message',
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
const trimmed = input.trim();
|
|
70
|
+
const cmd = trimmed.toLowerCase();
|
|
71
|
+
|
|
72
|
+
if (cmd === 'exit') {
|
|
73
|
+
await assistant.saveHistory();
|
|
74
|
+
console.log(chalk.green('\n✓ History Saved'));
|
|
75
|
+
console.log(chalk.blue('Bye!'));
|
|
76
|
+
console.log(
|
|
77
|
+
chalk.magenta('💼 Need a developer? 📧 susheelhbti@gmail.com')
|
|
78
|
+
);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (cmd === 'clear') {
|
|
83
|
+
await assistant.clearHistory();
|
|
84
|
+
console.log(chalk.green('\n✓ History Cleared'));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (cmd === 'history') {
|
|
89
|
+
console.log(chalk.yellow(`\nStored Messages : ${assistant.getHistoryCount()}`));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
process.stdout.write(chalk.cyan('\nAI : '));
|
|
94
|
+
const response = await assistant.chat(trimmed);
|
|
95
|
+
console.log(chalk.green(response));
|
|
96
|
+
console.log('');
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// inquirer throws when the prompt stream is closed (e.g. Ctrl+C / EOF)
|
|
99
|
+
if (
|
|
100
|
+
error?.message?.includes('closed') ||
|
|
101
|
+
error?.name === 'ExitPromptError' ||
|
|
102
|
+
error?.isTtyError
|
|
103
|
+
) {
|
|
104
|
+
await assistant.saveHistory().catch(() => {});
|
|
105
|
+
console.log(chalk.blue('\nBye!'));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
console.error(chalk.red('Error:'), error.message);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.on('uncaughtException', (error) => {
|
|
114
|
+
console.error(chalk.red('Uncaught Error:'), error.message);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
process.on('unhandledRejection', (reason) => {
|
|
119
|
+
console.error(chalk.red('Unhandled Rejection:'), reason);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (require.main === module) {
|
|
123
|
+
runCLI().catch((error) => {
|
|
124
|
+
console.error(chalk.red('Fatal Error:'), error.message);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = runCLI;
|
package/index.js
CHANGED
|
@@ -1,7 +1,99 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const QwenModel = require('./src/model');
|
|
2
|
+
const KnowledgeManager = require('./src/knowledge');
|
|
3
|
+
const HistoryManager = require('./src/history');
|
|
4
|
+
|
|
5
|
+
class LocalSage {
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.options = {
|
|
8
|
+
model: options.model || 'onnx-community/Qwen2.5-0.5B-Instruct',
|
|
9
|
+
// null = auto-resolve: ./knowledge if it exists, else the current
|
|
10
|
+
// working directory the user ran the command from
|
|
11
|
+
knowledgePath: options.knowledgePath || null,
|
|
12
|
+
maxTokens: options.maxTokens || 200,
|
|
13
|
+
temperature: options.temperature || 0.7,
|
|
14
|
+
topP: options.topP || 0.9,
|
|
15
|
+
pdfSupport: options.pdfSupport !== false,
|
|
16
|
+
...options,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
this.model = new QwenModel({
|
|
20
|
+
model: this.options.model,
|
|
21
|
+
maxTokens: this.options.maxTokens,
|
|
22
|
+
temperature: this.options.temperature,
|
|
23
|
+
topP: this.options.topP,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
this.knowledge = new KnowledgeManager(this.options.knowledgePath);
|
|
27
|
+
this.history = new HistoryManager('history.json', 34);
|
|
28
|
+
this.isInitialized = false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async initialize() {
|
|
32
|
+
if (this.isInitialized) return;
|
|
33
|
+
await this.model.load();
|
|
34
|
+
await this.knowledge.loadKnowledge();
|
|
35
|
+
await this.history.loadHistory();
|
|
36
|
+
this.isInitialized = true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async chat(message) {
|
|
40
|
+
if (!this.isInitialized) {
|
|
41
|
+
await this.initialize();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const knowledge = this.knowledge.getKnowledge(true, 4000);
|
|
45
|
+
const systemPrompt = `You are a helpful AI assistant.
|
|
46
|
+
|
|
47
|
+
Answer only using the information below whenever possible.
|
|
48
|
+
|
|
49
|
+
If the answer is not found in the knowledge, you may answer using your general knowledge, but clearly mention that it is not from the provided knowledge.
|
|
50
|
+
|
|
51
|
+
Knowledge:
|
|
52
|
+
|
|
53
|
+
${knowledge}`;
|
|
54
|
+
|
|
55
|
+
// FIX: add the user's message BEFORE generating,
|
|
56
|
+
// otherwise the model never sees the current question.
|
|
57
|
+
this.history.addMessage('user', message);
|
|
58
|
+
|
|
59
|
+
const response = await this.model.generateResponse(
|
|
60
|
+
systemPrompt,
|
|
61
|
+
this.history.getHistory()
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
this.history.addMessage('assistant', response);
|
|
65
|
+
await this.history.saveHistory();
|
|
66
|
+
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async saveHistory() {
|
|
71
|
+
await this.history.saveHistory();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async clearHistory() {
|
|
75
|
+
await this.history.clear();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getHistoryCount() {
|
|
79
|
+
return this.history.getCount();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getHistory() {
|
|
83
|
+
return this.history.getHistory();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getKnowledge() {
|
|
87
|
+
return this.knowledge.getKnowledge();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getResolvedKnowledgePath() {
|
|
91
|
+
return this.knowledge.getResolvedPath();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async reloadKnowledge() {
|
|
95
|
+
await this.knowledge.loadKnowledge();
|
|
96
|
+
}
|
|
3
97
|
}
|
|
4
98
|
|
|
5
|
-
module.exports =
|
|
6
|
-
hello
|
|
7
|
-
};
|
|
99
|
+
module.exports = LocalSage;
|
package/package.json
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "localsage",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "LocalSage — a local, offline AI assistant with PDF & document knowledge. Powered by Qwen via Transformers.js. Pure Node.js, no Python.",
|
|
5
5
|
"main": "index.js",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"localsage": "bin/localsage"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node cli.js",
|
|
11
|
+
"setup": "node scripts/setup.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"ai",
|
|
15
|
+
"assistant",
|
|
16
|
+
"local-ai",
|
|
17
|
+
"offline",
|
|
18
|
+
"qwen",
|
|
19
|
+
"llm",
|
|
20
|
+
"chatbot",
|
|
21
|
+
"pdf",
|
|
22
|
+
"rag",
|
|
23
|
+
"transformers",
|
|
24
|
+
"chat-with-docs"
|
|
25
|
+
],
|
|
26
|
+
"author": "susheelhbti <susheelhbti@gmail.com>",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@huggingface/transformers": "^3.3.1",
|
|
30
|
+
"chalk": "^4.1.2",
|
|
31
|
+
"commander": "^11.1.0",
|
|
32
|
+
"fs-extra": "^11.2.0",
|
|
33
|
+
"inquirer": "^8.2.6",
|
|
34
|
+
"pdf-parse": "^1.1.1"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/susheelhbti/localsage.git"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/susheelhbti/localsage/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/susheelhbti/localsage#readme",
|
|
47
|
+
"files": [
|
|
48
|
+
"bin/",
|
|
49
|
+
"src/",
|
|
50
|
+
"scripts/",
|
|
51
|
+
"index.js",
|
|
52
|
+
"cli.js",
|
|
53
|
+
"README.md",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
]
|
|
10
56
|
}
|
package/scripts/setup.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Optional helper: run `npm run setup` to scaffold a knowledge folder
|
|
3
|
+
// in the CURRENT directory.
|
|
4
|
+
// NOTE: intentionally NOT a postinstall hook — postinstall runs inside
|
|
5
|
+
// node_modules and would fail / pollute the wrong directory.
|
|
6
|
+
|
|
7
|
+
const fs = require('fs-extra');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
|
|
11
|
+
console.log(chalk.cyan('Setting up LocalSage...'));
|
|
12
|
+
|
|
13
|
+
async function setup() {
|
|
14
|
+
const knowledgePath = path.join(process.cwd(), 'knowledge');
|
|
15
|
+
if (!(await fs.pathExists(knowledgePath))) {
|
|
16
|
+
await fs.mkdir(knowledgePath);
|
|
17
|
+
console.log(chalk.green('✓ Created knowledge folder'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sampleFile = path.join(knowledgePath, 'sample.txt');
|
|
21
|
+
if (!(await fs.pathExists(sampleFile))) {
|
|
22
|
+
await fs.writeFile(
|
|
23
|
+
sampleFile,
|
|
24
|
+
'This is a sample knowledge file.\n\n' +
|
|
25
|
+
'You can add your own text files or PDFs here.\n' +
|
|
26
|
+
'The AI will use this information to answer questions.\n\n' +
|
|
27
|
+
'Example:\n' +
|
|
28
|
+
'The capital of France is Paris.\n' +
|
|
29
|
+
'Water boils at 100 degrees Celsius.\n'
|
|
30
|
+
);
|
|
31
|
+
console.log(chalk.green('✓ Created sample knowledge file'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(chalk.green('\n✓ Setup complete!'));
|
|
35
|
+
console.log(chalk.blue('\nTo start the assistant:'));
|
|
36
|
+
console.log(' localsage');
|
|
37
|
+
console.log(chalk.gray('\nOr with options:'));
|
|
38
|
+
console.log(
|
|
39
|
+
' localsage --model onnx-community/Qwen2.5-1.5B-Instruct --knowledge ./my_knowledge'
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setup().catch((error) => {
|
|
44
|
+
console.error(chalk.red('Setup error:'), error.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
package/src/history.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class HistoryManager {
|
|
5
|
+
constructor(historyFile = 'history.json', maxHistory = 34) {
|
|
6
|
+
this.historyFile = historyFile;
|
|
7
|
+
// keep an even number so user/assistant pairs stay intact
|
|
8
|
+
this.maxHistory = maxHistory % 2 === 0 ? maxHistory : maxHistory + 1;
|
|
9
|
+
this.messages = [];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
filePath() {
|
|
13
|
+
return path.join(process.cwd(), this.historyFile);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async loadHistory() {
|
|
17
|
+
if (await fs.pathExists(this.filePath())) {
|
|
18
|
+
try {
|
|
19
|
+
const data = await fs.readFile(this.filePath(), 'utf-8');
|
|
20
|
+
this.messages = JSON.parse(data);
|
|
21
|
+
if (!Array.isArray(this.messages)) this.messages = [];
|
|
22
|
+
console.log(`History Loaded. ${this.messages.length} messages.\n`);
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.error('Error loading history:', error.message);
|
|
25
|
+
this.messages = [];
|
|
26
|
+
}
|
|
27
|
+
} else {
|
|
28
|
+
this.messages = [];
|
|
29
|
+
}
|
|
30
|
+
return this.messages;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async saveHistory() {
|
|
34
|
+
try {
|
|
35
|
+
await fs.writeJson(this.filePath(), this.messages, { spaces: 2 });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('Error saving history:', error.message);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
addMessage(role, content) {
|
|
42
|
+
this.messages.push({ role, content });
|
|
43
|
+
if (this.messages.length > this.maxHistory) {
|
|
44
|
+
this.messages = this.messages.slice(-this.maxHistory);
|
|
45
|
+
// never let the trimmed history start with an assistant message
|
|
46
|
+
if (this.messages[0]?.role === 'assistant') {
|
|
47
|
+
this.messages.shift();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async clear() {
|
|
53
|
+
this.messages = [];
|
|
54
|
+
await this.saveHistory();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
getHistory() {
|
|
58
|
+
return this.messages;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getCount() {
|
|
62
|
+
return this.messages.length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = HistoryManager;
|
package/src/knowledge.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const fs = require('fs-extra');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class KnowledgeManager {
|
|
5
|
+
/**
|
|
6
|
+
* @param {string|null} knowledgePath - explicit path from the user, or null
|
|
7
|
+
* to auto-resolve: use ./knowledge if it exists, otherwise the
|
|
8
|
+
* current working directory itself.
|
|
9
|
+
*/
|
|
10
|
+
constructor(knowledgePath = null) {
|
|
11
|
+
this.requestedPath = knowledgePath;
|
|
12
|
+
this.knowledgePath = null; // resolved in loadKnowledge()
|
|
13
|
+
this.knowledge = '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
resolvePath() {
|
|
17
|
+
if (this.requestedPath) {
|
|
18
|
+
return path.resolve(this.requestedPath);
|
|
19
|
+
}
|
|
20
|
+
const sub = path.join(process.cwd(), 'knowledge');
|
|
21
|
+
if (fs.pathExistsSync(sub)) {
|
|
22
|
+
return sub;
|
|
23
|
+
}
|
|
24
|
+
// Fall back to the directory the user ran the command from
|
|
25
|
+
return process.cwd();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async loadKnowledge() {
|
|
29
|
+
this.knowledgePath = this.resolvePath();
|
|
30
|
+
let combinedKnowledge = '';
|
|
31
|
+
|
|
32
|
+
// Load a standalone knowledge.txt from the working directory —
|
|
33
|
+
// but only if it isn't already inside the folder we're about to scan
|
|
34
|
+
// (avoids loading it twice when knowledgePath === cwd).
|
|
35
|
+
const txtFile = path.join(process.cwd(), 'knowledge.txt');
|
|
36
|
+
if (
|
|
37
|
+
path.dirname(txtFile) !== this.knowledgePath &&
|
|
38
|
+
(await fs.pathExists(txtFile))
|
|
39
|
+
) {
|
|
40
|
+
try {
|
|
41
|
+
combinedKnowledge += await fs.readFile(txtFile, 'utf-8');
|
|
42
|
+
console.log('Knowledge loaded from knowledge.txt');
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error('Error loading knowledge.txt:', error.message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (await fs.pathExists(this.knowledgePath)) {
|
|
49
|
+
console.log(`Loading knowledge from: ${this.knowledgePath}`);
|
|
50
|
+
const folderKnowledge = await this.loadFolder(this.knowledgePath);
|
|
51
|
+
if (folderKnowledge) {
|
|
52
|
+
if (combinedKnowledge) {
|
|
53
|
+
combinedKnowledge += '\n\n' + '='.repeat(50) + '\n\n';
|
|
54
|
+
}
|
|
55
|
+
combinedKnowledge += folderKnowledge;
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.log(`Knowledge path not found: ${this.knowledgePath}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.knowledge = combinedKnowledge;
|
|
62
|
+
|
|
63
|
+
if (!this.knowledge.trim()) {
|
|
64
|
+
console.log('Warning: No knowledge loaded. Using general knowledge only.\n');
|
|
65
|
+
} else {
|
|
66
|
+
console.log(`Total knowledge loaded: ${this.knowledge.length} characters\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return this.knowledge;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async loadFolder(folderPath) {
|
|
73
|
+
let combined = '';
|
|
74
|
+
const files = await fs.readdir(folderPath);
|
|
75
|
+
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const filePath = path.join(folderPath, file);
|
|
78
|
+
|
|
79
|
+
let stat;
|
|
80
|
+
try {
|
|
81
|
+
stat = await fs.stat(filePath);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (stat.isDirectory()) continue;
|
|
86
|
+
|
|
87
|
+
const ext = path.extname(file).toLowerCase();
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
if (ext === '.txt' || ext === '.md') {
|
|
91
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
92
|
+
combined += `\n\n--- Content from ${file} ---\n\n${content}`;
|
|
93
|
+
console.log(`Loaded: ${file}`);
|
|
94
|
+
} else if (ext === '.pdf') {
|
|
95
|
+
console.log(`Reading PDF: ${file}`);
|
|
96
|
+
const content = await this.extractPDF(filePath);
|
|
97
|
+
if (content.trim()) {
|
|
98
|
+
combined += `\n\n--- Content from ${file} ---\n\n${content}`;
|
|
99
|
+
console.log(`Loaded: ${file}`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`Warning: No text extracted from ${file}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error(`Error reading ${file}:`, error.message);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return combined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async extractPDF(filePath) {
|
|
113
|
+
try {
|
|
114
|
+
const pdfParse = require('pdf-parse');
|
|
115
|
+
const dataBuffer = await fs.readFile(filePath);
|
|
116
|
+
const data = await pdfParse(dataBuffer);
|
|
117
|
+
return data.text || '';
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error(`Error extracting PDF ${filePath}:`, error.message);
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getResolvedPath() {
|
|
125
|
+
return this.knowledgePath || this.resolvePath();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getKnowledge(truncate = true, maxLength = 4000) {
|
|
129
|
+
if (!truncate || this.knowledge.length <= maxLength) {
|
|
130
|
+
return this.knowledge;
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
this.knowledge.substring(0, maxLength) +
|
|
134
|
+
'\n\n[Knowledge truncated due to length]'
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = KnowledgeManager;
|
package/src/model.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
// @huggingface/transformers is an ES Module — load it via dynamic import
|
|
4
|
+
// so this package stays CommonJS-friendly.
|
|
5
|
+
let transformersPromise = null;
|
|
6
|
+
function loadTransformers() {
|
|
7
|
+
if (!transformersPromise) {
|
|
8
|
+
transformersPromise = import('@huggingface/transformers');
|
|
9
|
+
}
|
|
10
|
+
return transformersPromise;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class QwenModel {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
// NOTE: must be a repo that ships ONNX weights.
|
|
16
|
+
// "Qwen/Qwen2.5-0.5B-Instruct" does NOT — "onnx-community/..." does.
|
|
17
|
+
this.modelName = options.model || 'onnx-community/Qwen2.5-0.5B-Instruct';
|
|
18
|
+
this.maxTokens = options.maxTokens || 200;
|
|
19
|
+
this.temperature = options.temperature || 0.7;
|
|
20
|
+
this.topP = options.topP || 0.9;
|
|
21
|
+
this.repetitionPenalty = options.repetitionPenalty || 1.1;
|
|
22
|
+
this.pipeline = null;
|
|
23
|
+
this.isLoaded = false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async load() {
|
|
27
|
+
if (this.isLoaded) return;
|
|
28
|
+
|
|
29
|
+
console.log('Loading AI Model (first run downloads ~500MB, please wait)...');
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const { pipeline, env } = await loadTransformers();
|
|
33
|
+
|
|
34
|
+
// Cache models inside the user's home cache dir (default behaviour),
|
|
35
|
+
// but allow override via LOCALSAGE_MODEL_DIR.
|
|
36
|
+
if (process.env.LOCALSAGE_MODEL_DIR) {
|
|
37
|
+
env.cacheDir = path.resolve(process.env.LOCALSAGE_MODEL_DIR);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.pipeline = await pipeline('text-generation', this.modelName, {
|
|
41
|
+
dtype: 'q4', // quantized for speed / low RAM
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
this.isLoaded = true;
|
|
45
|
+
console.log('Model Loaded Successfully.\n');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Error loading model:', error.message);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} systemPrompt
|
|
54
|
+
* @param {Array<{role:string, content:string}>} messages - full chat history,
|
|
55
|
+
* MUST already include the latest user message.
|
|
56
|
+
*/
|
|
57
|
+
async generateResponse(systemPrompt, messages = []) {
|
|
58
|
+
if (!this.isLoaded) {
|
|
59
|
+
await this.load();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const chat = [
|
|
64
|
+
{ role: 'system', content: systemPrompt },
|
|
65
|
+
...(Array.isArray(messages) ? messages : []),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const result = await this.pipeline(chat, {
|
|
69
|
+
max_new_tokens: this.maxTokens,
|
|
70
|
+
temperature: this.temperature,
|
|
71
|
+
top_p: this.topP,
|
|
72
|
+
repetition_penalty: this.repetitionPenalty,
|
|
73
|
+
do_sample: true,
|
|
74
|
+
return_full_text: false,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// With chat (messages) input, generated_text is the message list;
|
|
78
|
+
// the assistant reply is the last entry. With return_full_text: false
|
|
79
|
+
// some versions return just the new text — handle both shapes.
|
|
80
|
+
const generated = result?.[0]?.generated_text;
|
|
81
|
+
|
|
82
|
+
if (typeof generated === 'string') {
|
|
83
|
+
return generated.trim() || "I'm sorry, I couldn't generate a response.";
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(generated)) {
|
|
86
|
+
const last = generated[generated.length - 1];
|
|
87
|
+
const text = typeof last === 'string' ? last : last?.content;
|
|
88
|
+
return (text || '').trim() || "I'm sorry, I couldn't generate a response.";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return "I'm sorry, I couldn't generate a response.";
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Error generating response:', error.message);
|
|
94
|
+
return "I'm sorry, I encountered an error while generating a response.";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = QwenModel;
|