Compare commits

..

7 Commits

Author SHA1 Message Date
koalazf.99
5cd3349773 rename GEMINI_DIR to QWEN_DIR 2025-08-26 20:14:10 +08:00
koalazf.99
f73d662260 update: remove context.services.config?.getUserMemory() logic from project level memory show 2025-08-26 19:44:02 +08:00
koalazf.99
65b3db8cb2 merge main and fix conflict 2025-08-26 13:50:29 +08:00
koalazf.99
380afc53cb update: use sub-command to switch between project and global memory ops 2025-08-26 13:18:11 +08:00
koalazf99
300881405a tmp 2025-08-24 00:31:10 +08:00
博凡
9f1164b221 merge main 2025-08-23 21:00:54 +08:00
koalazf.99
d7f7580a30 support: qwen md selection 2025-08-18 22:34:08 +08:00
22 changed files with 451 additions and 184 deletions

View File

@@ -46,7 +46,7 @@ jobs:
- name: 'Log in to the Container registry'
if: |-
${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.publish == 'true') }}
${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }}
uses: 'docker/login-action@v3' # ratchet:exclude
with:
registry: '${{ env.REGISTRY }}'

View File

@@ -95,19 +95,10 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
npm ci
- name: 'Run formatter check'
run: |-
@@ -282,24 +273,15 @@ jobs:
with:
node-version: '${{ matrix.node-version }}'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
- name: 'Build project'
run: |-
npm run build
- name: 'Install dependencies for testing'
run: |-
npm ci
- name: 'Run tests and generate reports'
env:
NO_COLOR: true

View File

@@ -28,19 +28,10 @@ jobs:
with:
node-version: '${{ matrix.node-version }}'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
npm ci
- name: 'Build project'
run: |-
@@ -83,19 +74,10 @@ jobs:
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
registry-url: 'https://registry.npmjs.org/'
- name: 'Configure npm for rate limiting'
run: |-
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-retries 5
npm config set fetch-timeout 300000
- name: 'Install dependencies'
run: |-
npm ci --prefer-offline --no-audit --progress=false
npm ci
- name: 'Build project'
run: |-

View File

