v2.2.15: Astra Office Refactor & Multi-Service Integration

This commit is contained in:
g1nation
2026-05-16 22:07:06 +09:00
parent 9dcc98ad33
commit 9ca95ab997
46 changed files with 5648 additions and 1299 deletions
+18
View File
@@ -0,0 +1,18 @@
// Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema
// 도 같은 entry 로 노출 예정.
//
// 현재 노출: full webview panel HTML 생성 함수. sidebarProvider.ts 는 이 한 줄만 import.
export { renderAstraOfficePanelHtml } from './view/panelHtml';
export type { AstraOfficePanelAssets } from './view/panelHtml';
export type {
OfficeSnapshot,
OfficeAgentSnapshot,
OfficePhase,
OfficeActivityItem,
OfficeBubbleSeed,
} from './schema';
export { validateOfficeSnapshot, makeEmptyOfficeSnapshot } from './schema';
export { presentOfficeSnapshot } from './presenter';
export type { LayoutV2, OfficeDeskCell, OfficeProp } from './view/layoutSchema';
export { validateLayout, migrateLayout } from './view/layoutSchema';
+181
View File
@@ -0,0 +1,181 @@
/**
* Presenter — 옛 AgentWorkState + bubble queue + activity items 를 OfficeSnapshot 으로
* 변환하는 *pure* 함수. mini / full view 둘 다 같은 OfficeSnapshot 을 받게 만드는 게 목표.
*
* 이번 세션의 범위: 인터페이스 + 스텁. 실제 wiring 은 다음 세션에서:
* - sidebarProvider 의 `_pixelOfficeBroadcast` 가 옛 message 대신 OfficeSnapshot 송신
* - mini view (media/sidebar.js) 와 full view (features/astraOffice/view/runtime.ts)
* 둘 다 `officeSnapshot` message 를 받아 자기 식대로 렌더
*
* 이번 세션에 옛 message 와 OfficeSnapshot 을 *동시에* 보낼 수도 있게 — 호환 모드. 다음
* 세션에서 옛 message 제거.
*/
import type { AgentWorkState, AgentBubble } from '../company/pixelOfficeState';
import type { CompanyState } from '../company/types';
import {
type OfficeSnapshot,
type OfficeAgentSnapshot,
type OfficePhase,
type OfficeActivityItem,
type OfficeBubbleSeed,
makeEmptyOfficeSnapshot,
} from './schema';
/** mini/full view 가 받는 message envelope. */
export interface OfficeSnapshotMessage {
type: 'officeSnapshot';
value: OfficeSnapshot;
}
/** 옛 AgentWorkState.status → OfficePhase 매핑. */
const STATUS_TO_PHASE: Record<string, OfficePhase> = {
idle: 'idle',
intake: 'intake',
analyzing: 'planning',
need_clarification: 'awaiting-approval',
contract_ready: 'planning',
planning: 'planning',
executing: 'executing',
reviewing: 'reviewing',
waiting_approval: 'awaiting-approval',
error: 'error',
done: 'done',
};
/** agentId 가 alias 면 정식 id 로 정규화. dispatcher / agents.ts 와 같은 규칙. */
const AGENT_ALIASES: Record<string, string> = {
writer: 'writer',
editor: 'designer',
secretary: 'support',
business: 'inspector',
};
export function normalizeAgentId(rawAgentId: string | undefined): string | null {
if (!rawAgentId) return null;
const lower = rawAgentId.toLowerCase();
return AGENT_ALIASES[lower] ?? lower;
}
/**
* 옛 입력들을 합쳐 새 OfficeSnapshot 을 만든다. 입력 중 일부가 undefined 라도 안전.
*
* 이번 세션 stub: 옛 AgentWorkState 의 *단일 슬롯* 정보로 roster 1개 짜리 snapshot 만
* 생성. 다음 세션에서 CompanyState 의 active roster 전체로 확장.
*/
export function presentOfficeSnapshot(input: {
activeState?: AgentWorkState;
recentBubbles?: AgentBubble[];
recentActivity?: OfficeActivityItem[];
company?: CompanyState;
/**
* 이 turn 의 active agent roster — `listActiveAgentsByCategory` 결과를 평탄화해서 전달.
* 빈 배열/undefined 면 옛 동작 (active agent 1명만) 으로 fallback.
*/
roster?: Array<{ agentId: string; agentName: string; roleCategory: OfficeAgentSnapshot['roleCategory'] }>;
}): OfficeSnapshot {
const snap = makeEmptyOfficeSnapshot();
const { activeState, recentBubbles, recentActivity, roster: rosterInput } = input;
if (activeState) {
const phase = STATUS_TO_PHASE[activeState.status] ?? 'idle';
snap.phase = phase;
snap.activeAgentId = normalizeAgentId(activeState.agentId);
const activeId = snap.activeAgentId;
// Roster:
// - rosterInput 이 주어지면 (#G) 회사 전체 active agent 사용. active agent 만
// activeState 의 상태로 표시하고 나머지는 idle.
// - 없으면 (옛 caller) 활성 agent 1명만 fallback.
if (rosterInput && rosterInput.length > 0) {
const now = Date.now();
snap.roster = rosterInput.map((r) => {
const isActive = activeId !== null && r.agentId === activeId;
return {
agentId: r.agentId,
agentName: r.agentName,
roleCategory: r.roleCategory,
status: isActive ? activeState.status : 'idle',
currentStep: isActive ? activeState.currentStep : undefined,
lastLog: isActive ? (activeState.recentLogs ?? []).slice(-1)[0] : undefined,
lastActivityAt: isActive ? (activeState.updatedAt ?? now) : 0,
};
});
} else {
const role = _inferRoleCategory(activeState.agentId);
const agent: OfficeAgentSnapshot = {
agentId: snap.activeAgentId ?? activeState.agentId,
agentName: activeState.agentName ?? activeState.agentId,
roleCategory: role,
status: activeState.status,
currentStep: activeState.currentStep,
lastLog: (activeState.recentLogs ?? []).slice(-1)[0],
lastActivityAt: activeState.updatedAt ?? Date.now(),
};
snap.roster = [agent];
}
if (activeState.currentTask) {
snap.task = {
goal: activeState.currentTask,
criteria: activeState.requirementContract?.criteria,
openQuestions: activeState.requirementContract?.openQuestions,
format: activeState.requirementContract?.format,
context: activeState.requirementContract?.context,
};
}
if (activeState.pipelineStages) {
snap.pipeline = {
stages: activeState.pipelineStages.map((s) => ({
label: s.label,
agentId: s.agent,
status: s.status,
})),
index: activeState.pipelineStages.findIndex((s) => s.status === 'active'),
};
}
if (activeState.needUserInput?.length || activeState.awaitingApproval) {
snap.awaiting = {
kind: activeState.awaitingApproval ? 'approval' : 'clarification',
questions: activeState.awaitingApproval
? [activeState.awaitingApproval]
: (activeState.needUserInput ?? []),
};
}
}
if (recentActivity?.length) snap.activity = recentActivity.slice(-32);
if (recentBubbles?.length) {
snap.newBubbles = recentBubbles
.map((b) => _toBubbleSeed(b))
.filter((b): b is OfficeBubbleSeed => b !== null);
}
snap.updatedAt = Date.now();
return snap;
}
// ── helpers ──
function _inferRoleCategory(rawAgentId: string | undefined): OfficeAgentSnapshot['roleCategory'] {
if (!rawAgentId) return 'support';
const id = rawAgentId.toLowerCase();
if (id.includes('ceo')) return 'ceo';
if (id.includes('plan')) return 'planner';
if (id.includes('research')) return 'researcher';
if (id.includes('design')) return 'designer';
if (id.includes('writer')) return 'writer';
if (id.includes('editor')) return 'designer';
if (id.includes('developer') || id.includes('dev')) return 'developer';
if (id.includes('qa')) return 'qa';
if (id.includes('inspect') || id.includes('business')) return 'inspector';
return 'support';
}
function _toBubbleSeed(b: AgentBubble): OfficeBubbleSeed | null {
if (!b || !b.text) return null;
return {
agentId: normalizeAgentId(b.agentId) ?? b.agentId,
text: b.text,
type: (b.type as OfficeBubbleSeed['type']) ?? 'status',
};
}
+305
View File
@@ -0,0 +1,305 @@
/**
* OfficeSnapshot — Astra Office 의 도메인 타입.
*
* 동시성 진실 (docs/ASTRA_OFFICE_REFACTOR.md §1): dispatcher 는 직렬이라 한 시점에
* active agent 는 0 또는 1명. 이걸 데이터로 강제하는 게 이 타입의 핵심 역할.
*
* 이 세션에서는 *타입 + validator + empty factory* 만. 백엔드 emit 측 wiring 은
* 다음 세션에서 단계적으로 옮긴다. 현재는 옛 AgentWorkState/CompanyTurnEvent 가
* presenter 입력으로 살아있고, 출력만 OfficeSnapshot.
*/
import type { AgentStatus } from '../company/pixelOfficeState';
export type OfficePhase =
| 'idle'
| 'intake'
| 'planning'
| 'executing'
| 'reviewing'
| 'awaiting-approval'
| 'reporting'
| 'done'
| 'error';
/** 한 명의 agent 가 한 시점에 가진 상태. */
export interface OfficeAgentSnapshot {
/** company roster 의 정식 id (built-in 또는 custom). */
agentId: string;
/** 표시 이름. */
agentName: string;
/** 책상 색깔 / 매핑에 쓰이는 role category. */
roleCategory:
| 'ceo'
| 'planner'
| 'researcher'
| 'designer'
| 'developer'
| 'qa'
| 'inspector'
| 'support'
| 'writer';
status: AgentStatus;
/** "검수 라운드 2/3", "타입 누락" 같은 짧은 부가 정보. */
currentStep?: string;
/** 머리 위 말풍선 source. presenter 가 비워서 보내면 webview 는 풍선 안 띄움. */
lastLog?: string;
/** 정렬 / 신선도 판정. */
lastActivityAt: number;
}
export interface OfficeActivityItem {
/** epoch ms. */
ts: number;
agentId: string;
text: string;
kind?: 'ok' | 'warn' | 'err' | 'info';
}
/** webview 가 풍선을 띄울 때 참고할 hint. presenter 가 매 update 마다 새 풍선 후보를 만들어 보냄. */
export interface OfficeBubbleSeed {
agentId: string;
text: string;
/** 색깔/스타일. presenter 가 status/event/warning/error/success 중 분류. */
type: 'status' | 'event' | 'warning' | 'error' | 'success';
}
export interface OfficeSnapshot {
/** 회사 전체 phase. activeAgent 가 어떤 단계에 있는지와 분리해서 추적. */
phase: OfficePhase;
/** 활성 agent id — 직렬 dispatch 가정. null 이면 idle. */
activeAgentId: string | null;
/** 이 turn 에 참여 가능한 모든 agent. webview 의 책상 그리드 source. */
roster: OfficeAgentSnapshot[];
/** 현재 요구사항 / 계약 요약. */
task?: {
goal: string;
context?: string;
format?: string;
criteria?: string[];
openQuestions?: string[];
};
/** 파이프라인 모드의 stage 진행도. */
pipeline?: {
stages: Array<{
label: string;
agentId?: string;
status: 'done' | 'active' | 'pending';
}>;
index: number;
};
/** 승인 / 추가 정보 대기. */
awaiting?: {
kind: 'approval' | 'clarification';
questions: string[];
};
/** 누적 활동 ring buffer — webview ticker 의 source. */
activity: OfficeActivityItem[];
/** 이 update 사이클에서 새로 생긴 풍선 후보. presenter 가 매 emit 마다 갱신. */
newBubbles: OfficeBubbleSeed[];
/** 디버깅/정렬용. */
updatedAt: number;
}
const VALID_PHASES: ReadonlySet<OfficePhase> = new Set<OfficePhase>([
'idle',
'intake',
'planning',
'executing',
'reviewing',
'awaiting-approval',
'reporting',
'done',
'error',
]);
const VALID_ROLES: ReadonlySet<OfficeAgentSnapshot['roleCategory']> = new Set([
'ceo',
'planner',
'researcher',
'designer',
'developer',
'qa',
'inspector',
'support',
'writer',
]);
const VALID_STATUSES: ReadonlySet<AgentStatus> = new Set<AgentStatus>([
'idle',
'intake',
'analyzing',
'need_clarification',
'contract_ready',
'planning',
'executing',
'reviewing',
'waiting_approval',
'error',
'done',
]);
const VALID_BUBBLE_TYPES = new Set<OfficeBubbleSeed['type']>([
'status',
'event',
'warning',
'error',
'success',
]);
const VALID_KINDS = new Set<NonNullable<OfficeActivityItem['kind']>>([
'ok',
'warn',
'err',
'info',
]);
/**
* 런타임 schema validation. 임의의 unknown 입력을 받아 *완전한 OfficeSnapshot* 또는
* null 을 반환한다. 누락된 필드는 안전한 기본값으로 채운다. 잘못된 enum 값은 'idle'/
* 기본 role 로 fall through. 호출자는 null 이면 default snapshot 으로 폴백 권장.
*/
export function validateOfficeSnapshot(raw: unknown): OfficeSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
const phaseRaw = String(r.phase ?? 'idle');
const phase: OfficePhase = (VALID_PHASES as ReadonlySet<string>).has(phaseRaw)
? (phaseRaw as OfficePhase)
: 'idle';
const activeAgentId =
typeof r.activeAgentId === 'string' && r.activeAgentId.length > 0
? r.activeAgentId
: null;
const rosterRaw = Array.isArray(r.roster) ? r.roster : [];
const roster: OfficeAgentSnapshot[] = rosterRaw
.map((a) => _validateAgent(a))
.filter((a): a is OfficeAgentSnapshot => a !== null);
const taskRaw = r.task as Record<string, unknown> | undefined;
const task = taskRaw && typeof taskRaw === 'object' && typeof taskRaw.goal === 'string'
? {
goal: taskRaw.goal,
context: _optStr(taskRaw.context),
format: _optStr(taskRaw.format),
criteria: Array.isArray(taskRaw.criteria)
? taskRaw.criteria.filter((x): x is string => typeof x === 'string')
: undefined,
openQuestions: Array.isArray(taskRaw.openQuestions)
? taskRaw.openQuestions.filter((x): x is string => typeof x === 'string')
: undefined,
}
: undefined;
const pipelineRaw = r.pipeline as Record<string, unknown> | undefined;
const pipeline = pipelineRaw && Array.isArray(pipelineRaw.stages)
? {
stages: pipelineRaw.stages
.map((s) => _validateStage(s))
.filter((s): s is { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } => s !== null),
index: typeof pipelineRaw.index === 'number' ? pipelineRaw.index : 0,
}
: undefined;
const awaitingRaw = r.awaiting as Record<string, unknown> | undefined;
const awaiting = awaitingRaw && (awaitingRaw.kind === 'approval' || awaitingRaw.kind === 'clarification')
? {
kind: awaitingRaw.kind as 'approval' | 'clarification',
questions: Array.isArray(awaitingRaw.questions)
? awaitingRaw.questions.filter((x): x is string => typeof x === 'string')
: [],
}
: undefined;
const activity: OfficeActivityItem[] = Array.isArray(r.activity)
? r.activity
.map((it) => _validateActivity(it))
.filter((it): it is OfficeActivityItem => it !== null)
: [];
const newBubbles: OfficeBubbleSeed[] = Array.isArray(r.newBubbles)
? r.newBubbles
.map((b) => _validateBubble(b))
.filter((b): b is OfficeBubbleSeed => b !== null)
: [];
const updatedAt = typeof r.updatedAt === 'number' ? r.updatedAt : Date.now();
return { phase, activeAgentId, roster, task, pipeline, awaiting, activity, newBubbles, updatedAt };
}
export function makeEmptyOfficeSnapshot(): OfficeSnapshot {
return {
phase: 'idle',
activeAgentId: null,
roster: [],
activity: [],
newBubbles: [],
updatedAt: Date.now(),
};
}
// ─────────── helpers ───────────
function _optStr(v: unknown): string | undefined {
return typeof v === 'string' ? v : undefined;
}
function _validateAgent(raw: unknown): OfficeAgentSnapshot | null {
if (!raw || typeof raw !== 'object') return null;
const a = raw as Record<string, unknown>;
if (typeof a.agentId !== 'string' || !a.agentId) return null;
const role = typeof a.roleCategory === 'string' && (VALID_ROLES as ReadonlySet<string>).has(a.roleCategory)
? (a.roleCategory as OfficeAgentSnapshot['roleCategory'])
: 'support';
const status: AgentStatus = typeof a.status === 'string' && (VALID_STATUSES as ReadonlySet<string>).has(a.status)
? (a.status as AgentStatus)
: 'idle';
return {
agentId: a.agentId,
agentName: typeof a.agentName === 'string' ? a.agentName : a.agentId,
roleCategory: role,
status,
currentStep: _optStr(a.currentStep),
lastLog: _optStr(a.lastLog),
lastActivityAt: typeof a.lastActivityAt === 'number' ? a.lastActivityAt : 0,
};
}
function _validateStage(raw: unknown): { label: string; agentId?: string; status: 'done' | 'active' | 'pending' } | null {
if (!raw || typeof raw !== 'object') return null;
const s = raw as Record<string, unknown>;
if (typeof s.label !== 'string') return null;
const statusRaw = String(s.status ?? 'pending');
const status = (statusRaw === 'done' || statusRaw === 'active' || statusRaw === 'pending') ? statusRaw : 'pending';
return { label: s.label, agentId: _optStr(s.agentId), status };
}
function _validateActivity(raw: unknown): OfficeActivityItem | null {
if (!raw || typeof raw !== 'object') return null;
const a = raw as Record<string, unknown>;
if (typeof a.text !== 'string' || typeof a.agentId !== 'string') return null;
const kind = typeof a.kind === 'string' && (VALID_KINDS as ReadonlySet<string>).has(a.kind)
? (a.kind as OfficeActivityItem['kind'])
: undefined;
return {
ts: typeof a.ts === 'number' ? a.ts : Date.now(),
agentId: a.agentId,
text: a.text,
kind,
};
}
function _validateBubble(raw: unknown): OfficeBubbleSeed | null {
if (!raw || typeof raw !== 'object') return null;
const b = raw as Record<string, unknown>;
if (typeof b.agentId !== 'string' || typeof b.text !== 'string') return null;
const type = typeof b.type === 'string' && (VALID_BUBBLE_TYPES as ReadonlySet<string>).has(b.type)
? (b.type as OfficeBubbleSeed['type'])
: 'status';
return { agentId: b.agentId, text: b.text, type };
}
@@ -0,0 +1,180 @@
/**
* Pixel Office layout 저장 스키마 — workspaceState 의 `g1nation.pixelOfficeLayout` 키
* 에 저장되는 객체의 런타임 validator + v1 → v2 migration.
*
* 옛 runtime.ts 의 `_isV2Snap()` heuristic 을 정식 schema 로 격상. webview 에서 받는
* 즉시 한 번 통과시키면 깨진 데이터 / 옛 데이터 모두 안전하게 처리된다.
*
* 백엔드는 unknown 그대로 저장하지만, *로드 직후* 이 validator 를 적용해 정규화한다.
*/
export interface OfficeDeskCell {
/** 안정적 식별자 — DOM dataset.role 로도 쓰임. */
roleKey: string;
/** 매핑된 agent id. 비어있으면 unmapped. */
agentKey: string;
label: string;
charRow: number; // 0~7
deskSprite: string;
/** 앉은 face. */
face: 'L' | 'R' | 'U' | 'D';
boss: boolean;
/** seat 에서 잠시 일어났다 가는 dock 좌표. */
dock?: [number, number];
/** 랜덤 roam 후보 좌표들. */
roam?: Array<[number, number]>;
deskX: number;
deskY: number;
deskW: number;
deskRot: number;
deskZ: number;
seatX: number;
seatY: number;
charRot: number;
charZ: number;
/** 캐릭터를 지운 빈 책상. */
noChar: boolean;
}
export interface OfficeProp {
id: string;
name: string;
x: number;
y: number;
w?: number;
rot: number;
z: number;
}
export interface LayoutV2 {
schema: 2;
cells: OfficeDeskCell[];
objs: OfficeProp[];
}
const VALID_FACES = new Set<OfficeDeskCell['face']>(['L', 'R', 'U', 'D']);
/**
* raw 가 valid v2 layout 이면 정규화된 LayoutV2 를, 아니면 null.
* v1 (옛 좌표만 있는 포맷) 은 별도 `migrateLayout()` 사용.
*/
export function validateLayout(raw: unknown): LayoutV2 | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.cells)) return null;
const isV2 = r.schema === 2 || r.cells.some(
(c) =>
c && typeof c === 'object' &&
(typeof (c as Record<string, unknown>).deskSprite === 'string'
|| typeof (c as Record<string, unknown>).agentKey === 'string'
|| typeof (c as Record<string, unknown>).charRow === 'number'),
);
if (!isV2) return null;
const cells = r.cells.map((c) => _normalizeCell(c)).filter((c): c is OfficeDeskCell => c !== null);
const objsRaw = Array.isArray(r.objs) ? r.objs : [];
const objs = objsRaw.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null);
return { schema: 2, cells, objs };
}
/**
* v1 (옛 좌표 패치 포맷) → v2 (전체 station 정의) 마이그레이션. v1 은 좌표만 갖고 있어
* default station 의 나머지 필드(charRow, deskSprite 등)를 채워줘야 한다. webview 의
* default station 매핑이 함께 주어져야 정확. 없으면 best-effort.
*
* 이번 세션 stub: v1 입력이 들어오면 v2 shape 으로 일단 변환 (default 필드는 0/빈 값).
* 다음 세션에서 default station 룩업과 결합.
*/
export function migrateLayout(raw: unknown): LayoutV2 | null {
const asV2 = validateLayout(raw);
if (asV2) return asV2;
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (!Array.isArray(r.cells)) return null;
const cells: OfficeDeskCell[] = r.cells
.map((cRaw) => {
if (!cRaw || typeof cRaw !== 'object') return null;
const c = cRaw as Record<string, unknown>;
if (typeof c.roleKey !== 'string') return null;
return _normalizeCell({
...c,
// v1 은 charRow / deskSprite 등이 없으니 안전한 기본값.
charRow: 0,
deskSprite: 'desk-main',
face: 'R',
boss: false,
agentKey: c.roleKey, // 옛 키가 곧 agent 였음.
label: c.roleKey,
noChar: false,
});
})
.filter((c): c is OfficeDeskCell => c !== null);
const objs = Array.isArray(r.objs)
? r.objs.map((o) => _normalizeProp(o)).filter((o): o is OfficeProp => o !== null)
: [];
return { schema: 2, cells, objs };
}
function _normalizeCell(raw: unknown): OfficeDeskCell | null {
if (!raw || typeof raw !== 'object') return null;
const c = raw as Record<string, unknown>;
if (typeof c.roleKey !== 'string' || !c.roleKey) return null;
const face = typeof c.face === 'string' && (VALID_FACES as ReadonlySet<string>).has(c.face)
? (c.face as OfficeDeskCell['face'])
: 'R';
return {
roleKey: c.roleKey,
agentKey: typeof c.agentKey === 'string' ? c.agentKey : '',
label: typeof c.label === 'string' ? c.label : c.roleKey,
charRow: _num(c.charRow, 0),
deskSprite: typeof c.deskSprite === 'string' ? c.deskSprite : 'desk-main',
face,
boss: !!c.boss,
dock: _pair(c.dock),
roam: Array.isArray(c.roam)
? (c.roam.map(_pair).filter(Boolean) as Array<[number, number]>)
: undefined,
deskX: _num(c.deskX, 0),
deskY: _num(c.deskY, 0),
deskW: _num(c.deskW, 112),
deskRot: _num(c.deskRot, 0),
deskZ: _num(c.deskZ, 0),
seatX: _num(c.seatX, 0),
seatY: _num(c.seatY, 0),
charRot: _num(c.charRot, 0),
charZ: _num(c.charZ, 0),
noChar: !!c.noChar,
};
}
function _normalizeProp(raw: unknown): OfficeProp | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (typeof o.name !== 'string') return null;
return {
id: typeof o.id === 'string' ? o.id : `obj_${Math.random().toString(36).slice(2, 8)}`,
name: o.name,
x: _num(o.x, 0),
y: _num(o.y, 0),
w: typeof o.w === 'number' ? o.w : undefined,
rot: _num(o.rot, 0),
z: _num(o.z, 0),
};
}
function _num(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback;
}
function _pair(v: unknown): [number, number] | undefined {
if (!Array.isArray(v) || v.length !== 2) return undefined;
const a = typeof v[0] === 'number' ? v[0] : NaN;
const b = typeof v[1] === 'number' ? v[1] : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return undefined;
return [a, b];
}
@@ -0,0 +1,21 @@
// 자동 분리: src/sidebarProvider.ts 3984-4001 에서 추출. 동작 동등.
export const OFFICE_BODY = `
<body>
<header><div><div class="h-title">🏢 ASTRA OFFICE</div><div class="h-sub" id="agent">Astra</div></div><div style="display:flex;gap:8px;align-items:center;"><button id="editBtn" class="edit-btn" title="배치 편집 모드 토글">✏️ 편집</button><div class="status" id="status">idle</div></div></header>
<div id="miniMap" class="mini-map" style="display:none;"></div>
<div id="editToolbar" class="edit-toolbar" style="display:none;">
<span class="et-hint">드래그로 이동 · <b>R</b> 회전 · <b>]</b>/<b>[</b> 레이어 · 4px snap</span>
<button id="addDeskBtn" class="add" title="책상 추가">+ 책상</button>
<button id="addPropBtn" class="add" title="프랍(소품) 추가">+ 프랍</button>
<button id="deleteSelBtn" class="del" title="선택 항목 삭제" disabled>🗑 삭제</button>
<button id="layerUpBtn" title="레이어 위로 (])">⬆</button>
<button id="layerDownBtn" title="레이어 아래로 ([)">⬇</button>
<button id="saveBtn">💾 저장</button>
<button id="resetBtn" title="기본 배치로 복귀">↻ 디폴트</button>
<button id="cancelBtn" title="저장 안 하고 종료">✕ 취소</button>
</div>
<div class="strip"><span><b>작업</b> <span id="task">—</span></span><span><b>단계</b> <span id="step">—</span></span></div>
<main class="office"><div class="stage" id="stage"><div class="wall-window w1"></div><div class="wall-window w2"></div></div><div id="propPanel" class="prop-panel"></div></main>
<div id="ticker" class="ticker" style="display:none;"><div class="tk-track" id="tickerTrack"></div></div>
<footer><div class="progress"><div class="bar" id="bar"></div></div><div id="log">—</div></footer>
`;
@@ -0,0 +1,121 @@
// 자동 분리: src/sidebarProvider.ts 3866-3982 에서 추출. 동작 동등.
// design doc: docs/ASTRA_OFFICE_REFACTOR.md
export const OFFICE_CSS = `
<style>
:root{--bg:#0E1019;--wall:#202536;--floor:#302634;--floor2:#281F2C;--text:#F1F4FB;--muted:#A8B0C7;--accent:#7C83FF;}
*{box-sizing:border-box} body{margin:0;height:100vh;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;flex-direction:column;overflow:hidden}
header{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;background:rgba(0,0,0,.22);border-bottom:1px solid rgba(255,255,255,.08)}
.h-title{font-weight:800}.h-sub{font-size:11px;color:var(--muted)}.status{font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.18);border-radius:999px}
.strip{display:flex;gap:16px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid rgba(255,255,255,.06)}.strip b{color:var(--text)}
.office{position:relative;flex:1;overflow:hidden;display:flex;align-items:center;justify-content:center;background:linear-gradient(180deg,#21283a 0 16%,transparent 16%),radial-gradient(ellipse at 50% 0%,rgba(124,131,255,.12),transparent 42%),linear-gradient(135deg,#322835,#271f2a)}
.office:before{content:'';position:absolute;left:0;right:0;top:16%;bottom:0;background-image:linear-gradient(rgba(255,255,255,.028) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.028) 1px,transparent 1px);background-size:48px 48px}
.office:after{content:'';position:absolute;left:0;right:0;top:15.5%;height:8px;background:linear-gradient(180deg,rgba(0,0,0,.36),transparent)}
.stage{position:relative;width:720px;height:585px;margin:0}
.wall-window{position:absolute;top:16px;width:86px;height:42px;border:3px solid rgba(206,223,255,.35);background:linear-gradient(180deg,rgba(160,208,255,.3),rgba(110,150,210,.1));box-shadow:inset 0 0 0 2px rgba(15,20,31,.55)}
.wall-window.w1{left:84px}.wall-window.w2{left:550px}
.obj,.desk,.char{position:absolute;image-rendering:pixelated}
.obj{filter:drop-shadow(3px 4px 0 rgba(0,0,0,.28));z-index:4}
.desk{width:112px;z-index:5;filter:drop-shadow(4px 5px 0 rgba(0,0,0,.32))}.desk.boss{width:136px}.label{position:absolute;left:50%;bottom:-10px;transform:translateX(-50%);font-size:10px;color:rgba(241,244,251,.78);white-space:nowrap;text-shadow:1px 1px #000}
.char{width:56px;height:72px;z-index:7;transition:left 1.17s cubic-bezier(.2,.7,.2,1),top 1.17s cubic-bezier(.2,.7,.2,1)}.char.walking{z-index:14}.char img{position:absolute;left:0;bottom:0;max-width:100%;max-height:100%;image-rendering:pixelated;filter:drop-shadow(2px 2px 0 rgba(0,0,0,.45));transform-origin:center bottom}
.char.active:before{content:'';position:absolute;left:24px;top:-10px;width:8px;height:8px;background:var(--role-color,var(--accent));box-shadow:0 0 12px var(--role-color,var(--accent));animation:po-pulse 1.6s ease-in-out infinite}
@keyframes po-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:.6}}
/* ── C. 직군별 페르소나 컬러 ── 책상 outline 가벼운 강조, 활성 캐릭터 위 점이 직군색.
data-role attribute로 자동 매핑. 사용자가 PNG sprite로 swap해도 컬러는 유지. */
.char[data-agent="ceo"],.desk[data-agent="ceo"] {--role-color:#A78BFA}
.char[data-agent="planner"],.desk[data-agent="planner"] {--role-color:#60A5FA}
.char[data-agent="researcher"],.desk[data-agent="researcher"] {--role-color:#10B981}
.char[data-agent="designer"],.desk[data-agent="designer"] {--role-color:#F472B6}
.char[data-agent="developer"],.desk[data-agent="developer"] {--role-color:#FBBF24}
.char[data-agent="qa"],.desk[data-agent="qa"] {--role-color:#22D3EE}
.char[data-agent="inspector"],.desk[data-agent="inspector"] {--role-color:#FB923C}
.char[data-agent="support"],.desk[data-agent="support"] {--role-color:#94A3B8}
.char.active::after{content:'';position:absolute;left:0;right:0;bottom:-4px;height:3px;background:var(--role-color,var(--accent));box-shadow:0 0 8px var(--role-color,var(--accent));border-radius:2px;animation:po-glow 1.6s ease-in-out infinite}
@keyframes po-glow{0%,100%{opacity:.7}50%{opacity:1}}
/* desk 는 line 3878 의 .obj,.desk,.char{position:absolute} 를 그대로 유지해야 한다.
과거 .desk{position:relative} 가 cascade로 override 되어, 새로 추가한 책상이 normal-flow Y
에 따라 stage 바깥으로 밀려나던 버그가 있었음. ::after pseudo 는 absolute parent 기준으로도 정상 동작. */
.desk::after{content:'';position:absolute;inset:-2px;border-radius:4px;border:2px solid transparent;pointer-events:none;transition:border-color .3s}
.stage:has(.char.active[data-agent="ceo"]) .desk[data-agent="ceo"]::after,
.stage:has(.char.active[data-agent="planner"]) .desk[data-agent="planner"]::after,
.stage:has(.char.active[data-agent="researcher"]) .desk[data-agent="researcher"]::after,
.stage:has(.char.active[data-agent="designer"]) .desk[data-agent="designer"]::after,
.stage:has(.char.active[data-agent="developer"]) .desk[data-agent="developer"]::after,
.stage:has(.char.active[data-agent="qa"]) .desk[data-agent="qa"]::after,
.stage:has(.char.active[data-agent="inspector"]) .desk[data-agent="inspector"]::after,
.stage:has(.char.active[data-agent="support"]) .desk[data-agent="support"]::after
{border-color:var(--role-color)}
.shadow{position:absolute;left:12px;bottom:0;width:28px;height:7px;background:radial-gradient(ellipse,rgba(0,0,0,.55),transparent 70%)}
.bubble{position:absolute;z-index:20;transform:translate(-50%,-100%);background:#fff;color:#222;padding:5px 8px;border-radius:8px;font-size:11px;box-shadow:2px 2px 0 rgba(0,0,0,.35);white-space:nowrap}
.edit-btn{background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.2);color:#F1F4FB;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px}.edit-btn:hover{background:rgba(99,102,241,.25);border-color:#6366F1}
/* ── B. 워크플로우 미니 맵 ── 헤더 아래 dot strip. 각 dot이 stage 하나. 완료=
채워진 점, 활성=링 펄스, 대기=빈 점. 호버 시 라벨 표시. */
.mini-map{display:flex;gap:5px;align-items:center;padding:7px 16px;background:rgba(0,0,0,.3);border-bottom:1px solid rgba(255,255,255,.06);overflow-x:auto;scrollbar-width:none}.mini-map::-webkit-scrollbar{display:none}
.mini-map .mm-dot{position:relative;width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.12);border:1.5px solid rgba(255,255,255,.18);flex-shrink:0;cursor:default;transition:all .25s}
.mini-map .mm-dot[data-status="done"]{background:#10B981;border-color:#10B981;box-shadow:0 0 4px rgba(16,185,129,.5)}
.mini-map .mm-dot[data-status="active"]{background:var(--accent);border-color:var(--accent);width:14px;height:14px;box-shadow:0 0 0 3px rgba(99,102,241,.3);animation:mm-pulse 1.4s ease-in-out infinite}
@keyframes mm-pulse{0%,100%{box-shadow:0 0 0 3px rgba(99,102,241,.3)}50%{box-shadow:0 0 0 6px rgba(99,102,241,.15)}}
.mini-map .mm-bar{flex:1;height:1px;background:linear-gradient(90deg,rgba(255,255,255,.08),rgba(255,255,255,.16))}
.mini-map .mm-label{position:absolute;left:50%;top:-22px;transform:translateX(-50%);font-size:10px;color:#F1F4FB;background:rgba(0,0,0,.85);padding:2px 6px;border-radius:3px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50}
.mini-map .mm-dot:hover .mm-label{opacity:1}
.mini-map .mm-counter{flex-shrink:0;font-size:10px;color:#94A3B8;margin-left:8px;white-space:nowrap}
/* ── E. Activity Ticker ── action-tag executor 결과를 하단 strip으로 흘림.
사용자가 에이전트의 *실제 행동*(파일 쓰기, 명령 실행)을 실시간으로 보며 신뢰. */
.ticker{position:relative;padding:5px 16px;background:rgba(99,102,241,.08);border-top:1px solid rgba(99,102,241,.18);overflow:hidden;font-size:11px;font-family:ui-monospace,monospace;height:24px}
.tk-track{display:flex;gap:18px;white-space:nowrap;animation:tk-roll 22s linear infinite;will-change:transform}
.ticker:hover .tk-track{animation-play-state:paused}
.tk-item{flex-shrink:0;color:#D7DBEA}
.tk-item.tk-ok{color:#10B981}
.tk-item.tk-warn{color:#F5C518}
.tk-item.tk-err{color:#EF4444}
.tk-item .tk-agent{color:#A78BFA;margin-right:5px;font-weight:600}
@keyframes tk-roll{from{transform:translateX(0)}to{transform:translateX(-50%)}}
/* ── D. 캐릭터 컨텍스트 메뉴 ── 편집 모드 X일 때 캐릭터 클릭하면 작은 메뉴 popup.
현재 turn 제어 + 최근 활동 보기. */
.ctx-menu{position:fixed;z-index:1000;background:#13162A;border:1px solid #2A2E3F;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:4px;min-width:170px;font-size:12px;color:#F1F4FB}
.ctx-menu-head{padding:6px 10px 4px;font-size:10px;color:#94A3B8;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:4px}
.ctx-menu-head .cmh-role{color:var(--role-color,#A78BFA);font-weight:700;text-transform:uppercase}
.ctx-menu-item{display:flex;align-items:center;gap:8px;padding:7px 10px;cursor:pointer;border-radius:4px;transition:background .12s}
.ctx-menu-item:hover{background:rgba(99,102,241,.18)}
.ctx-menu-item.danger:hover{background:rgba(239,68,68,.18);color:#FCA5A5}
.ctx-menu-divider{height:1px;background:rgba(255,255,255,.08);margin:3px 4px}
body[data-edit-mode="true"] .ctx-menu{display:none!important}
body:not([data-edit-mode="true"]) .char{cursor:pointer}
.ctx-detail{position:fixed;z-index:1001;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 12px 36px rgba(0,0,0,.7);padding:16px 18px;color:#F1F4FB;min-width:320px;max-width:520px;max-height:60vh;overflow-y:auto;font-size:12px;line-height:1.5}
.ctx-detail h3{margin:0 0 8px;font-size:14px;color:var(--role-color,#A78BFA);text-transform:uppercase;letter-spacing:.04em}
.ctx-detail .cd-close{position:absolute;top:8px;right:10px;background:transparent;border:none;color:#94A3B8;font-size:16px;cursor:pointer}
.ctx-detail dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:4px 14px}
.ctx-detail dt{color:#94A3B8;font-weight:600;white-space:nowrap}
.ctx-detail dd{margin:0;color:#F1F4FB;overflow-wrap:anywhere}
.ctx-detail .cd-logs{margin-top:10px;padding:6px 8px;background:rgba(0,0,0,.3);border-radius:4px;font-family:ui-monospace,monospace;font-size:10.5px;max-height:120px;overflow-y:auto}
.edit-toolbar{display:flex;gap:8px;align-items:center;padding:6px 16px;background:rgba(99,102,241,.18);border-bottom:1px solid rgba(99,102,241,.4);font-size:11px;flex-wrap:wrap}.edit-toolbar .et-hint{flex:1;color:#D7DBEA;min-width:160px}.edit-toolbar button{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);color:#F1F4FB;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px}.edit-toolbar button:hover{background:rgba(99,102,241,.35)}
.edit-toolbar button.add{background:rgba(16,185,129,.22);border-color:rgba(16,185,129,.55)}.edit-toolbar button.add:hover{background:rgba(16,185,129,.4)}
.edit-toolbar button.del{background:rgba(239,68,68,.22);border-color:rgba(239,68,68,.55)}.edit-toolbar button.del:hover{background:rgba(239,68,68,.4)}.edit-toolbar button[disabled]{opacity:.4;cursor:not-allowed}
/* 선택된 item 의 속성 편집 패널 — 우측 슬라이드 */
.prop-panel{position:absolute;right:12px;top:90px;width:240px;background:#13162A;border:1px solid #2A2E3F;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.6);padding:12px;font-size:11px;color:#F1F4FB;z-index:25;display:none}
.prop-panel.show{display:block}
.prop-panel h4{margin:0 0 8px;font-size:12px;color:#A78BFA;text-transform:uppercase;letter-spacing:.04em}
.prop-panel .pp-row{margin-bottom:8px}
.prop-panel label{display:block;font-size:10px;color:#94A3B8;margin-bottom:2px}
.prop-panel select,.prop-panel input{width:100%;background:#0c1020;color:#F1F4FB;border:1px solid #2A2E3F;border-radius:4px;padding:3px 6px;font-size:11px}
.prop-panel .pp-thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:4px}
.prop-panel .pp-thumb{width:100%;aspect-ratio:1/1;background:#0c1020;border:1px solid #2A2E3F;border-radius:3px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:2px}
.prop-panel .pp-thumb img{max-width:100%;max-height:100%;image-rendering:pixelated}
.prop-panel .pp-thumb.active{border-color:#A78BFA;box-shadow:0 0 0 2px rgba(167,139,250,.35)}
/* 프랍 추가 picker — 모달 grid */
.prop-picker{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1100;display:flex;align-items:center;justify-content:center}
.prop-picker-box{background:#13162A;border:1px solid #2A2E3F;border-radius:10px;padding:14px;max-width:520px;max-height:80vh;overflow-y:auto;color:#F1F4FB}
.prop-picker-box h3{margin:0 0 10px;font-size:13px;color:#A78BFA}
.prop-picker-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
.prop-pick{background:#0c1020;border:1px solid #2A2E3F;border-radius:4px;padding:6px;cursor:pointer;text-align:center}
.prop-pick:hover{border-color:#A78BFA}
.prop-pick img{max-width:60px;max-height:60px;image-rendering:pixelated}
.prop-pick .pp-name{font-size:10px;color:#94A3B8;margin-top:4px;word-break:break-all}
/* 편집 모드 — 드래그 가능 요소 강조 */
body[data-edit-mode="true"] .stage{background-image:linear-gradient(rgba(99,102,241,.15) 1px,transparent 1px),linear-gradient(90deg,rgba(99,102,241,.15) 1px,transparent 1px);background-size:32px 32px}
body[data-edit-mode="true"] .desk,body[data-edit-mode="true"] .char,body[data-edit-mode="true"] .obj{cursor:grab;outline:1px dashed rgba(99,102,241,.5)}
body[data-edit-mode="true"] .desk:hover,body[data-edit-mode="true"] .char:hover,body[data-edit-mode="true"] .obj:hover{outline:2px solid #6366F1;z-index:30}
body[data-edit-mode="true"] .dragging{cursor:grabbing!important;opacity:.7;outline:2px solid #FB923C!important;z-index:40}
body[data-edit-mode="true"] .selected{outline:2px solid #F472B6!important;box-shadow:0 0 0 4px rgba(244,114,182,.25);z-index:35}
body[data-edit-mode="true"] .char .shadow{display:none}
footer{padding:8px 16px 12px;border-top:1px solid rgba(255,255,255,.08);background:rgba(0,0,0,.25);font-size:11px;color:var(--muted)}.progress{height:5px;background:rgba(255,255,255,.08);margin-bottom:6px}.bar{height:100%;width:0;background:var(--accent);transition:width .25s}
`;
@@ -0,0 +1,26 @@
// Full Astra Office webview HTML composition.
// 옛 sidebarProvider.ts 의 거대한 _pixelOfficePanelHtml 을 4개 파일로 분리한 entry.
// 이번 세션은 *동작 동등 분리* 만. 다음 세션에 mini view 와 공통 presenter 도입.
import { OFFICE_CSS } from './officeStyles';
import { OFFICE_BODY } from './officeBody';
import { officeRuntimeJs } from './runtime';
export interface AstraOfficePanelAssets {
cspSource: string;
derivedBase: string;
}
export function renderAstraOfficePanelHtml(assets: AstraOfficePanelAssets): string {
return `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${assets.cspSource} data:; style-src 'unsafe-inline'; script-src 'unsafe-inline';" />
<style>
${OFFICE_CSS}
</style></head>
<body>
${OFFICE_BODY}
${officeRuntimeJs(assets.derivedBase)}</body></html>`;
}
File diff suppressed because it is too large Load Diff
+205
View File
@@ -0,0 +1,205 @@
/**
* Google Calendar API v3 — event create/list 호출.
*
* access token 은 caller 가 직접 주입한다. 만료 처리는 `withFreshAccessToken`
* 헬퍼가 refresh token 으로 갱신 → 호출 → 401 발생 시 한 번 더 갱신 + 재시도.
*
* 외부 라이브러리(googleapis) 안 씀 — Calendar API 는 REST 라 native fetch 면 충분.
*/
import * as vscode from 'vscode';
import { refreshAccessToken } from './oauth';
import { readCalendarConfig, writeCalendarConfig } from './calendarCache';
const API_BASE = 'https://www.googleapis.com/calendar/v3';
export interface CalendarEventInput {
/** 일정 제목 (필수). */
title: string;
/** ISO 시작 시각 — 'YYYY-MM-DDTHH:MM' (로컬) 또는 'YYYY-MM-DDTHH:MM:SS±HH:MM' (timezone 포함). */
start: string;
/** ISO 종료 시각. 없으면 duration(분) 으로부터 계산. duration 도 없으면 60분. */
end?: string;
/** end 없을 때 시작부터 이만큼 (분 단위, default 60). */
durationMinutes?: number;
description?: string;
location?: string;
/** all-day 일정 여부 — true 면 start 는 'YYYY-MM-DD' 만 받음. */
allDay?: boolean;
}
export interface CreatedEvent {
/** Google 이 발급한 event id. */
id: string;
/** Google Calendar 웹에서 열 수 있는 URL. */
htmlLink: string;
/** API 가 echo 해준 시작 시각. */
startIso: string;
title: string;
}
/**
* 일정 생성. config 에 refresh token 이 있어야 함. access token 자동 갱신.
*
* 반환값:
* ok: true → CreatedEvent
* ok: false → 에러 메시지 (UI 표시용)
*/
export async function createCalendarEvent(
context: vscode.ExtensionContext,
input: CalendarEventInput,
): Promise<{ ok: true; event: CreatedEvent } | { ok: false; error: string }> {
const cfg = readCalendarConfig(context);
const tokenResult = await _getFreshAccessToken(context);
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
const body = _buildEventBody(input, cfg.defaultDurationMinutes ?? 60);
if (!body.ok) return { ok: false, error: body.error };
const calId = (cfg.calendarId || 'primary').trim() || 'primary';
const url = `${API_BASE}/calendars/${encodeURIComponent(calId)}/events`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${tokenResult.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body.event),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = json?.error?.message || `HTTP ${res.status}`;
return { ok: false, error: msg };
}
return {
ok: true,
event: {
id: json.id,
htmlLink: json.htmlLink,
startIso: json.start?.dateTime ?? json.start?.date ?? input.start,
title: input.title,
},
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/** Calendar API 요청 body 빌더 — 단위테스트 가능하도록 분리. */
export function _buildEventBody(
input: CalendarEventInput,
fallbackDurationMin: number,
): { ok: true; event: any } | { ok: false; error: string } {
if (!input.title || !input.title.trim()) return { ok: false, error: 'title 비어있음' };
if (!input.start || !input.start.trim()) return { ok: false, error: 'start 비어있음' };
if (input.allDay) {
// all-day: date 형식만 (YYYY-MM-DD). end 는 exclusive 라 다음 날.
const startDate = input.start.slice(0, 10);
const endDate = input.end ? input.end.slice(0, 10) : _addDaysDate(startDate, 1);
return {
ok: true,
event: {
summary: input.title.trim(),
description: input.description || undefined,
location: input.location || undefined,
start: { date: startDate },
end: { date: endDate },
reminders: { useDefault: true },
},
};
}
let endIso: string | undefined = input.end;
if (!endIso) {
const dur = (input.durationMinutes && input.durationMinutes > 0) ? input.durationMinutes : fallbackDurationMin;
const computed = _addMinutesIso(input.start, dur);
if (!computed) return { ok: false, error: `start 시각 형식 오류: ${input.start}` };
endIso = computed;
}
// Google Calendar 는 timezone 정보가 없으면 timeZone 필드 별도 필요.
// 'YYYY-MM-DDTHH:MM' 처럼 timezone 빠진 입력은 OS 로컬 timezone 으로 가정.
const hasOffset = /([+-]\d{2}:\d{2}|Z)$/.test(input.start);
const timeZone = hasOffset ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
return {
ok: true,
event: {
summary: input.title.trim(),
description: input.description || undefined,
location: input.location || undefined,
start: { dateTime: input.start, ...(timeZone ? { timeZone } : {}) },
end: { dateTime: endIso, ...(timeZone ? { timeZone } : {}) },
reminders: {
useDefault: false,
overrides: [
{ method: 'popup', minutes: 5 },
{ method: 'popup', minutes: 60 },
],
},
},
};
}
/** 'YYYY-MM-DDTHH:MM[:SS][±HH:MM|Z]' 에 분 더해 ISO 반환. 잘못된 형식이면 null. */
export function _addMinutesIso(startIso: string, minutes: number): string | null {
// 안전한 파싱: 명시적 정규식 → Date → ISO 재조립. timezone 정보 보존.
const m = startIso.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(:\d{2})?([+-]\d{2}:\d{2}|Z)?$/);
if (!m) return null;
const [, base, sec, tz] = m;
const full = `${base}${sec ?? ':00'}${tz ?? ''}`;
const t = new Date(full);
if (Number.isNaN(t.getTime())) return null;
const out = new Date(t.getTime() + minutes * 60 * 1000);
// 원본이 timezone 정보 없는 로컬 시각이면 같은 포맷으로 돌려준다.
if (!tz) {
const yy = out.getFullYear();
const MM = String(out.getMonth() + 1).padStart(2, '0');
const dd = String(out.getDate()).padStart(2, '0');
const hh = String(out.getHours()).padStart(2, '0');
const mm = String(out.getMinutes()).padStart(2, '0');
const ss = String(out.getSeconds()).padStart(2, '0');
return `${yy}-${MM}-${dd}T${hh}:${mm}:${ss}`;
}
return out.toISOString();
}
/** 'YYYY-MM-DD' + N 일. all-day 일정 end 계산용. */
export function _addDaysDate(yyyymmdd: string, days: number): string {
const m = yyyymmdd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return yyyymmdd;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
d.setDate(d.getDate() + days);
const yy = d.getFullYear(), MM = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
return `${yy}-${MM}-${dd}`;
}
/**
* 현재 access token 이 유효하면 그대로, 아니면 refresh. config 의 만료 시각 사용.
* 갱신 후 만료 시각도 config 에 기록 — 다음 호출 때 불필요한 갱신 방지.
*
* Calendar / Sheets API 양쪽이 같은 token 을 공유한다 (scope 가 모두 같은 OAuth 에 포함).
* 그래서 `_` prefix 떼고 export — Sheets API client 가 직접 호출.
*/
export async function getFreshAccessToken(
context: vscode.ExtensionContext,
): Promise<{ ok: true; accessToken: string } | { ok: false; error: string }> {
const cfg = readCalendarConfig(context);
if (!cfg.clientId || !cfg.clientSecret || !cfg.refreshToken) {
return { ok: false, error: 'Google OAuth 가 설정되지 않았습니다. "Astra: Google Calendar OAuth 연결 (쓰기)" 명령으로 한 번 로그인하세요. (Calendar 와 Sheets 권한이 함께 발급됩니다.)' };
}
const now = Date.now();
if (cfg.accessToken && cfg.accessTokenExpiresAt && cfg.accessTokenExpiresAt > now) {
return { ok: true, accessToken: cfg.accessToken };
}
const r = await refreshAccessToken(cfg.clientId, cfg.clientSecret, cfg.refreshToken);
if (!r.ok) return { ok: false, error: r.error };
await writeCalendarConfig(context, { accessToken: r.accessToken, accessTokenExpiresAt: r.expiresAt });
return { ok: true, accessToken: r.accessToken };
}
// 내부 호출용 alias 유지 — 한 줄짜리라 비용 없음.
const _getFreshAccessToken = getFreshAccessToken;
+170
View File
@@ -0,0 +1,170 @@
/**
* Google Calendar (iCal) 캐시 — fetch + parse + 회사 _shared/calendar_cache.md 에 저장.
*
* Connect_origin 의 google_calendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음.
* 사용자가 Google Calendar 설정 → "비공개 주소(iCal 형식)" 복사 → 본 모듈에 입력 한 번이면
* 모든 agent 가 매 turn 자동으로 다가오는 일정 컨텍스트를 받는다.
*
* 보안:
* - iCal URL 은 ExtensionContext.globalState 에 저장 (machine-local, git 침범 X).
* - 캐시 파일은 회사 디렉토리 `_shared/calendar_cache.md` 에 평문 마크다운으로 저장.
* 이 파일은 .gitignore 대상은 아니지만 일정 제목/시각이 들어있음 — 사용자가 commit
* 안 하도록 가이드 문구를 README/명령에서 안내한다.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
import { parseIcs, selectUpcoming, IcsEvent } from './icsParser';
/** globalState 키 — iCal URL 과 부수 설정 한 묶음. */
export const CAL_CONFIG_KEY = 'g1nation.calendar.ical';
export interface CalendarConfig {
/** Google Calendar 비공개 iCal URL. 빈 문자열이면 iCal 읽기 비활성. */
icalUrl: string;
/** 며칠치 미리 가져올지 (default 14). */
daysAhead: number;
/** 마지막 성공 fetch ISO timestamp (자동 표시용). */
lastFetchAt?: string;
// ── OAuth (쓰기) 관련 필드 — Google Calendar API v3 호출에 사용. ──
// 모두 ExtensionContext.globalState 에만 저장 (machine-local). 옵션이라 비어있어도 iCal 읽기는 동작.
/** Google Cloud Console 에서 발급한 OAuth Client ID. */
clientId?: string;
/** 같은 페이지의 Client Secret (Desktop app 의 secret 은 공개 가능한 식별자). */
clientSecret?: string;
/** OAuth 로 받은 refresh token — 진짜 비밀. machine-local. */
refreshToken?: string;
/** Calendar API 가 쓰는 캘린더 식별자 — 'primary' 또는 특정 calendarId. */
calendarId?: string;
/** end 없는 이벤트 default 길이 (분). */
defaultDurationMinutes?: number;
/** 캐시된 access token (만료 전까지 재사용). */
accessToken?: string;
/** access token 만료 epoch ms. */
accessTokenExpiresAt?: number;
/** 연결된 Google 계정 이메일 (UI 표시용). */
connectedAs?: string;
/** OAuth 연결 시각 ISO. */
connectedAt?: string;
}
export function readCalendarConfig(context: vscode.ExtensionContext): CalendarConfig {
const raw = context.globalState.get(CAL_CONFIG_KEY) as Partial<CalendarConfig> | undefined;
return {
icalUrl: typeof raw?.icalUrl === 'string' ? raw.icalUrl : '',
daysAhead: typeof raw?.daysAhead === 'number' && raw.daysAhead > 0 ? raw.daysAhead : 14,
lastFetchAt: typeof raw?.lastFetchAt === 'string' ? raw.lastFetchAt : undefined,
clientId: typeof raw?.clientId === 'string' ? raw.clientId : undefined,
clientSecret: typeof raw?.clientSecret === 'string' ? raw.clientSecret : undefined,
refreshToken: typeof raw?.refreshToken === 'string' ? raw.refreshToken : undefined,
calendarId: typeof raw?.calendarId === 'string' ? raw.calendarId : undefined,
defaultDurationMinutes: typeof raw?.defaultDurationMinutes === 'number' ? raw.defaultDurationMinutes : undefined,
accessToken: typeof raw?.accessToken === 'string' ? raw.accessToken : undefined,
accessTokenExpiresAt: typeof raw?.accessTokenExpiresAt === 'number' ? raw.accessTokenExpiresAt : undefined,
connectedAs: typeof raw?.connectedAs === 'string' ? raw.connectedAs : undefined,
connectedAt: typeof raw?.connectedAt === 'string' ? raw.connectedAt : undefined,
};
}
export async function writeCalendarConfig(context: vscode.ExtensionContext, patch: Partial<CalendarConfig>): Promise<void> {
const cur = readCalendarConfig(context);
const next: CalendarConfig = { ...cur, ...patch };
await context.globalState.update(CAL_CONFIG_KEY, next);
}
/** 회사 디렉토리 내부 캐시 파일 경로. workspace 없으면 globalStorage 로 fallback. */
function _cachePath(context: vscode.ExtensionContext): string {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'calendar_cache.md');
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'calendar_cache.md');
}
export interface RefreshResult {
ok: boolean;
count: number;
error?: string;
cachePath: string;
}
/**
* iCal URL 에서 fetch → ICS 파싱 → upcoming 필터 → 마크다운 캐시 파일에 쓰기.
* URL 비어있으면 즉시 ok:false 반환 (사용자 안내는 호출자가).
*/
export async function refreshCalendarCache(context: vscode.ExtensionContext): Promise<RefreshResult> {
const cfg = readCalendarConfig(context);
const cachePath = _cachePath(context);
if (!cfg.icalUrl) {
return { ok: false, count: 0, error: 'iCal URL 이 설정되지 않았습니다. 명령 팔레트에서 "Astra: Google Calendar 연결" 을 먼저 실행하세요.', cachePath };
}
if (!/^https?:\/\//.test(cfg.icalUrl)) {
return { ok: false, count: 0, error: 'URL 이 http:// 또는 https:// 로 시작하지 않습니다.', cachePath };
}
let raw: string;
try {
// Node 18+ 의 native fetch 사용 — axios / node-fetch 의존성 없이.
const res = await fetch(cfg.icalUrl, {
method: 'GET',
headers: { 'User-Agent': 'Astra-Calendar/1.0' },
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
return { ok: false, count: 0, error: `HTTP ${res.status} — URL 이 잘못됐거나 만료됐을 수 있습니다.`, cachePath };
}
raw = await res.text();
} catch (e: any) {
return { ok: false, count: 0, error: `다운로드 실패: ${e?.message ?? String(e)}`, cachePath };
}
const events = parseIcs(raw);
const upcoming = selectUpcoming(events, cfg.daysAhead);
const now = new Date();
const md = _renderMarkdown(upcoming, cfg.daysAhead, now);
try {
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
fs.writeFileSync(cachePath, md, 'utf8');
} catch (e: any) {
return { ok: false, count: 0, error: `캐시 저장 실패: ${e?.message ?? String(e)}`, cachePath };
}
await writeCalendarConfig(context, { lastFetchAt: now.toISOString() });
return { ok: true, count: upcoming.length, cachePath };
}
/** Agent prompt 에 주입할 캐시 본문 읽기. 없으면 빈 문자열. */
export function readCalendarCache(context: vscode.ExtensionContext): string {
const cachePath = _cachePath(context);
try {
if (!fs.existsSync(cachePath)) return '';
return fs.readFileSync(cachePath, 'utf8');
} catch {
return '';
}
}
function _renderMarkdown(events: IcsEvent[], daysAhead: number, now: Date): string {
const tsLabel = (d: Date, allDay: boolean) => {
const yy = d.getFullYear(), mm = String(d.getMonth() + 1).padStart(2, '0'), dd = String(d.getDate()).padStart(2, '0');
const wk = ['일', '월', '화', '수', '목', '금', '토'][d.getDay()];
if (allDay) return `${yy}-${mm}-${dd} (${wk})`;
const HH = String(d.getHours()).padStart(2, '0'), MM = String(d.getMinutes()).padStart(2, '0');
return `${yy}-${mm}-${dd} (${wk}) ${HH}:${MM}`;
};
const lines: string[] = [
'# 📅 다가오는 일정 (Google Calendar)',
`_업데이트: ${tsLabel(now, false)} · 향후 ${daysAhead}일_`,
'',
];
if (events.length === 0) {
lines.push('_없음_');
} else {
for (const ev of events) {
const ts = tsLabel(ev.start, ev.allDay);
const loc = ev.location ? ` — 📍 ${ev.location}` : '';
lines.push(`- **${ts}** · ${ev.summary}${loc}`);
}
}
return lines.join('\n') + '\n';
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Minimal ICS parser — no library deps. Connect_origin 의 Python 버전을
* 그대로 옮겼고, 본 함수는 *pure* 라서 단위테스트가 쉽다.
*
* 처리 범위:
* - VEVENT 블록 추출
* - line continuation (다음 줄이 공백 시작) 펼치기
* - SUMMARY / DESCRIPTION / LOCATION / DTSTART / DTEND 필드
* - DTSTART;VALUE=DATE → all-day 이벤트 표시
* - YYYYMMDD / YYYYMMDDTHHMMSS / ...Z (UTC) 포맷 모두 처리
*
* 처리 안 함 (필요해지면 v2):
* - RRULE 반복 일정 (단일 instance 만 표시)
* - TZID 타임존 변환 (UTC 가 아니면 로컬로 가정)
* - VTIMEZONE 블록
* - 첨부 / 참석자 / 알림
*/
export interface IcsEvent {
/** 시작 시각 (로컬 Date 기준). all-day 일정은 자정. */
start: Date;
/** 종료 시각. 없으면 undefined. */
end?: Date;
/** 제목 (없으면 '(제목 없음)'). */
summary: string;
location: string;
description: string;
/** DTSTART 가 VALUE=DATE 형식이었으면 true — 시각은 무시하고 날짜만 의미. */
allDay: boolean;
}
/** 한 문자열의 ICS 본문을 받아 VEVENT 들을 배열로 반환. 잘못된 입력은 빈 배열. */
export function parseIcs(raw: string): IcsEvent[] {
if (!raw || typeof raw !== 'string') return [];
// Line continuation: ICS 는 75자 wrap 시 다음 줄이 공백/탭으로 시작 → 합쳐줘야 한다.
const unfolded = raw.replace(/\r?\n[ \t]/g, '');
const events: IcsEvent[] = [];
let cur: Record<string, string> | null = null;
let curDateOnly: Record<'start' | 'end', boolean> = { start: false, end: false };
for (const rawLine of unfolded.split('\n')) {
const line = rawLine.replace(/\r$/, '');
if (line === 'BEGIN:VEVENT') {
cur = {};
curDateOnly = { start: false, end: false };
} else if (line === 'END:VEVENT') {
if (cur) {
const ev = _toEvent(cur, curDateOnly);
if (ev) events.push(ev);
}
cur = null;
} else if (cur && line.includes(':')) {
const colonIdx = line.indexOf(':');
const keyPart = line.slice(0, colonIdx);
const value = line.slice(colonIdx + 1);
const base = keyPart.split(';', 1)[0];
if (base === 'SUMMARY' || base === 'DESCRIPTION' || base === 'LOCATION'
|| base === 'DTSTART' || base === 'DTEND') {
cur[base] = value;
if ((base === 'DTSTART' || base === 'DTEND') && keyPart.includes(';VALUE=DATE')) {
curDateOnly[base === 'DTSTART' ? 'start' : 'end'] = true;
}
}
}
}
return events;
}
/** 시작 시각 기준 오름차순 정렬 + 현재 시각 - 1시간 ~ 미래 cutoffDays 범위만 필터. */
export function selectUpcoming(events: IcsEvent[], daysAhead: number, now: Date = new Date()): IcsEvent[] {
const past = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
const cutoff = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
return events
.filter((e) => e.start >= past && e.start <= cutoff)
.sort((a, b) => a.start.getTime() - b.start.getTime());
}
function _toEvent(raw: Record<string, string>, dateOnly: { start: boolean; end: boolean }): IcsEvent | null {
const start = _parseDt(raw.DTSTART ?? '');
if (!start) return null;
const end = _parseDt(raw.DTEND ?? '');
return {
start,
end: end ?? undefined,
summary: _unescape(raw.SUMMARY ?? '(제목 없음)'),
location: _unescape(raw.LOCATION ?? ''),
description: _unescape(raw.DESCRIPTION ?? ''),
allDay: dateOnly.start,
};
}
function _parseDt(s: string): Date | null {
if (!s) return null;
const trimmed = s.trim();
// Strip trailing Z (UTC marker — Date 파싱 시 자동 처리되지 않으므로 명시 변환).
const utc = trimmed.endsWith('Z');
const core = utc ? trimmed.slice(0, -1) : trimmed;
// Two valid forms: YYYYMMDDTHHMMSS or YYYYMMDD
const m = core.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2}))?$/);
if (!m) return null;
const [, yy, mm, dd, HH, MM, SS] = m;
const year = parseInt(yy, 10), month = parseInt(mm, 10) - 1, day = parseInt(dd, 10);
const hour = HH ? parseInt(HH, 10) : 0;
const min = MM ? parseInt(MM, 10) : 0;
const sec = SS ? parseInt(SS, 10) : 0;
if (utc) {
return new Date(Date.UTC(year, month, day, hour, min, sec));
}
return new Date(year, month, day, hour, min, sec);
}
function _unescape(s: string): string {
// ICS literal escapes: \, \; \n \\
return s.replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\n/g, ' ').replace(/\\\\/g, '\\');
}
+32
View File
@@ -0,0 +1,32 @@
export {
parseIcs,
selectUpcoming,
IcsEvent,
} from './icsParser';
export {
CAL_CONFIG_KEY,
CalendarConfig,
readCalendarConfig,
writeCalendarConfig,
refreshCalendarCache,
readCalendarCache,
RefreshResult,
} from './calendarCache';
export {
runOAuthLoopback,
refreshAccessToken,
fetchUserEmail,
OAuthResult,
OAuthFailure,
} from './oauth';
export {
createCalendarEvent,
CalendarEventInput,
CreatedEvent,
_buildEventBody,
_addMinutesIso,
_addDaysDate,
} from './calendarApi';
+235
View File
@@ -0,0 +1,235 @@
/**
* Google OAuth 2.0 — loopback (Desktop app) 흐름.
*
* Google 은 Desktop 앱 OAuth client 에 대해 http://127.0.0.1:<ephemeral_port>
* redirect URI 를 허용한다. 본 모듈은:
* 1. ephemeral port 에 일회용 HTTP 서버 띄움
* 2. 사용자 브라우저로 Google 로그인 페이지 열기
* 3. 콜백으로 code 받기
* 4. code → access/refresh token 교환
* 5. 서버 종료
*
* 보안: refresh token 은 호출자가 globalState 에 저장 (machine-local).
* Client ID/Secret 도 같이 저장하지만, Desktop app 의 client secret 은
* Google 가이드에 따라 *공개 가능* (혼동 방지: 진짜 비밀이 아니라 식별자).
* refresh token 이 사실상 진짜 비밀.
*/
import * as http from 'http';
import * as crypto from 'crypto';
import * as vscode from 'vscode';
// Calendar 와 Sheets 양쪽 권한을 한 번에 요청 — 사용자가 OAuth 한 번 하면 둘 다 동작.
// 옛 사용자(Calendar 만 연결)는 Sheets 사용 시 권한 부족 에러 → 재연결 필요.
const SCOPE = [
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/spreadsheets',
'openid',
'email',
].join(' ');
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
export interface OAuthResult {
ok: true;
accessToken: string;
refreshToken: string;
/** Google 가 동의한 scope 들 (공백 구분). */
scope: string;
/** access token 만료 epoch ms. */
expiresAt: number;
}
export interface OAuthFailure { ok: false; error: string; }
/**
* 풀 OAuth flow — 사용자가 cancel 누르면 cancelToken trip → ok:false.
*
* @param clientId Google Cloud 의 OAuth Client ID
* @param clientSecret 같은 페이지의 Client Secret
* @param cancelToken VS Code Progress 의 취소 토큰
*/
export async function runOAuthLoopback(
clientId: string,
clientSecret: string,
cancelToken: vscode.CancellationToken,
): Promise<OAuthResult | OAuthFailure> {
return new Promise<OAuthResult | OAuthFailure>((resolve) => {
let _settled = false;
const settle = (v: OAuthResult | OAuthFailure) => { if (_settled) return; _settled = true; resolve(v); };
// CSRF 방어 — state 파라미터 검증.
const expectedState = crypto.randomBytes(16).toString('hex');
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
const code = url.searchParams.get('code');
const err = url.searchParams.get('error');
const stateParam = url.searchParams.get('state');
// favicon / 빈 callback 요청은 무시 (브라우저 자동 요청)
if (!code && !err) { res.writeHead(204); res.end(); return; }
if (stateParam !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('state mismatch — possible CSRF. 다시 시도하세요.');
server.close();
settle({ ok: false, error: 'state mismatch' });
return;
}
if (err) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('실패', err, false));
server.close();
settle({ ok: false, error: err });
return;
}
// Got the code — exchange for tokens.
const port = (server.address() as any)?.port;
const redirectUri = `http://127.0.0.1:${port}`;
const body = new URLSearchParams({
code: code!,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
});
try {
const tokenRes = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
const json: any = await tokenRes.json().catch(() => ({}));
if (!tokenRes.ok || !json.access_token || !json.refresh_token) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('토큰 교환 실패', JSON.stringify(json).slice(0, 300), false));
server.close();
settle({ ok: false, error: json.error_description || json.error || 'no refresh_token in response' });
return;
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('연결 완료', '이 탭은 닫아도 됩니다.', true));
server.close();
settle({
ok: true,
accessToken: json.access_token,
refreshToken: json.refresh_token,
scope: json.scope ?? '',
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
});
} catch (e: any) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(_renderHtml('네트워크 오류', e?.message ?? String(e), false));
server.close();
settle({ ok: false, error: e?.message ?? String(e) });
}
} catch (e: any) {
try { res.writeHead(500); res.end(); } catch { /* ignore */ }
server.close();
settle({ ok: false, error: e?.message ?? String(e) });
}
});
// ephemeral port (0) — Desktop OAuth client 는 어떤 localhost port 도 허용.
server.listen(0, '127.0.0.1', () => {
const port = (server.address() as any)?.port;
const redirectUri = `http://127.0.0.1:${port}`;
const authUrl = `${AUTH_URL}?` + new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: SCOPE,
access_type: 'offline',
prompt: 'consent', // refresh_token 을 *항상* 발급받기 위해 강제 (Google 의 default 는 처음 한 번만 발급).
state: expectedState,
}).toString();
void vscode.env.openExternal(vscode.Uri.parse(authUrl));
});
// 사용자 cancel 시 서버 닫고 종료.
cancelToken.onCancellationRequested(() => {
try { server.close(); } catch { /* ignore */ }
settle({ ok: false, error: 'cancelled' });
});
// 안전망 — 5분 무응답 시 자동 종료.
const tHandle = setTimeout(() => {
try { server.close(); } catch { /* ignore */ }
settle({ ok: false, error: 'timeout (5분)' });
}, 5 * 60 * 1000);
cancelToken.onCancellationRequested(() => clearTimeout(tHandle));
});
}
/** refresh_token 으로 새 access_token 발급. 만료된 access token 자동 갱신용. */
export async function refreshAccessToken(
clientId: string,
clientSecret: string,
refreshToken: string,
): Promise<{ ok: true; accessToken: string; expiresAt: number } | { ok: false; error: string }> {
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
refresh_token: refreshToken,
grant_type: 'refresh_token',
});
try {
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok || !json.access_token) {
return { ok: false, error: json.error_description || json.error || `HTTP ${res.status}` };
}
return {
ok: true,
accessToken: json.access_token,
expiresAt: Date.now() + (Number(json.expires_in ?? 3600) - 60) * 1000,
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/** access token 으로 사용자 이메일 조회 — 누가 연결됐는지 보여주기 위함. */
export async function fetchUserEmail(accessToken: string): Promise<string> {
try {
const res = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return '';
const json: any = await res.json();
return json?.email ?? json?.name ?? '';
} catch {
return '';
}
}
/** 셋업 완료 페이지 — 깔끔한 메시지 + 자동으로 탭 닫기 안내. */
function _renderHtml(title: string, msg: string, success: boolean): string {
const color = success ? '#10b981' : '#ef4444';
const icon = success ? '✅' : '⚠️';
return `<!doctype html><html lang="ko"><head><meta charset="utf-8">
<title>Astra · ${title}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#0b1018;color:#e2e8f0;font-family:-apple-system,system-ui,sans-serif}
.card{background:rgba(20,28,40,.96);border:1px solid ${color};border-radius:14px;padding:36px 32px;max-width:420px;text-align:center;box-shadow:0 24px 80px rgba(0,0,0,.6)}
.icon{font-size:48px;margin-bottom:12px}
h1{font-size:20px;margin-bottom:12px;color:${color}}
p{color:#94a3b8;font-size:13px;line-height:1.6}
small{color:#475569;font-size:11px;margin-top:18px;display:block}
</style></head><body>
<div class="card"><div class="icon">${icon}</div><h1>${title}</h1><p>${_esc(msg)}</p><small>이 탭은 닫아도 됩니다.</small></div>
</body></html>`;
}
function _esc(s: string): string {
return String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' } as Record<string, string>)[c],
);
}
+16 -1
View File
@@ -108,7 +108,22 @@ export const COMPANY_AGENTS: Record<string, CompanyAgentDef> = {
specialty: '일정·마일스톤 관리, 스프린트·간트 차트, 리소스 배분·우선순위 조정, 리스크 추적·완화, 회의 노트·의사결정 로그, 데일리 스탠드업, 다른 에이전트 산출물 요약 보고, 알림·리마인더, 이해관계자 커뮤니케이션',
tagline: '일정·리소스·소통을 챙기고 정리합니다',
roleCategory: 'support',
persona: '친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.',
persona: `친근하고 정중하지만 일정 앞에서는 단호한 톤. 짧고 정리된 문장. 보고할 때는 한눈에 보이게 불릿 + 핵심만 (날짜·담당·상태). 이모지는 😊·📅·✅ 정도.
**회의록·트랜스크립트·요청 입력 시 자동 분배 패턴 (당신의 핵심 업무):**
입력에서 다음 4종을 *각각 따로* 추출하고 *각각의 액션 태그* 로 즉시 emit:
1. **확정 일정** (시각이 명확한 약속/미팅/마감) → \`<create_calendar_event>\` 로 Google Calendar 등록 + \`<add_task>\` 로 추적기에도 동시 등록.
2. **할일** (시각 없거나 모호한 to-do, 책임 명확) → \`<add_task>\` 로 추적기에만 등록. 시각 확정 안 됐으면 due 비움.
3. **결정 사항** (방향성·합의) → 별도 액션 없이 답변 본문 "## 결정" 섹션에 한 줄씩 정리 (decisions.md 는 시스템이 자동).
4. **시각 모호** ("다음주", "조만간") → 액션 태그 emit 금지. 답변 마지막에 "❓ 확정 필요: …" 로 질문.
**진척 추적**: 사용자가 "어제 X 끝냈어" / "Y 블락됐어" 같은 보고를 하면 *즉시* \`<update_task>\` 또는 \`<complete_task>\` emit. "잘 챙겨드릴게요" 라고 말만 하지 말고 태그로 실제 갱신.
**답변 마지막 한 줄 요약** (사용자가 무엇이 등록됐는지 즉시 확인):
- 📅 등록: 제목 · 시각
- 📋 추가: 제목 · 담당 · 마감
- ✅ 완료: 제목`,
},
writer: {
id: 'writer',
+17
View File
@@ -566,6 +566,21 @@ async function _dispatchOne(
}
const memory = readAgentMemory(deps.context, agentId);
const decisions = readDecisions(deps.context, 2000);
// Google Calendar iCal 캐시 (선택 사항). 셋업 안 된 사용자는 빈 문자열 → 무시.
// 매 dispatch 마다 디스크 read 1회 발생하지만 캐시는 KB 단위라 비용 무시 가능.
let calendarContext = '';
try {
const { readCalendarCache } = require('../calendar') as typeof import('../calendar');
calendarContext = readCalendarCache(deps.context) ?? '';
} catch { /* feature 미설치 / 캐시 없음 — silent skip */ }
// Task tracker — _shared/tasks.md 의 active 항목 요약. 모든 agent 가 진척 상황을
// 한 눈에 볼 수 있도록. 비어있으면 빈 문자열 → 프롬프트에서 섹션 자체 생략.
let tasksContext = '';
try {
const { readTaskStore, summarizeActiveTasks } = require('../tasks') as typeof import('../tasks');
tasksContext = summarizeActiveTasks(readTaskStore(deps.context));
} catch { /* silent */ }
const peerOutputs = earlierOutputs
.filter((o) => !o.error) // skip failed peers — they'd just confuse the next agent
.map((o) => {
@@ -624,6 +639,8 @@ async function _dispatchOne(
const system = buildSpecialistPrompt({
agentId, state,
agentMemory: memory, sharedDecisions: decisions,
calendarContext,
tasksContext,
peerOutputs,
brainContext, // injected as `[SECOND BRAIN CONTEXT]` block
knowledgeMixPolicy: policyBlock, // injected as `[KNOWLEDGE MIX POLICY]` block
+30 -2
View File
@@ -239,12 +239,40 @@ const PLAN_ONLY: PipelineTemplate = {
],
};
/** Read-only registry of templates the UI surfaces. Add more here later. */
/**
* "개발까지만" — FULL_PRODUCT_DEV 의 1~10 단계 (plan-discuss ~ dev-impl) 만 진행.
* QA·배포는 사용자가 코드 받아본 뒤 수동 처리하길 원하는 경우. dev-impl 종료
* 직후 CEO 합산 보고로 turn 종료. design-review 의 dev-design loop-back 은 유지.
*
* stages 는 FULL_PRODUCT_DEV.stages.slice(0, 10) 으로 *참조 공유* — 사용자가
* 템플릿 stamp 시 deep-copy 되므로 본 정의 객체는 read-only 안전.
*/
const DEV_ONLY: PipelineTemplate = {
templateId: 'dev-only',
name: '개발까지만 (10단계)',
description: '풀 워크플로에서 QA·배포 단계를 뺀 버전. 기획 → 개발까지 만들고 검증·배포는 사용자가 직접 챙깁니다.',
suggestedPipelineId: 'dev-only',
suggestedPipelineName: '기획→개발 파이프라인',
stages: FULL_PRODUCT_DEV.stages.slice(0, 10),
};
/** Read-only registry of templates the UI surfaces. */
export const PIPELINE_TEMPLATES: PipelineTemplate[] = [
FULL_PRODUCT_DEV,
PLAN_ONLY,
DEV_ONLY,
FULL_PRODUCT_DEV,
];
/**
* 스코프 단축 표기 — 사용자가 사이드바에서 한 번에 보고 선택할 수 있는 3-way 라벨.
* 각 키는 PIPELINE_TEMPLATES 의 templateId 와 1:1. 매핑 변경 시 UI 동기화 필요.
*/
export const SCOPE_PRESETS = [
{ templateId: 'plan-only', shortLabel: '기획만', longLabel: '기획서까지만' },
{ templateId: 'dev-only', shortLabel: '개발까지', longLabel: '개발까지만' },
{ templateId: 'full-product-dev', shortLabel: '풀', longLabel: '배포까지 풀 파이프라인' },
] as const;
export function getPipelineTemplate(id: string): PipelineTemplate | undefined {
return PIPELINE_TEMPLATES.find((t) => t.templateId === id);
}
+54
View File
@@ -28,6 +28,18 @@ export interface SpecialistPromptInputs {
agentMemory?: string;
/** Tail of `_shared/decisions.md` (may be empty). */
sharedDecisions?: string;
/**
* `_shared/calendar_cache.md` 내용 (Google Calendar iCal 읽기 전용 feature).
* 비어있으면 prompt 에 섹션 추가 안 함. 모든 agent 가 동일하게 받음 — 일정·운영을
* 다루는 secretary 직군이 가장 활용 가능성 높지만, 기획·개발도 "이번 주 빈 슬롯"
* 같은 추론에 쓸 수 있음.
*/
calendarContext?: string;
/**
* Task tracker (`_shared/tasks.md`) active 항목 요약. 모든 agent 가 진척 상황 + 막힌
* task 를 인지하고 일을 분배·재조정. 비어있으면 섹션 생략.
*/
tasksContext?: string;
/**
* Peer outputs from earlier agents in *this* dispatch, in execution order.
* Truncated by the dispatcher before passing — this builder doesn't trim
@@ -121,6 +133,28 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
parts.push(' • `<delete_file path="..."/>` — 파일·디렉토리 삭제');
parts.push(' • `<list_files path="..."/>` — 디렉토리 목록 보기');
parts.push(' • `<run_command>명령</run_command>` — 셸 실행 (디렉토리 생성 등)');
parts.push(' • `<create_calendar_event title="..." start="2026-05-21T14:00" duration="60" location="...">설명</create_calendar_event>` — Google Calendar 일정 자동 생성 (OAuth 연결 필요)');
parts.push(' • `<read_sheet spreadsheet_id="..." range="Sheet1!A1:D20"/>` — Google Sheets 셀 범위 읽기');
parts.push(' • `<write_sheet spreadsheet_id="..." range="Sheet1!A1">TSV 본문</write_sheet>` — 셀 덮어쓰기 (탭 구분, 줄바꿈 = 행)');
parts.push(' • `<append_sheet spreadsheet_id="..." range="Sheet1!A:C">TSV 본문</append_sheet>` — 마지막 데이터 행 아래에 append');
parts.push(' • `<add_task title="..." owner="@me" due="2026-05-24T18:00" notes="..."/>` — 작업 추적기에 task 추가');
parts.push(' • `<update_task id="t_001" status="in_progress|blocked|done" notes="..."/>` — 진척·blocker 갱신');
parts.push(' • `<complete_task id="t_001"/>` — task 완료 처리 (done 섹션으로 이동)');
parts.push('');
parts.push('📋 **Task 사용 시점**:');
parts.push('- 회의록·요청 처리 중 *명확한 할일* 마다 add_task 1개씩 emit. 추측·확장 금지.');
parts.push('- 사용자가 진척 보고하면 즉시 update_task / complete_task. "추적해뒀어요" 라고 말만 하지 말 것.');
parts.push('- due 가 분 단위로 명확하면 add_task + create_calendar_event 함께 emit — 추적기에도, 캘린더에도.');
parts.push('');
parts.push('📅 **Calendar 사용 시점**:');
parts.push('- 사용자가 회의록 / 안건 / "X 일까지 끝내야 해" 같이 *명확한 시각이 있는* 약속·작업을 공유했을 때.');
parts.push('- 시간이 모호하면 ("다음주에", "조만간") 태그 emit 하지 말고 *물어봐서 확정* 한 뒤 emit.');
parts.push('- 회의록에 여러 일정/할일이 섞여 있으면 *각각 1개 태그씩* emit. 한 태그에 여러 일정 욱여넣지 말 것.');
parts.push('');
parts.push('📊 **Sheets 사용 시점**:');
parts.push('- spreadsheet_id 는 사용자가 *직접 알려준 것만* 사용. 추측 / 생성 금지.');
parts.push('- 쓰기 전엔 한 줄로 "어디에 무엇을 쓰겠다" 미리 보고. 사용자가 stop 안 걸면 즉시 태그 emit.');
parts.push('- 본문은 TSV (탭 구분, 줄바꿈 = 행). 한 줄에 칼럼이 여러개면 탭으로 구분.');
parts.push('');
parts.push('🛑 **경로 규칙 (위반 시 권한 거부됨)**:');
parts.push('- 경로는 **워크스페이스 루트 상대 경로**로 쓰세요. 예: `timertest/timer.py`, `src/utils.py`');
@@ -188,6 +222,26 @@ export function buildSpecialistPrompt(inputs: SpecialistPromptInputs): string {
parts.push(decisions);
}
// ── Google Calendar (iCal 읽기 전용) ──
// 모든 agent 가 받는 외부 컨텍스트 — "이번 주 화/목 14:00-16:00 비어있다",
// "내일 13:00 미팅" 같은 정보. 캐시 파일은 g1nation.calendar.refresh 명령 또는
// 셋업 시점에 갱신됨 — turn 마다 fetch 하면 외부 의존성 + 지연 발생하므로 보수적.
const calendarCtx = (inputs.calendarContext ?? '').trim();
if (calendarCtx) {
parts.push('');
parts.push('## 사용자 일정 컨텍스트 (Google Calendar)');
parts.push(calendarCtx);
}
// ── Task tracker — active 작업 한 눈에 ──
const tasksCtx = (inputs.tasksContext ?? '').trim();
if (tasksCtx) {
parts.push('');
parts.push('## 진행 중인 작업 (tasks.md · active)');
parts.push('아래는 회사 작업 추적기에 active 로 들어있는 task 들입니다. 진척 / 막힌 항목 / 다가오는 마감을 인지하고 답변에 반영하세요. 사용자가 진척을 알려주면 `<update_task>` 또는 `<complete_task>` 로 즉시 갱신.');
parts.push(tasksCtx);
}
// ── Self-Reflector Phase A 룰 (가장 마지막에 prepend) ──
// 답변 끝에 [Self-Reflector Check] 블록을 자동으로 붙이게 만든다. developer
// 직군이면 코드 가드 블록도 함께 — undefined variable / 잘못된 경로 같은
+13
View File
@@ -0,0 +1,13 @@
export {
readSheetRange,
writeSheetRange,
appendSheetRows,
parseTsvBody,
valuesToMarkdownTable,
SheetCell,
SheetValues,
ReadResult,
WriteResult,
AppendResult,
ApiFailure,
} from './sheetsApi';
+166
View File
@@ -0,0 +1,166 @@
/**
* Google Sheets API v4 — read / write / append.
*
* 토큰은 calendar 와 공유 (같은 OAuth 에 spreadsheets scope 포함). 별도 셋업 없음 —
* "Astra: Google Calendar OAuth 연결" 명령으로 한 번 로그인하면 둘 다 동작한다.
*
* 외부 라이브러리 안 씀 — Sheets API REST + native fetch.
*/
import * as vscode from 'vscode';
import { getFreshAccessToken } from '../calendar/calendarApi';
const API_BASE = 'https://sheets.googleapis.com/v4/spreadsheets';
/** 2D 값 배열 — 각 셀은 string | number | boolean (Sheets API valueType). */
export type SheetCell = string | number | boolean | null;
export type SheetValues = SheetCell[][];
export interface ReadResult { ok: true; values: SheetValues; range: string; }
export interface WriteResult { ok: true; updatedCells: number; updatedRange: string; }
export interface AppendResult extends WriteResult { appendedRange: string; }
export interface ApiFailure { ok: false; error: string; }
/**
* range 의 셀들을 2D 배열로 읽기. 빈 셀은 빈 문자열로 패딩되지 않음 — Sheets API 는
* 마지막 비어있지 않은 셀까지만 반환. caller 가 필요하면 normalize.
*/
export async function readSheetRange(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
): Promise<ReadResult | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}`;
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${tok.accessToken}` },
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) {
return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
}
return { ok: true, values: (json.values ?? []) as SheetValues, range: json.range ?? range };
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/**
* range 의 셀을 values 로 덮어쓰기. range 의 좌상단부터 values 배열만큼 채움.
* Sheets API 의 valueInputOption='USER_ENTERED' 사용 → "=A1+B1" 같은 수식은 수식으로,
* 숫자/날짜 문자열은 Sheets 가 자동 파싱.
*/
export async function writeSheetRange(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
values: SheetValues,
): Promise<WriteResult | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
`?valueInputOption=USER_ENTERED`;
try {
const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${tok.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
return { ok: true, updatedCells: Number(json.updatedCells ?? 0), updatedRange: json.updatedRange ?? range };
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
/**
* 값 추가 — range 안에서 *가장 마지막으로 데이터가 있는 행 아래* 에 새 행으로 append.
* 로그·일지·트래킹 시트에 유용.
*/
export async function appendSheetRows(
context: vscode.ExtensionContext,
spreadsheetId: string,
range: string,
values: SheetValues,
): Promise<AppendResult | ApiFailure> {
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
if (!Array.isArray(values) || values.length === 0) return { ok: false, error: 'values 비어있음' };
const tok = await getFreshAccessToken(context);
if (!tok.ok) return { ok: false, error: tok.error };
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}` +
`:append?valueInputOption=USER_ENTERED&insertDataOption=INSERT_ROWS`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${tok.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ range, values, majorDimension: 'ROWS' }),
signal: AbortSignal.timeout(15000),
});
const json: any = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
const upd = json.updates ?? {};
return {
ok: true,
updatedCells: Number(upd.updatedCells ?? 0),
updatedRange: upd.updatedRange ?? range,
appendedRange: upd.updatedRange ?? range,
};
} catch (e: any) {
return { ok: false, error: e?.message ?? String(e) };
}
}
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
/**
* action tag 본문(여러 줄, 탭/파이프 구분) → 2D SheetValues 변환.
* - 우선 탭(\t) 으로 split — 진짜 TSV. LLM 이 보통 이걸 emit.
* - 탭이 한 칸도 없으면 ` | ` 파이프 구분으로 fallback (양 옆 공백 1개씩).
* - 빈 줄은 무시 (trailing newline 안전).
* 셀 값은 모두 문자열 — Sheets API 가 USER_ENTERED 로 자동 형변환.
*/
export function parseTsvBody(body: string): SheetValues {
if (!body || typeof body !== 'string') return [];
// 공백·탭·개행만 있는 입력은 빈 배열로 — LLM 이 빈 본문 emit 했을 때 안전.
if (!body.trim()) return [];
const trimmed = body.replace(/^\s*\n+/, '').replace(/\n+\s*$/, '');
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length === 0) return [];
const useTab = lines.some((l) => l.includes('\t'));
return lines.map((line) =>
useTab ? line.split('\t') : line.split(/\s*\|\s*/),
);
}
/** 결과 2D 배열을 LLM 친화적 짧은 마크다운 테이블로 (read 결과를 chat 에 주입할 때). */
export function valuesToMarkdownTable(values: SheetValues, maxRows: number = 50): string {
if (!values.length) return '_(empty)_';
const truncated = values.slice(0, maxRows);
const rendered = truncated.map((row) => '| ' + row.map((c) => String(c ?? '').replace(/\|/g, '\\|')).join(' | ') + ' |');
if (rendered.length === 0) return '_(empty)_';
// 헤더 구분선 — 첫 행을 헤더로 가정.
const cols = truncated[0]?.length ?? 1;
const sep = '|' + Array(cols).fill('---').join('|') + '|';
const result = [rendered[0], sep, ...rendered.slice(1)].join('\n');
if (values.length > maxRows) {
return result + `\n_(... ${values.length - maxRows} more rows truncated)_`;
}
return result;
}
+13
View File
@@ -0,0 +1,13 @@
export {
Task,
TaskStatus,
TaskStore,
readTaskStore,
writeTaskStore,
parseTaskStore,
renderTaskStore,
addTask,
updateTask,
completeTask,
summarizeActiveTasks,
} from './taskStore';
+245
View File
@@ -0,0 +1,245 @@
/**
* Task tracker — `.astra/company/_shared/tasks.md` 단일 파일.
*
* 설계 원칙:
* - 외부 DB 없이 *사람이 읽을 수 있는* 마크다운 테이블에 누적. git 으로 history 추적 가능.
* - 파싱은 regex 기반 (셀 구분자 `|`). 사용자가 손으로 편집해도 fault-tolerant.
* - 모든 task 는 안정적 id (t_001, t_002, ...) — agent 가 update/complete 시 식별자로 사용.
* - active / done 두 섹션. done 은 최근 N개만 보관 (오래된 건 archive).
*
* 파일 포맷:
*
* # Tasks
*
* ## Active
* | ID | Title | Owner | Due | Status | Notes |
* |---|---|---|---|---|---|
* | t_001 | 광고주 자료 정리 | @me | 2026-05-24T18:00 | in_progress | 자료 대기 |
* | t_002 | 디자인 리뷰 준비 | @planner | 2026-05-21T13:00 | open | |
*
* ## Done (recent 10)
* | ID | Title | Owner | Completed |
* |---|---|---|---|
* | t_000 | 일정 셋업 | @me | 2026-05-20T10:00 |
*/
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'done';
export interface Task {
id: string;
title: string;
/** @me / @planner / @qa 같은 자유 형식. 정확한 agent id 와 무관해도 됨. */
owner: string;
/** ISO 'YYYY-MM-DDTHH:MM' 또는 빈 문자열. due 없는 task 도 허용. */
due: string;
status: TaskStatus;
notes: string;
/** done 으로 전환된 시각 ISO. status==='done' 일 때만 의미. */
completedAt?: string;
}
const DONE_KEEP_RECENT = 10;
function _tasksPath(context: vscode.ExtensionContext): string {
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (ws) return path.join(ws, '.astra', 'company', '_shared', 'tasks.md');
return path.join(context.globalStorageUri.fsPath, 'company', '_shared', 'tasks.md');
}
/** 전체 task store. active 와 done 분리. */
export interface TaskStore {
active: Task[];
done: Task[];
}
export function readTaskStore(context: vscode.ExtensionContext): TaskStore {
const p = _tasksPath(context);
try {
if (!fs.existsSync(p)) return { active: [], done: [] };
const md = fs.readFileSync(p, 'utf8');
return parseTaskStore(md);
} catch {
return { active: [], done: [] };
}
}
export function writeTaskStore(context: vscode.ExtensionContext, store: TaskStore): void {
const p = _tasksPath(context);
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, renderTaskStore(store), 'utf8');
} catch {
// silent fail — caller 가 다음 read 에서 빈 store 받게 됨.
}
}
// ────────────── parse ──────────────
export function parseTaskStore(md: string): TaskStore {
const active: Task[] = [];
const done: Task[] = [];
let section: 'active' | 'done' | null = null;
const lines = md.split(/\r?\n/);
for (const line of lines) {
if (/^##\s*Active/i.test(line)) { section = 'active'; continue; }
if (/^##\s*Done/i.test(line)) { section = 'done'; continue; }
if (!section) continue;
if (!line.trim().startsWith('|')) continue;
// header / separator 행 skip
if (/^\|\s*ID\b/i.test(line)) continue;
if (/^\|\s*[-:]+/.test(line)) continue;
const cells = _parseRow(line);
if (!cells || cells.length < 3) continue;
if (section === 'active') {
const [id, title, owner, due, status, notes] = cells;
if (!id || !id.startsWith('t_')) continue;
active.push({
id,
title: title ?? '',
owner: owner ?? '',
due: due ?? '',
status: _normalizeStatus(status),
notes: notes ?? '',
});
} else {
const [id, title, owner, completedAt] = cells;
if (!id || !id.startsWith('t_')) continue;
done.push({
id,
title: title ?? '',
owner: owner ?? '',
due: '',
status: 'done',
notes: '',
completedAt: completedAt ?? '',
});
}
}
return { active, done };
}
function _parseRow(line: string): string[] | null {
const t = line.trim();
if (!t.startsWith('|') || !t.endsWith('|')) return null;
const inner = t.slice(1, -1);
return inner.split('|').map((c) => c.trim().replace(/\\\|/g, '|'));
}
function _normalizeStatus(s: string | undefined): TaskStatus {
const v = String(s ?? 'open').trim().toLowerCase().replace(/\s+/g, '_');
if (v === 'in_progress' || v === 'inprogress' || v === 'progress') return 'in_progress';
if (v === 'blocked' || v === 'block') return 'blocked';
if (v === 'done' || v === 'completed' || v === 'closed') return 'done';
return 'open';
}
// ────────────── render ──────────────
export function renderTaskStore(store: TaskStore): string {
const lines: string[] = [
'# Tasks',
'',
'## Active',
'| ID | Title | Owner | Due | Status | Notes |',
'|---|---|---|---|---|---|',
];
for (const t of store.active) {
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.due)} | ${t.status} | ${_esc(t.notes)} |`);
}
if (store.active.length === 0) lines.push('_(no active tasks)_');
lines.push('');
lines.push(`## Done (recent ${DONE_KEEP_RECENT})`);
lines.push('| ID | Title | Owner | Completed |');
lines.push('|---|---|---|---|');
const recentDone = store.done.slice(-DONE_KEEP_RECENT);
for (const t of recentDone) {
lines.push(`| ${t.id} | ${_esc(t.title)} | ${_esc(t.owner)} | ${_esc(t.completedAt ?? '')} |`);
}
if (recentDone.length === 0) lines.push('_(no completed tasks)_');
return lines.join('\n') + '\n';
}
function _esc(s: string): string {
return String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
}
// ────────────── CRUD helpers ──────────────
/**
* 새 task 추가. id 는 active+done 전체 max + 1.
* 사용자가 같은 title 로 중복 요청해도 별개 task — dedup 책임은 agent.
*/
export function addTask(store: TaskStore, input: {
title: string;
owner?: string;
due?: string;
notes?: string;
status?: TaskStatus;
}): Task {
const allIds = [...store.active, ...store.done].map((t) => t.id);
const max = allIds.reduce((acc, id) => {
const m = id.match(/^t_(\d+)$/);
if (!m) return acc;
const n = parseInt(m[1], 10);
return n > acc ? n : acc;
}, 0);
const id = `t_${String(max + 1).padStart(3, '0')}`;
const task: Task = {
id,
title: input.title.trim(),
owner: (input.owner ?? '').trim(),
due: (input.due ?? '').trim(),
status: input.status ?? 'open',
notes: (input.notes ?? '').trim(),
};
store.active.push(task);
return task;
}
/** id 로 task 찾아 patch 적용. 못 찾으면 null. */
export function updateTask(store: TaskStore, id: string, patch: Partial<Task>): Task | null {
const idx = store.active.findIndex((t) => t.id === id);
if (idx < 0) return null;
const cur = store.active[idx];
const next: Task = { ...cur, ...patch, id: cur.id };
store.active[idx] = next;
return next;
}
/**
* task 를 done 으로 옮김. active 에서 빼고 done 에 push.
* 못 찾으면 null. 이미 done 이었으면 active 에는 없으므로 동일.
*/
export function completeTask(store: TaskStore, id: string, completedAt?: string): Task | null {
const idx = store.active.findIndex((t) => t.id === id);
if (idx < 0) return null;
const [t] = store.active.splice(idx, 1);
const closed: Task = { ...t, status: 'done', completedAt: completedAt ?? new Date().toISOString().slice(0, 16) };
store.done.push(closed);
return closed;
}
/** 프롬프트 주입용 — active 만 짧은 마크다운으로. due 가 가까운 순. */
export function summarizeActiveTasks(store: TaskStore, max: number = 12): string {
if (store.active.length === 0) return '';
const sorted = [...store.active].sort((a, b) => {
// due 있는 task 우선 (asc). 없는 건 뒤로.
if (a.due && !b.due) return -1;
if (!a.due && b.due) return 1;
return (a.due || '').localeCompare(b.due || '');
});
const shown = sorted.slice(0, max);
const lines = shown.map((t) => {
const due = t.due ? ` · 마감 ${t.due}` : '';
const owner = t.owner ? ` · ${t.owner}` : '';
const status = t.status !== 'open' ? ` [${t.status}]` : '';
const notes = t.notes ? `${t.notes}` : '';
return `- ${t.id}${status} ${t.title}${owner}${due}${notes}`;
});
if (sorted.length > max) lines.push(`- _(... ${sorted.length - max} more)_`);
return lines.join('\n');
}