@@ -48,7 +48,7 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
settings_json: |-
settings_json: |
{
"maxSessionTurns": 25,
"coreTools": [
@@ -68,7 +68,7 @@ jobs:
## Steps
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Use shell command `echo` to check the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
2. Use right tool to review the issue title and body provided in the environment variables: "${ISSUE_TITLE}" and "${ISSUE_BODY}".
3. Ignore any existing priorities or tags on the issue. Just report your findings.
4. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*. For area/* and kind/* limit yourself to only the single most applicable label in each case.
6. Apply the selected labels to this issue using: `gh issue edit ${{ github.event.issue.number }} --repo ${{ github.repository }} --add-label "label1,label2"`.

View File

@@ -36,7 +36,7 @@ jobs:
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
run: |
echo "🔍 Finding issues without labels..."
NO_LABEL_ISSUES=$(gh issue list --repo ${{ github.repository }} --search "is:open is:issue no:label" --json number,title,body)
@@ -66,7 +66,7 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
settings_json: |-
settings_json: |
{
"maxSessionTurns": 25,
"coreTools": [
@@ -88,7 +88,7 @@ jobs:
## Steps
1. Run: `gh label list --repo ${{ github.repository }} --limit 100` to get all available labels.
2. Use shell command `echo` to check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
2. Use right tool to check environment variable for issues to triage: $ISSUES_TO_TRIAGE (JSON array of issues)
3. Review the issue title, body and any comments provided in the environment variables.
4. Ignore any existing priorities or tags on the issue.
5. Select the most relevant labels from the existing labels, focusing on kind/*, area/*, sub-area/* and priority/*.

View File

@@ -16,7 +16,7 @@ on:
jobs:
review-pr:
if: |-
if: >
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
github.event.action == 'opened' &&
@@ -59,7 +59,7 @@ jobs:
${{ github.event_name == 'pull_request_target' || github.event_name == 'workflow_dispatch' }}
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |-
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PR_NUMBER=${{ github.event.inputs.pr_number }}
else
@@ -82,7 +82,7 @@ jobs:
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
COMMENT_BODY: '${{ github.event.comment.body }}'
run: |-
run: |
PR_NUMBER=${{ github.event.issue.number }}
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
# Extract additional instructions from comment
@@ -110,15 +110,22 @@ jobs:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
settings_json: |-
settings_json: |
{
"coreTools": [
"run_shell_command",
"run_shell_command(echo)",
"run_shell_command(gh pr view)",
"run_shell_command(gh pr diff)",
"run_shell_command(gh pr comment)",
"run_shell_command(cat)",
"run_shell_command(head)",
"run_shell_command(tail)",
"run_shell_command(grep)",
"write_file"
],
"sandbox": false
}
prompt: |-
prompt: |
You are an expert code reviewer. You have access to shell commands to gather PR information and perform the review.
IMPORTANT: Use the available shell commands to gather information. Do not ask for information to be provided.

View File

@@ -69,11 +69,6 @@ This guide provides solutions to common issues and debugging tips, including top
- **Cause:** The `is-in-ci` package checks for the presence of `CI`, `CONTINUOUS_INTEGRATION`, or any environment variable with a `CI_` prefix. When any of these are found, it signals that the environment is non-interactive, which prevents the CLI from starting in its interactive mode.
- **Solution:** If the `CI_` prefixed variable is not needed for the CLI to function, you can temporarily unset it for the command. e.g., `env -u CI_TOKEN qwen`
- **CLI becomes unresponsive during interactive selection dialogs**
- **Issue:** When encountering selection dialogs with numbered options (such as shell command confirmation dialogs), the CLI appears "frozen" and does not respond to keyboard input.
- **Cause:** This was caused by multiple keyboard input handlers (`useKeypress` hooks) being active simultaneously, creating conflicts in terminal raw mode control.
- **Solution:** This issue has been fixed in recent versions. The keyboard input handling has been consolidated to prevent conflicts. If you encounter this issue, ensure you're using the latest version of Qwen Code.
- **DEBUG mode not working from project .env file**
- **Issue:** Setting `DEBUG=true` in a project's `.env` file doesn't enable debug mode for the CLI.
- **Cause:** The `DEBUG` and `DEBUG_MODE` variables are automatically excluded from project `.env` files to prevent interference with the CLI behavior.

View File

@@ -35,7 +35,7 @@ describe('docsCommand', () => {
throw new Error('docsCommand must have an action.');
}
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
await docsCommand.action(mockContext, '');
@@ -57,7 +57,7 @@ describe('docsCommand', () => {
// Simulate a sandbox environment
process.env.SANDBOX = 'gemini-sandbox';
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
await docsCommand.action(mockContext, '');
@@ -80,7 +80,7 @@ describe('docsCommand', () => {
// Simulate the specific 'sandbox-exec' environment
process.env.SANDBOX = 'sandbox-exec';
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
await docsCommand.action(mockContext, '');

View File

@@ -18,7 +18,7 @@ export const docsCommand: SlashCommand = {
description: 'open full Qwen Code documentation in your browser',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en';
const docsUrl = 'https://github.com/QwenLM/qwen-code';
if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') {
context.ui.addItem(

View File

@@ -117,7 +117,7 @@ describe('memoryCommand', () => {
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
content: 'Usage: /memory add [--global|--project] <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
@@ -132,7 +132,7 @@ describe('memoryCommand', () => {
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${fact}"`,
text: `Attempting to save to memory : "${fact}"`,
},
expect.any(Number),
);
@@ -143,6 +143,61 @@ describe('memoryCommand', () => {
toolArgs: { fact },
});
});
it('should handle --global flag and add scope to tool args', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this globally';
const result = addCommand.action(mockContext, `--global ${fact}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory (global): "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact, scope: 'global' },
});
});
it('should handle --project flag and add scope to tool args', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this for project';
const result = addCommand.action(mockContext, `--project ${fact}`);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory (project): "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact, scope: 'project' },
});
});
it('should return error if flag is provided but no fact follows', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = addCommand.action(mockContext, '--global ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add [--global|--project] <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
});
describe('/memory refresh', () => {
@@ -173,7 +228,7 @@ describe('memoryCommand', () => {
mockContext = createMockCommandContext({
services: {
config: Promise.resolve(mockConfig),
config: mockConfig,
settings: {
merged: {
memoryDiscoveryMaxDirs: 1000,

View File

@@ -7,7 +7,11 @@
import {
getErrorMessage,
loadServerHierarchicalMemory,
QWEN_DIR,
} from '@qwen-code/qwen-code-core';
import path from 'node:path';
import os from 'os';
import fs from 'fs/promises';
import { MessageType } from '../types.js';
import {
CommandKind,
@@ -41,24 +45,136 @@ export const memoryCommand: SlashCommand = {
Date.now(),
);
},
subCommands: [
{
name: '--project',
description: 'Show project-level memory contents.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
const projectMemoryPath = path.join(process.cwd(), 'QWEN.md');
const memoryContent = await fs.readFile(
projectMemoryPath,
'utf-8',
);
const messageContent =
memoryContent.trim().length > 0
? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---`
: 'Project memory is currently empty.';
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Project memory file not found or is currently empty.',
},
Date.now(),
);
}
},
},
{
name: '--global',
description: 'Show global memory contents.',
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
const globalMemoryPath = path.join(
os.homedir(),
QWEN_DIR,
'QWEN.md',
);
const globalMemoryContent = await fs.readFile(
globalMemoryPath,
'utf-8',
);
const messageContent =
globalMemoryContent.trim().length > 0
? `Global memory content:\n\n---\n${globalMemoryContent}\n---`
: 'Global memory is currently empty.';
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
},
Date.now(),
);
} catch (_error) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Global memory file not found or is currently empty.',
},
Date.now(),
);
}
},
},
],
},
{
name: 'add',
description: 'Add content to the memory.',
description:
'Add content to the memory. Use --global for global memory or --project for project memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
content:
'Usage: /memory add [--global|--project] <text to remember>',
};
}
const trimmedArgs = args.trim();
let scope: 'global' | 'project' | undefined;
let fact: string;
// Check for scope flags
if (trimmedArgs.startsWith('--global ')) {
scope = 'global';
fact = trimmedArgs.substring('--global '.length).trim();
} else if (trimmedArgs.startsWith('--project ')) {
scope = 'project';
fact = trimmedArgs.substring('--project '.length).trim();
} else if (trimmedArgs === '--global' || trimmedArgs === '--project') {
// Flag provided but no text after it
return {
type: 'message',
messageType: 'error',
content:
'Usage: /memory add [--global|--project] <text to remember>',
};
} else {
// No scope specified, will be handled by the tool
fact = trimmedArgs;
}
if (!fact || fact.trim() === '') {
return {
type: 'message',
messageType: 'error',
content:
'Usage: /memory add [--global|--project] <text to remember>',
};
}
const scopeText = scope ? `(${scope})` : '';
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${args.trim()}"`,
text: `Attempting to save to memory ${scopeText}: "${fact}"`,
},
Date.now(),
);
@@ -66,9 +182,67 @@ export const memoryCommand: SlashCommand = {
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim() },
toolArgs: scope ? { fact, scope } : { fact },
};
},
subCommands: [
{
name: '--project',
description: 'Add content to project-level memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --project <text to remember>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to project memory: "${args.trim()}"`,
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim(), scope: 'project' },
};
},
},
{
name: '--global',
description: 'Add content to global memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add --global <text to remember>',
};
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to global memory: "${args.trim()}"`,
},
Date.now(),
);
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim(), scope: 'global' },
};
},
},
],
},
{
name: 'refresh',
@@ -84,7 +258,7 @@ export const memoryCommand: SlashCommand = {
);
try {
const config = await context.services.config;
const config = context.services.config;
if (config) {
const { memoryContent, fileCount } =
await loadServerHierarchicalMemory(

View File

@@ -8,6 +8,7 @@ import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
RadioButtonSelect,
RadioSelectItem,
@@ -30,6 +31,15 @@ export const ShellConfirmationDialog: React.FC<
> = ({ request }) => {
const { commands, onConfirm } = request;
useKeypress(
(key) => {
if (key.name === 'escape') {
onConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: true },
);
const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) {
onConfirm(item);
@@ -40,10 +50,6 @@ export const ShellConfirmationDialog: React.FC<
}
};
const handleEscape = () => {
onConfirm(ToolConfirmationOutcome.Cancel);
};
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
{
label: 'Yes, allow once',
@@ -90,12 +96,7 @@ export const ShellConfirmationDialog: React.FC<
<Text>Do you want to proceed?</Text>
</Box>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
onEscape={handleEscape}
isFocused
/>
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
</Box>
);
};

View File

@@ -20,6 +20,7 @@ import {
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -56,10 +57,17 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
useKeypress(
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: isFocused },
);
const handleEscape = () => handleConfirm(ToolConfirmationOutcome.Cancel);
const handleCancel = () => handleConfirm(ToolConfirmationOutcome.Cancel);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
let question: string;
@@ -275,8 +283,6 @@ export const ToolConfirmationMessage: React.FC<
<RadioButtonSelect
items={options}
onSelect={handleSelect}
onEscape={handleEscape}
onCancel={handleCancel}
isFocused={isFocused}
/>
</Box>

View File

@@ -34,10 +34,6 @@ export interface RadioButtonSelectProps<T> {
onSelect: (value: T) => void;
/** Function called when an item is highlighted. Receives the `value` of the selected item. */
onHighlight?: (value: T) => void;
/** Function called when escape key is pressed. */
onEscape?: () => void;
/** Function called when Ctrl+C is pressed. */
onCancel?: () => void;
/** Whether this select input is currently focused and should respond to input. */
isFocused?: boolean;
/** Whether to show the scroll arrows. */
@@ -59,8 +55,6 @@ export function RadioButtonSelect<T>({
initialIndex = 0,
onSelect,
onHighlight,
onEscape,
onCancel,
isFocused,
showScrollArrows = false,
maxItemsToShow = 10,
@@ -97,18 +91,6 @@ export function RadioButtonSelect<T>({
const { sequence, name } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Handle escape key
if (name === 'escape') {
onEscape?.();
return;
}
// Handle Ctrl+C
if (key.ctrl && name === 'c') {
onCancel?.();
return;
}
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
clearTimeout(numberInputTimer.current);

View File

@@ -1351,9 +1351,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
// Handle text content
if (choice.delta?.content) {
if (typeof choice.delta.content === 'string') {
parts.push({ text: choice.delta.content });
}
parts.push({ text: choice.delta.content });
}
// Handle tool calls - only accumulate during streaming, emit when complete
@@ -1373,36 +1371,10 @@ export class OpenAIContentGenerator implements ContentGenerator {
accumulatedCall.id = toolCall.id;
}
if (toolCall.function?.name) {
// If this is a new function name, reset the arguments
if (accumulatedCall.name !== toolCall.function.name) {
accumulatedCall.arguments = '';
}
accumulatedCall.name = toolCall.function.name;
}
if (toolCall.function?.arguments) {
// Check if we already have a complete JSON object
const currentArgs = accumulatedCall.arguments;
const newArgs = toolCall.function.arguments;
// If current arguments already form a complete JSON and new arguments start a new object,
// this indicates a new tool call with the same name
let shouldReset = false;
if (currentArgs && newArgs.trim().startsWith('{')) {
try {
JSON.parse(currentArgs);
// If we can parse current arguments as complete JSON and new args start with {,
// this is likely a new tool call
shouldReset = true;
} catch {
// Current arguments are not complete JSON, continue accumulating
}
}
if (shouldReset) {
accumulatedCall.arguments = newArgs;
} else {
accumulatedCall.arguments += newArgs;
}
accumulatedCall.arguments += toolCall.function.arguments;
}
}
}
@@ -1590,7 +1562,7 @@ export class OpenAIContentGenerator implements ContentGenerator {
}
}
messageContent = textParts.join('').trimEnd();
messageContent = textParts.join('');
}
const choice: OpenAIChoice = {

View File

@@ -10,7 +10,7 @@ import * as path from 'path';
import * as fs from 'fs/promises';
import * as os from 'os';
import type { ChildProcess } from 'node:child_process';
import { getProjectHash, GEMINI_DIR } from '../utils/paths.js';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
const hoistedMockExec = vi.hoisted(() => vi.fn());
vi.mock('node:child_process', () => ({
@@ -157,7 +157,7 @@ describe('GitService', () => {
let gitConfigPath: string;
beforeEach(() => {
repoDir = path.join(homedir, GEMINI_DIR, 'history', hash);
repoDir = path.join(homedir, QWEN_DIR, 'history', hash);
gitConfigPath = path.join(repoDir, '.gitconfig');
});

View File

@@ -10,7 +10,7 @@ import * as os from 'os';
import { isNodeError } from '../utils/errors.js';
import { exec } from 'node:child_process';
import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git';
import { getProjectHash, GEMINI_DIR } from '../utils/paths.js';
import { getProjectHash, QWEN_DIR } from '../utils/paths.js';
export class GitService {
private projectRoot: string;
@@ -21,7 +21,7 @@ export class GitService {
private getHistoryDir(): string {
const hash = getProjectHash(this.projectRoot);
return path.join(os.homedir(), GEMINI_DIR, 'history', hash);
return path.join(os.homedir(), QWEN_DIR, 'history', hash);
}
async initialize(): Promise<void> {

View File

@@ -522,13 +522,16 @@ describe('MemoryTool', () => {
expect(result).not.toBe(false);
if (result && result.type === 'edit') {
expect(result.title).toBe('Choose Memory Storage Location');
expect(result.fileName).toBe('Memory Storage Options');
expect(result.fileDiff).toContain('Choose where to save this memory');
expect(result.title).toContain('Choose Memory Location');
expect(result.title).toContain('GLOBAL');
expect(result.title).toContain('PROJECT');
expect(result.fileName).toBe('QWEN.md');
expect(result.fileDiff).toContain('Test fact');
expect(result.fileDiff).toContain('Global:');
expect(result.fileDiff).toContain('Project:');
expect(result.originalContent).toBe('');
expect(result.fileDiff).toContain('--- QWEN.md');
expect(result.fileDiff).toContain('+++ QWEN.md');
expect(result.fileDiff).toContain('+- Test fact');
expect(result.originalContent).toContain('scope: global');
expect(result.originalContent).toContain('INSTRUCTIONS:');
}
});
@@ -577,13 +580,16 @@ describe('MemoryTool', () => {
expect(description).toBe(`${expectedPath} (project)`);
});
it('should default to global scope when scope is not specified', () => {
it('should show choice prompt when scope is not specified', () => {
const params = { fact: 'Test fact' };
const invocation = memoryTool.build(params);
const description = invocation.getDescription();
const expectedPath = path.join('~', '.qwen', 'QWEN.md');
expect(description).toBe(`${expectedPath} (global)`);
const globalPath = path.join('~', '.qwen', 'QWEN.md');
const projectPath = path.join(process.cwd(), 'QWEN.md');
expect(description).toBe(
`CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`,
);
});
});
});

View File

@@ -199,7 +199,12 @@ class MemoryToolInvocation extends BaseToolInvocation<
private static readonly allowlist: Set<string> = new Set();
getDescription(): string {
const scope = this.params.scope || 'global';
if (!this.params.scope) {
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `CHOOSE: ${globalPath} (global) OR ${projectPath} (project)`;
}
const scope = this.params.scope;
const memoryFilePath = getMemoryFilePath(scope);
return `${tildeifyPath(memoryFilePath)} (${scope})`;
}
@@ -207,26 +212,54 @@ class MemoryToolInvocation extends BaseToolInvocation<
override async shouldConfirmExecute(
_abortSignal: AbortSignal,
): Promise<ToolEditConfirmationDetails | false> {
// If scope is not specified, prompt the user to choose
// When scope is not specified, show a choice dialog defaulting to global
if (!this.params.scope) {
// Show preview of what would be added to global by default
const defaultScope = 'global';
const currentContent = await readMemoryFileContent(defaultScope);
const newContent = computeNewContent(currentContent, this.params.fact);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
const fileName = path.basename(getMemoryFilePath(defaultScope));
const choiceText = `Choose where to save this memory:
"${this.params.fact}"
Options:
- Global: ${globalPath} (shared across all projects)
- Project: ${projectPath} (current project only)
Preview of changes to be made to GLOBAL memory:
`;
const fileDiff =
choiceText +
Diff.createPatch(
fileName,
currentContent,
newContent,
'Current',
'Proposed (Global)',
DEFAULT_DIFF_OPTIONS,
);
const confirmationDetails: ToolEditConfirmationDetails = {
type: 'edit',
title: `Choose Memory Storage Location`,
fileName: 'Memory Storage Options',
filePath: '',
fileDiff: `Choose where to save this memory:\n\n"${this.params.fact}"\n\nOptions:\n- Global: ${globalPath} (shared across all projects)\n- Project: ${projectPath} (current project only)\n\nPlease specify the scope parameter: "global" or "project"`,
originalContent: '',
newContent: `Memory to save: ${this.params.fact}\n\nScope options:\n- global: ${globalPath}\n- project: ${projectPath}`,
title: `Choose Memory Location: GLOBAL (${globalPath}) or PROJECT (${projectPath})`,
fileName,
filePath: getMemoryFilePath(defaultScope),
fileDiff,
originalContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${currentContent}`,
newContent: `scope: global\n\n# INSTRUCTIONS:\n# - Click "Yes" to save to GLOBAL memory: ${globalPath}\n# - Click "Modify with external editor" and change "global" to "project" to save to PROJECT memory: ${projectPath}\n\n${newContent}`,
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// This will be handled by the execution flow
// Will be handled in createUpdatedParams
},
};
return confirmationDetails;
}
// Only check allowlist when scope is specified
const scope = this.params.scope;
const memoryFilePath = getMemoryFilePath(scope);
const allowlistKey = `${memoryFilePath}_${scope}`;
@@ -279,17 +312,25 @@ class MemoryToolInvocation extends BaseToolInvocation<
};
}
// If scope is not specified, prompt the user to choose
if (!this.params.scope) {
const errorMessage =
'Please specify where to save this memory. Use scope parameter: "global" for user-level (~/.qwen/QWEN.md) or "project" for current project (./QWEN.md).';
// If scope is not specified and user didn't modify content, return error prompting for choice
if (!this.params.scope && !modified_by_user) {
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
const errorMessage = `Please specify where to save this memory:
Global: ${globalPath} (shared across all projects)
Project: ${projectPath} (current project only)`;
return {
llmContent: JSON.stringify({ success: false, error: errorMessage }),
returnDisplay: `${errorMessage}\n\nGlobal: ${tildeifyPath(getMemoryFilePath('global'))}\nProject: ${tildeifyPath(getMemoryFilePath('project'))}`,
llmContent: JSON.stringify({
success: false,
error: 'Please specify where to save this memory',
}),
returnDisplay: errorMessage,
};
}
const scope = this.params.scope;
const scope = this.params.scope || 'global';
const memoryFilePath = getMemoryFilePath(scope);
try {
@@ -447,24 +488,88 @@ export class MemoryTool
getModifyContext(_abortSignal: AbortSignal): ModifyContext<SaveMemoryParams> {
return {
getFilePath: (params: SaveMemoryParams) =>
getMemoryFilePath(params.scope || 'global'),
getCurrentContent: async (params: SaveMemoryParams): Promise<string> =>
readMemoryFileContent(params.scope || 'global'),
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
getFilePath: (params: SaveMemoryParams) => {
// Determine scope from modified content or default
let scope = params.scope || 'global';
if (params.modified_content) {
const scopeMatch = params.modified_content.match(
/^scope:\s*(global|project)\s*\n/i,
);
if (scopeMatch) {
scope = scopeMatch[1].toLowerCase() as 'global' | 'project';
}
}
return getMemoryFilePath(scope);
},
getCurrentContent: async (params: SaveMemoryParams): Promise<string> => {
// Check if content starts with scope directive
if (params.modified_content) {
const scopeMatch = params.modified_content.match(
/^scope:\s*(global|project)\s*\n/i,
);
if (scopeMatch) {
const scope = scopeMatch[1].toLowerCase() as 'global' | 'project';
const content = await readMemoryFileContent(scope);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`;
}
}
const scope = params.scope || 'global';
const content = await readMemoryFileContent(scope);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${content}`;
},
getProposedContent: async (params: SaveMemoryParams): Promise<string> => {
let scope = params.scope || 'global';
// Check if modified content has scope directive
if (params.modified_content) {
const scopeMatch = params.modified_content.match(
/^scope:\s*(global|project)\s*\n/i,
);
if (scopeMatch) {
scope = scopeMatch[1].toLowerCase() as 'global' | 'project';
}
}
const currentContent = await readMemoryFileContent(scope);
return computeNewContent(currentContent, params.fact);
const newContent = computeNewContent(currentContent, params.fact);
const globalPath = tildeifyPath(getMemoryFilePath('global'));
const projectPath = tildeifyPath(getMemoryFilePath('project'));
return `scope: ${scope}\n\n# INSTRUCTIONS:\n# - Save as "global" for GLOBAL memory: ${globalPath}\n# - Save as "project" for PROJECT memory: ${projectPath}\n\n${newContent}`;
},
createUpdatedParams: (
_oldContent: string,
modifiedProposedContent: string,
originalParams: SaveMemoryParams,
): SaveMemoryParams => ({
...originalParams,
modified_by_user: true,
modified_content: modifiedProposedContent,
}),
): SaveMemoryParams => {
// Parse user's scope choice from modified content
const scopeMatch = modifiedProposedContent.match(
/^scope:\s*(global|project)/i,
);
const scope = scopeMatch
? (scopeMatch[1].toLowerCase() as 'global' | 'project')
: 'global';
// Strip out the scope directive and instruction lines, keep only the actual memory content
const contentWithoutScope = modifiedProposedContent.replace(
/^scope:\s*(global|project)\s*\n/,
'',
);
const actualContent = contentWithoutScope
.replace(/^#[^\n]*\n/gm, '')
.replace(/^\s*\n/gm, '')
.trim();
return {
...originalParams,
scope,
modified_by_user: true,
modified_content: actualContent,
};
},
};
}
}

View File

@@ -8,7 +8,7 @@ import path from 'node:path';
import os from 'os';
import * as crypto from 'crypto';
export const GEMINI_DIR = '.qwen';
export const QWEN_DIR = '.qwen';
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
const TMP_DIR_NAME = 'tmp';
const COMMANDS_DIR_NAME = 'commands';
@@ -181,7 +181,7 @@ export function getProjectHash(projectRoot: string): string {
*/
export function getProjectTempDir(projectRoot: string): string {
const hash = getProjectHash(projectRoot);
return path.join(os.homedir(), GEMINI_DIR, TMP_DIR_NAME, hash);
return path.join(os.homedir(), QWEN_DIR, TMP_DIR_NAME, hash);
}
/**
@@ -189,7 +189,7 @@ export function getProjectTempDir(projectRoot: string): string {
* @returns The path to the user's commands directory.
*/
export function getUserCommandsDir(): string {
return path.join(os.homedir(), GEMINI_DIR, COMMANDS_DIR_NAME);
return path.join(os.homedir(), QWEN_DIR, COMMANDS_DIR_NAME);
}
/**
@@ -198,5 +198,5 @@ export function getUserCommandsDir(): string {
* @returns The path to the project's commands directory.
*/
export function getProjectCommandsDir(projectRoot: string): string {
return path.join(projectRoot, GEMINI_DIR, COMMANDS_DIR_NAME);
return path.join(projectRoot, QWEN_DIR, COMMANDS_DIR_NAME);
}

View File

@@ -7,7 +7,7 @@
import path from 'node:path';
import { promises as fsp, existsSync, readFileSync } from 'node:fs';
import * as os from 'os';
import { GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js';
import { QWEN_DIR, GOOGLE_ACCOUNTS_FILENAME } from './paths.js';
interface UserAccounts {
active: string | null;
@@ -15,7 +15,7 @@ interface UserAccounts {
}
function getGoogleAccountsCachePath(): string {
return path.join(os.homedir(), GEMINI_DIR, GOOGLE_ACCOUNTS_FILENAME);
return path.join(os.homedir(), QWEN_DIR, GOOGLE_ACCOUNTS_FILENAME);
}
async function readAccounts(filePath: string): Promise<UserAccounts> {

View File

@@ -8,10 +8,10 @@ import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { randomUUID } from 'crypto';
import { GEMINI_DIR } from './paths.js';
import { QWEN_DIR } from './paths.js';
const homeDir = os.homedir() ?? '';
const geminiDir = path.join(homeDir, GEMINI_DIR);
const geminiDir = path.join(homeDir, QWEN_DIR);
const installationIdFile = path.join(geminiDir, 'installation_id');
function ensureGeminiDirExists() {